Skip to main content

data_beans/import/
mtx.rs

1use super::{log_feature_type_histogram, Output, Staged};
2use crate::sparse_io::*;
3use crate::utilities::name_matching::{
4    colon_peak_names, id_name, make_names_unique, RowTypeFilter,
5};
6
7use legume_numeric::matrix::common_io::*;
8use log::info;
9
10/// A MatrixMarket matrix and its name files, as [`from_mtx`] reads them.
11#[derive(Clone, Debug)]
12pub struct MtxImport {
13    /// The matrix, `.mtx.gz` or `.mtx`: features as rows, cells as columns.
14    pub mtx: Box<str>,
15    /// Feature names, one per line (`.tsv.gz` or `.tsv`). For 10x
16    /// `features.tsv[.gz]` the columns are `id`, `name`, `feature_type`; the
17    /// third, when present, is what the row-type filters read. A BED-shaped
18    /// peaks file names its rows `chr:start-end`. `None` numbers them.
19    pub row: Option<Box<str>>,
20    /// Cell barcodes, one per line; `None` numbers them.
21    pub col: Option<Box<str>>,
22    /// How many columns of `row` name a feature: 2 joins id and name as
23    /// `id_name`, 1 keeps the id.
24    pub row_name_columns: usize,
25    /// Comma-separated patterns; a row is kept if its feature_type contains
26    /// any of them (case-insensitive). Empty keeps every row.
27    pub select_row_type: Box<str>,
28    /// Comma-separated patterns; a row whose feature_type contains one goes,
29    /// after `select_row_type`.
30    pub remove_row_type: Box<str>,
31    /// The feature type of HTO (multiplexing) tags, such as `Antibody
32    /// Capture`. When set, each cell is named `barcode@{id}_{name}` for the
33    /// HTO row with its highest count, written to
34    /// `{stem}.barcode_to_hto.tsv.gz`; HTO rows then go, and so do cells
35    /// without any HTO count. Empty turns this off.
36    pub hto_row_type: Box<str>,
37}
38
39impl MtxImport {
40    /// `mtx` with the CLI's defaults: rows numbered, 10x scRNA and ATAC
41    /// rows kept (`Gene Expression,Peaks`), no HTO.
42    pub fn new(mtx: &str) -> Self {
43        Self {
44            mtx: mtx.into(),
45            row: None,
46            col: None,
47            row_name_columns: 2,
48            select_row_type: "Gene Expression,Peaks".into(),
49            remove_row_type: "".into(),
50            hto_row_type: "".into(),
51        }
52    }
53}
54
55/// Build the backend `out` names from the matrix `mtx`; the path written.
56pub fn from_mtx(mtx: &MtxImport, out: &Output) -> anyhow::Result<Box<str>> {
57    stage_mtx(mtx, out)?.finish()
58}
59
60/// [`from_mtx`] short of squeezing and putting the backend in place.
61pub fn stage_mtx(args: &MtxImport, out: &Output) -> anyhow::Result<Staged> {
62    let mtx_file = args.mtx.as_ref();
63    let row_file = args.row.as_ref();
64    let col_file = args.col.as_ref();
65
66    let (staged, backend) = out.stage()?;
67    let backend_file = staged.working();
68    let effective_output = &staged.effective;
69
70    let mut data = create_sparse_from_mtx_file(mtx_file, Some(backend_file), Some(&backend))?;
71
72    // Row names + (optional) ids + feature types — parsed together from the
73    // features.tsv file so we can support the 10x 3-column layout.
74    //   col 1: id        col 2: name        col 3: feature_type (optional)
75    let parsed_rows = if let Some(row_file) = row_file {
76        let rows = read_mtx_feature_rows(row_file.as_ref(), args.row_name_columns)?;
77        Some(rows)
78    } else {
79        None
80    };
81
82    if let Some(rows) = parsed_rows.as_ref() {
83        let mut display = rows.build_display_names();
84        make_names_unique(&mut display);
85        data.register_row_names_vec(&display);
86    } else if let Some(nrow) = data.num_rows() {
87        let row_names: Vec<Box<str>> = (1..(nrow + 1)).map(|i| format!("{}", i).into()).collect();
88        data.register_row_names_vec(&row_names);
89    }
90
91    if let Some(col_file) = col_file {
92        data.register_column_names_file(col_file);
93    } else if let Some(ncol) = data.num_columns() {
94        let col_names: Vec<Box<str>> = (1..(ncol + 1)).map(|i| format!("{}", i).into()).collect();
95        data.register_column_names_vec(&col_names);
96    }
97
98    let nrow_backend = data.num_rows().unwrap_or(0);
99    let ncol_backend = data.num_columns().unwrap_or(0);
100    if let Some(rows) = parsed_rows.as_ref() {
101        if let Some(row_types) = rows.types.as_ref() {
102            log_feature_type_histogram(mtx_file, row_types);
103
104            let sel = RowTypeFilter::parse(&args.select_row_type);
105            let rem = RowTypeFilter::parse(&args.remove_row_type);
106            let hto = RowTypeFilter::parse(&args.hto_row_type);
107            let hto_enabled = !hto.is_empty();
108
109            let mut keep_rows: Vec<usize> = Vec::new();
110            let mut hto_rows: Vec<usize> = Vec::new();
111            for (i, t) in row_types.iter().enumerate() {
112                if hto_enabled && hto.matches(t) {
113                    hto_rows.push(i);
114                    continue;
115                }
116                let selected = sel.is_empty() || sel.matches(t);
117                let removed = !rem.is_empty() && rem.matches(t);
118                if selected && !removed {
119                    keep_rows.push(i);
120                }
121            }
122
123            let keep_cols: Option<Vec<usize>> = if hto_enabled && !hto_rows.is_empty() {
124                info!(
125                    "HTO: {} multiplexing rows matching '{}'",
126                    hto_rows.len(),
127                    args.hto_row_type
128                );
129
130                // HTO matrices are tiny (<~20 rows), so dense is fine.
131                let hto_dense = data.read_rows_dmatrix(hto_rows.clone())?;
132                assert_eq!(hto_dense.nrows(), hto_rows.len());
133                assert_eq!(hto_dense.ncols(), ncol_backend);
134
135                struct HtoAssign {
136                    col: usize,
137                    hto_row: usize,
138                    count: f32,
139                }
140                let mut assigns: Vec<HtoAssign> = Vec::with_capacity(ncol_backend);
141                let mut zero_cells = 0usize;
142                for j in 0..ncol_backend {
143                    let col = hto_dense.column(j);
144                    let mut best = 0usize;
145                    let mut best_val = col[0];
146                    let mut col_sum = 0.0f32;
147                    for (k, &v) in col.iter().enumerate() {
148                        col_sum += v;
149                        if v > best_val {
150                            best_val = v;
151                            best = k;
152                        }
153                    }
154                    if col_sum <= 0.0 {
155                        zero_cells += 1;
156                        continue;
157                    }
158                    assigns.push(HtoAssign {
159                        col: j,
160                        hto_row: hto_rows[best],
161                        count: best_val,
162                    });
163                }
164
165                let dropped_frac = if ncol_backend > 0 {
166                    zero_cells as f64 / ncol_backend as f64
167                } else {
168                    0.0
169                };
170                info!(
171                    "HTO: {}/{} cells have zero HTO signal and will be dropped ({:.1}%)",
172                    zero_cells,
173                    ncol_backend,
174                    dropped_frac * 100.0
175                );
176                if dropped_frac > 0.5 {
177                    log::warn!(
178                        "HTO: dropped more than half of cells ({}/{}); \
179                         check that the HTO library is consistently present",
180                        zero_cells,
181                        ncol_backend
182                    );
183                }
184
185                let tag_for = |hto_row: usize| id_name(&rows.ids[hto_row], &rows.names[hto_row]);
186
187                let mut col_names = data.column_names()?;
188
189                let output_stem = crate::hdf5_io::strip_backend_suffix(effective_output);
190                let hto_file = format!("{}.barcode_to_hto.tsv.gz", output_stem);
191                {
192                    use std::io::Write;
193                    let mut w = open_buf_writer(&hto_file)?;
194                    writeln!(w, "barcode\tid\tname\tcount")?;
195                    for a in &assigns {
196                        let bc = col_names[a.col].as_ref();
197                        let id = rows.ids[a.hto_row].as_ref();
198                        let name = rows.names[a.hto_row].as_ref();
199                        writeln!(w, "{}\t{}\t{}\t{}", bc, id, name, a.count)?;
200                    }
201                    w.flush()?;
202                }
203                info!("Wrote {}", hto_file);
204
205                for a in &assigns {
206                    col_names[a.col] =
207                        format!("{}@{}", col_names[a.col], tag_for(a.hto_row)).into_boxed_str();
208                }
209                data.register_column_names_vec(&col_names);
210
211                Some(assigns.into_iter().map(|a| a.col).collect::<Vec<_>>())
212            } else {
213                if hto_enabled {
214                    info!(
215                        "HTO: no rows match feature type '{}'; skipping multiplexing",
216                        args.hto_row_type
217                    );
218                }
219                None
220            };
221
222            let need_row_subset = keep_rows.len() < nrow_backend;
223            let need_col_subset = keep_cols.as_ref().is_some_and(|v| v.len() < ncol_backend);
224
225            if need_row_subset || need_col_subset {
226                info!(
227                    "from-mtx subset: rows {} -> {}, cols {} -> {}",
228                    nrow_backend,
229                    keep_rows.len(),
230                    ncol_backend,
231                    keep_cols.as_ref().map(|v| v.len()).unwrap_or(ncol_backend),
232                );
233                data.subset_columns_rows(keep_cols.as_ref(), Some(&keep_rows))?;
234            }
235        }
236    }
237
238    drop(data);
239    Ok(staged)
240}
241
242struct MtxFeatureRows {
243    ids: Vec<Box<str>>,
244    names: Vec<Box<str>>,
245    /// Tab-separated third column when present. `None` when the file had
246    /// fewer than three columns (row-type filtering is then skipped).
247    types: Option<Vec<Box<str>>>,
248    row_name_columns: usize,
249}
250
251impl MtxFeatureRows {
252    /// The display names registered on the backend: `id{ROW_SEP}name`
253    /// ([`id_name`]) with two or more name columns, else the id.
254    fn build_display_names(&self) -> Vec<Box<str>> {
255        if self.row_name_columns >= 2 {
256            self.ids
257                .iter()
258                .zip(&self.names)
259                .map(|(id, name)| id_name(id, name))
260                .collect()
261        } else {
262            self.ids.clone()
263        }
264    }
265}
266
267fn read_mtx_feature_rows(
268    row_file: &str,
269    row_name_columns: usize,
270) -> anyhow::Result<MtxFeatureRows> {
271    use legume_numeric::matrix::common_io::read_lines_of_words_delim;
272
273    // 10x features.tsv is tab-separated and the 3rd column (feature_type)
274    // can contain spaces ("Gene Expression", "Antibody Capture"), so
275    // whitespace splitting would corrupt it.
276    let lines = read_lines_of_words_delim(row_file, &['\t'], -1)?.lines;
277    let n = lines.len();
278    let mut ids = Vec::with_capacity(n);
279    let mut names = Vec::with_capacity(n);
280    let mut types = Vec::with_capacity(n);
281    let mut any_type = false;
282
283    for words in lines.into_iter() {
284        let id: Box<str> = words.first().cloned().unwrap_or_else(|| "".into());
285        let name: Box<str> = words.get(1).cloned().unwrap_or_else(|| "".into());
286        let ty: Box<str> = words.get(2).cloned().unwrap_or_else(|| "".into());
287        if !ty.is_empty() {
288            any_type = true;
289        }
290        ids.push(id);
291        names.push(name);
292        types.push(ty);
293    }
294
295    // BED-shaped peaks file (e.g. 10x ATAC `peaks.bed`): col1 is a chromosome
296    // and cols 2/3 are integer start/end coordinates. Reshape so display
297    // names come out as `chr:start-end` and feature_type filtering is
298    // bypassed (BED has no feature_type column).
299    if any_type && looks_like_bed(&ids, &names, &types) {
300        info!(
301            "{}: detected BED-shaped peaks file; row names will be formatted as chr:start-end",
302            row_file
303        );
304        let n = ids.len();
305        let mut new_ids = Vec::with_capacity(n);
306        for i in 0..n {
307            new_ids.push(format!("{}:{}-{}", ids[i], names[i], types[i]).into_boxed_str());
308        }
309        return Ok(MtxFeatureRows {
310            ids: new_ids,
311            names: vec![Box::from(""); n],
312            types: None,
313            row_name_columns,
314        });
315    }
316
317    let n_peaks = colon_peak_names(&mut ids, &mut names, any_type.then_some(&types[..]));
318    if n_peaks > 0 {
319        info!("{row_file}: {n_peaks} peak names rewritten in chr:start-end form");
320    }
321    Ok(MtxFeatureRows {
322        ids,
323        names,
324        types: any_type.then_some(types),
325        row_name_columns,
326    })
327}
328
329/// Heuristic: rows look like BED when col1 is a chromosome name and cols 2/3
330/// parse as non-negative integers. We sample up to the first 16 non-empty
331/// rows (so a stray header line doesn't fool us) and require ALL of them to
332/// pass — one mismatch flips the file to "not BED".
333fn looks_like_bed(ids: &[Box<str>], starts: &[Box<str>], ends: &[Box<str>]) -> bool {
334    let mut checked = 0usize;
335    let mut matched = 0usize;
336    for ((id, start), end) in ids.iter().zip(starts).zip(ends) {
337        if checked >= 16 {
338            break;
339        }
340        let (id, start, end) = (id.as_ref(), start.as_ref(), end.as_ref());
341        if id.is_empty() || start.is_empty() || end.is_empty() {
342            continue;
343        }
344        checked += 1;
345        if is_chromosome_name(id) && start.parse::<u64>().is_ok() && end.parse::<u64>().is_ok() {
346            matched += 1;
347        }
348    }
349    checked >= 1 && matched == checked
350}
351
352/// Accepts `chr1`/`chr10`/`chrX` (UCSC) and bare `1`/`X`/`MT` (Ensembl).
353fn is_chromosome_name(s: &str) -> bool {
354    let bytes = s.as_bytes();
355    if bytes.len() >= 3 && bytes[..3].eq_ignore_ascii_case(b"chr") {
356        return true;
357    }
358    if matches!(
359        bytes,
360        b"X" | b"x" | b"Y" | b"y" | b"M" | b"m" | b"MT" | b"Mt" | b"mt"
361    ) {
362        return true;
363    }
364    s.parse::<u32>().is_ok()
365}