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#[derive(Clone, Debug)]
12pub struct MtxImport {
13 pub mtx: Box<str>,
15 pub row: Option<Box<str>>,
20 pub col: Option<Box<str>>,
22 pub row_name_columns: usize,
25 pub select_row_type: Box<str>,
28 pub remove_row_type: Box<str>,
31 pub hto_row_type: Box<str>,
37}
38
39impl MtxImport {
40 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
55pub fn from_mtx(mtx: &MtxImport, out: &Output) -> anyhow::Result<Box<str>> {
57 stage_mtx(mtx, out)?.finish()
58}
59
60pub 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 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 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 types: Option<Vec<Box<str>>>,
248 row_name_columns: usize,
249}
250
251impl MtxFeatureRows {
252 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 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 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
329fn 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
352fn 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}