1use rustc_hash::FxHashMap as HashMap;
2
3use anyhow::{bail, Result};
4use log::{info, warn};
5use nalgebra::DMatrix;
6
7use legume_numeric::matrix::common_io::{read_lines, unquote_field};
8use legume_numeric::matrix::traits::IoOps;
9
10#[derive(Debug, Clone)]
12pub struct CellAnnotations {
13 pub cell_to_individual: HashMap<Box<str>, usize>,
15 pub individual_ids: Vec<Box<str>>,
17}
18
19impl CellAnnotations {
20 pub fn to_column_aligned_vec(&self, column_names: &[Box<str>], missing: &str) -> Vec<Box<str>> {
25 column_names
26 .iter()
27 .map(|name| {
28 self.cell_to_individual
29 .get(name)
30 .map(|&idx| self.individual_ids[idx].clone())
31 .unwrap_or_else(|| Box::from(missing))
32 })
33 .collect()
34 }
35}
36
37pub struct CellTypeMembership {
42 pub matrix: DMatrix<f32>,
44 pub cell_type_names: Vec<Box<str>>,
46}
47
48fn read_annotation_rows(path: &str) -> Result<(Vec<Box<str>>, Option<char>)> {
61 let mut lines: Vec<Box<str>> = read_lines(path)?
62 .into_iter()
63 .filter(|line| !line.trim().is_empty() && !line.starts_with(['#', '%']))
64 .collect();
65 let delim = lines
68 .first()
69 .and_then(|header| ['\t', ','].into_iter().find(|&d| header.contains(d)))
70 .or_else(|| {
71 lines
72 .iter()
73 .skip(1)
74 .any(|line| line.contains('\t'))
75 .then_some('\t')
76 });
77 if !lines.is_empty() {
78 lines.remove(0);
79 }
80 Ok((lines, delim))
81}
82
83fn id_and_label(line: &str, delim: Option<char>, label_idx: usize) -> Option<(&str, &str)> {
87 debug_assert!(label_idx > 0, "field 0 is the cell id");
88 match delim {
89 Some(d) => {
90 let mut fields = line.split(d).map(unquote_field);
91 Some((fields.next()?, fields.nth(label_idx - 1)?))
92 }
93 None => {
94 let mut fields = line.split_whitespace();
95 Some((fields.next()?, fields.nth(label_idx - 1)?))
96 }
97 }
98}
99
100fn read_cell_labels(path: &str, label_idx: usize) -> Result<Vec<(Box<str>, Box<str>)>> {
107 let mut n_short = 0usize;
108 let mut n_empty = 0usize;
109 let mut out = Vec::new();
110 let (rows, delim) = read_annotation_rows(path)?;
111 for row in &rows {
112 let Some((cell_id, label)) = id_and_label(row, delim, label_idx) else {
113 n_short += 1;
114 continue;
115 };
116 if cell_id.is_empty() || label.is_empty() {
117 n_empty += 1;
118 continue;
119 }
120 out.push((cell_id.into(), label.into()));
121 }
122 let n_fields = label_idx + 1;
123 if out.is_empty() {
124 bail!(
125 "{path}: no row holds a cell id and a label in field {n_fields} \
126 ({n_short} rows with fewer than {n_fields} fields, {n_empty} rows \
127 with an empty cell id or label); the header line sets the delimiter"
128 );
129 }
130 if n_short > 0 || n_empty > 0 {
131 warn!(
132 "{path}: skipped {n_short} rows with fewer than {n_fields} fields \
133 and {n_empty} rows with an empty cell id or label"
134 );
135 }
136 Ok(out)
137}
138
139pub fn read_cell_annotations(path: &str) -> Result<CellAnnotations> {
150 info!("Reading cell annotations from {}", path);
151
152 let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
153 let mut individual_ids: Vec<Box<str>> = Vec::new();
154 let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
155
156 for (cell_id, ind_id) in read_cell_labels(path, 1)? {
157 let ind_idx = *individual_to_idx.entry(ind_id.clone()).or_insert_with(|| {
158 let idx = individual_ids.len();
159 individual_ids.push(ind_id);
160 idx
161 });
162
163 cell_to_individual.insert(cell_id, ind_idx);
164 }
165
166 info!(
167 "Loaded {} cell annotations: {} individuals",
168 cell_to_individual.len(),
169 individual_ids.len(),
170 );
171
172 Ok(CellAnnotations {
173 cell_to_individual,
174 individual_ids,
175 })
176}
177
178pub fn infer_cell_annotations(column_names: &[Box<str>]) -> CellAnnotations {
183 info!("Inferring individuals from cell names (barcode@indiv)");
184
185 let mut individual_to_idx: HashMap<Box<str>, usize> = Default::default();
186 let mut individual_ids: Vec<Box<str>> = Vec::new();
187 let mut cell_to_individual: HashMap<Box<str>, usize> = Default::default();
188
189 for cell_name in column_names {
190 let indiv: Box<str> = if let Some(pos) = cell_name.rfind('@') {
191 Box::from(&cell_name[pos + 1..])
192 } else {
193 Box::from("all")
194 };
195 let idx = *individual_to_idx.entry(indiv.clone()).or_insert_with(|| {
196 let i = individual_ids.len();
197 individual_ids.push(indiv);
198 i
199 });
200 cell_to_individual.insert(cell_name.clone(), idx);
201 }
202
203 info!(
204 "Inferred {} individuals from cell names",
205 individual_ids.len()
206 );
207
208 CellAnnotations {
209 cell_to_individual,
210 individual_ids,
211 }
212}
213
214pub fn build_onehot_membership(
220 path: &str,
221 column_names: &[Box<str>],
222) -> Result<CellTypeMembership> {
223 info!("Building one-hot membership from {}", path);
224
225 let mut celltype_to_idx: HashMap<Box<str>, usize> = Default::default();
226 let mut cell_type_names: Vec<Box<str>> = Vec::new();
227 let mut cell_to_ct: HashMap<Box<str>, usize> = Default::default();
228
229 for (cell_id, ct_name) in read_cell_labels(path, 2)? {
230 let ct_idx = *celltype_to_idx.entry(ct_name.clone()).or_insert_with(|| {
231 let idx = cell_type_names.len();
232 cell_type_names.push(ct_name);
233 idx
234 });
235
236 cell_to_ct.insert(cell_id, ct_idx);
237 }
238
239 let n_cells = column_names.len();
240 let n_ct = cell_type_names.len();
241 let mut matrix = DMatrix::<f32>::zeros(n_cells, n_ct);
242 let mut matched = 0usize;
243
244 for (cell_idx, cell_name) in column_names.iter().enumerate() {
245 if let Some(&ct_idx) = cell_to_ct.get(cell_name) {
246 matrix[(cell_idx, ct_idx)] = 1.0;
247 matched += 1;
248 }
249 }
250
251 info!(
252 "One-hot membership: {}/{} cells matched, {} cell types",
253 matched, n_cells, n_ct
254 );
255
256 if matched == 0 {
257 bail!("No cells matched between SC backend and annotation file");
258 }
259
260 Ok(CellTypeMembership {
261 matrix,
262 cell_type_names,
263 })
264}
265
266pub fn read_membership_proportions(
272 file_path: &str,
273 column_names: &[Box<str>],
274) -> Result<CellTypeMembership> {
275 info!("Reading membership proportions from {}", file_path);
276
277 let mat_with_names = DMatrix::<f32>::from_parquet(file_path)?;
278 let cell_type_names = mat_with_names.cols;
279 let n_cell_types = cell_type_names.len();
280 let n_cells = column_names.len();
281
282 let parquet_cell_lookup: HashMap<&str, usize> = mat_with_names
284 .rows
285 .iter()
286 .enumerate()
287 .map(|(i, name)| (name.as_ref(), i))
288 .collect();
289
290 let mut matrix = DMatrix::<f32>::zeros(n_cells, n_cell_types);
292 let mut matched = 0usize;
293
294 for (cell_idx, cell_name) in column_names.iter().enumerate() {
295 if let Some(&pq_row) = parquet_cell_lookup.get(cell_name.as_ref()) {
296 for k in 0..n_cell_types {
297 matrix[(cell_idx, k)] = mat_with_names.mat[(pq_row, k)];
298 }
299 matched += 1;
300 }
301 }
302
303 info!(
304 "Matched {}/{} cells to membership parquet ({} cell types)",
305 matched, n_cells, n_cell_types
306 );
307
308 if matched == 0 {
309 bail!("No cells matched between SC backend and membership parquet");
310 }
311
312 Ok(CellTypeMembership {
313 matrix,
314 cell_type_names,
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_read_cell_annotations_roundtrip() -> Result<()> {
324 use std::io::Write;
325
326 let dir = tempfile::tempdir()?;
327 let path = dir.path().join("cells.tsv");
328 let path_str = path.to_str().unwrap();
329
330 let mut f = std::fs::File::create(&path)?;
331 writeln!(f, "cell_id\tindividual_id\tcell_type")?;
332 writeln!(f, "cell_0\tIND_A\tT_cell")?;
333 writeln!(f, "cell_1\tIND_A\tB_cell")?;
334 writeln!(f, "cell_2\tIND_B\tT_cell")?;
335 writeln!(f, "cell_3\tIND_B\tT_cell")?;
336 f.flush()?;
337
338 let anno = read_cell_annotations(path_str)?;
339 assert_eq!(anno.individual_ids.len(), 2);
340 assert_eq!(anno.cell_to_individual.len(), 4);
341
342 let &ind_idx = anno.cell_to_individual.get(&Box::from("cell_0")).unwrap();
343 assert_eq!(anno.individual_ids[ind_idx].as_ref(), "IND_A");
344
345 Ok(())
346 }
347
348 fn write_table(dir: &tempfile::TempDir, name: &str, lines: &[&str]) -> String {
349 let path = dir.path().join(name);
350 std::fs::write(&path, lines.join("\n") + "\n").unwrap();
351 path.to_str().unwrap().to_string()
352 }
353
354 fn individual_of<'a>(anno: &'a CellAnnotations, cell: &str) -> &'a str {
355 let &idx = anno.cell_to_individual.get(cell).unwrap();
356 anno.individual_ids[idx].as_ref()
357 }
358
359 #[test]
360 fn fields_of_a_tsv_or_csv_keep_their_spaces() -> Result<()> {
361 let dir = tempfile::tempdir()?;
362 let tsv = write_table(
363 &dir,
364 "cells.tsv",
365 &[
366 "cell_id\tindividual_id\tcell_type",
367 "cell_0\tdonor 1\tCD4 T cell",
368 "cell_1\tdonor 2\tCD4 T cell",
369 "cell_2\tdonor 2\tB cell, naive",
370 ],
371 );
372 let csv = write_table(
373 &dir,
374 "cells.csv",
375 &[
376 "cell_id, individual_id, cell_type",
377 "cell_0, donor 1, CD4 T cell",
378 "cell_1, \"donor 2\", CD4 T cell",
379 "cell_2, donor 2, B cell",
380 ],
381 );
382 let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into(), "cell_2".into()];
383 for (path, b_cell) in [(&tsv, "B cell, naive"), (&csv, "B cell")] {
384 let anno = read_cell_annotations(path)?;
385 assert_eq!(
386 anno.individual_ids,
387 vec![Box::<str>::from("donor 1"), "donor 2".into()],
388 "{path}"
389 );
390 assert_eq!(individual_of(&anno, "cell_0"), "donor 1");
391 assert_eq!(individual_of(&anno, "cell_2"), "donor 2");
392
393 let membership = build_onehot_membership(path, &columns)?;
394 assert_eq!(
395 membership.cell_type_names,
396 vec![Box::<str>::from("CD4 T cell"), b_cell.into()],
397 "{path}"
398 );
399 assert_eq!(membership.matrix[(1, 0)], 1.0);
400 assert_eq!(membership.matrix[(2, 1)], 1.0);
401 }
402 Ok(())
403 }
404
405 #[test]
406 fn whitespace_tables_and_rows_with_an_empty_field() -> Result<()> {
407 let dir = tempfile::tempdir()?;
408 let spaced = write_table(
410 &dir,
411 "cells.txt",
412 &[
413 "cell indiv type",
414 "cell_0 IND_A T",
415 "cell_1 IND_B B",
416 ],
417 );
418 let anno = read_cell_annotations(&spaced)?;
419 assert_eq!(individual_of(&anno, "cell_0"), "IND_A");
420 assert_eq!(individual_of(&anno, "cell_1"), "IND_B");
421
422 let gapped = write_table(
424 &dir,
425 "gapped.tsv",
426 &[
427 "cell\tindiv",
428 "cell_0\tIND_A",
429 "cell_1\t",
430 "\tIND_B",
431 "",
432 "cell_2\tIND_A",
433 ],
434 );
435 let anno = read_cell_annotations(&gapped)?;
436 assert_eq!(anno.individual_ids, vec![Box::<str>::from("IND_A")]);
437 assert_eq!(anno.cell_to_individual.len(), 2);
438 Ok(())
439 }
440
441 #[test]
444 fn rows_with_tabs_set_the_delimiter_a_header_does_not_name() -> Result<()> {
445 let dir = tempfile::tempdir()?;
446 let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into()];
447 let table = write_table(
448 &dir,
449 "cells.txt",
450 &[
451 "cell indiv type",
452 "cell_0\tIND_A\tCD4 T cell",
453 "cell_1\tIND_B\tB",
454 ],
455 );
456 let membership = build_onehot_membership(&table, &columns)?;
457 assert_eq!(
458 membership.cell_type_names,
459 vec![Box::<str>::from("CD4 T cell"), "B".into()]
460 );
461 Ok(())
462 }
463
464 #[test]
465 fn the_header_line_sets_the_delimiter() -> Result<()> {
466 let dir = tempfile::tempdir()?;
467 let columns: Vec<Box<str>> = vec!["cell_0".into(), "cell_1".into(), "cell_2".into()];
468
469 let spaced = write_table(
471 &dir,
472 "cells.txt",
473 &[
474 "cell indiv type",
475 "cell_0 IND_A T",
476 "cell_1 IND_B B,naive",
477 "cell_2 IND_B T",
478 ],
479 );
480 let anno = read_cell_annotations(&spaced)?;
481 assert_eq!(anno.cell_to_individual.len(), 3);
482 assert_eq!(individual_of(&anno, "cell_1"), "IND_B");
483 let membership = build_onehot_membership(&spaced, &columns)?;
484 assert_eq!(
485 membership.cell_type_names,
486 vec![Box::<str>::from("T"), "B,naive".into()]
487 );
488 assert_eq!(membership.matrix[(1, 1)], 1.0);
489 assert_eq!(membership.matrix[(2, 0)], 1.0);
490
491 let tsv = write_table(
492 &dir,
493 "cells.tsv",
494 &[
495 "cell\tindiv\ttype",
496 "cell_0\tIND_A\tT",
497 "cell_1\tIND_B\tB",
498 "cell_2\tIND_B\tT",
499 ],
500 );
501 let anno = read_cell_annotations(&tsv)?;
502 assert_eq!(anno.cell_to_individual.len(), 3);
503 assert_eq!(individual_of(&anno, "cell_0"), "IND_A");
504 assert_eq!(individual_of(&anno, "cell_2"), "IND_B");
505 assert_eq!(
506 build_onehot_membership(&tsv, &columns)?.cell_type_names,
507 vec![Box::<str>::from("T"), "B".into()]
508 );
509 Ok(())
510 }
511
512 #[test]
513 fn a_table_where_no_row_parses_is_an_error() {
514 let dir = tempfile::tempdir().unwrap();
515 let mixed = write_table(
517 &dir,
518 "mixed.tsv",
519 &["cell\tindiv", "cell_0 IND_A", "cell_1 IND_B"],
520 );
521 let err = read_cell_annotations(&mixed).unwrap_err().to_string();
522 assert!(err.contains("2 rows with fewer than 2 fields"), "{err}");
523
524 let two = write_table(&dir, "two.tsv", &["cell\tindiv", "cell_0\tIND_A"]);
526 assert!(read_cell_annotations(&two).is_ok());
527 let columns: Vec<Box<str>> = vec!["cell_0".into()];
528 assert!(build_onehot_membership(&two, &columns).is_err());
529
530 let header_only = write_table(&dir, "empty.tsv", &["cell\tindiv"]);
531 assert!(read_cell_annotations(&header_only).is_err());
532 }
533
534 #[test]
535 fn test_infer_cell_annotations() {
536 let names: Vec<Box<str>> = vec![
537 "ACGT@IND_A".into(),
538 "TGCA@IND_A".into(),
539 "GGCC@IND_B".into(),
540 ];
541 let anno = infer_cell_annotations(&names);
542 assert_eq!(anno.individual_ids.len(), 2);
543 assert_eq!(anno.cell_to_individual.len(), 3);
544 }
545
546 #[test]
547 fn test_to_column_aligned_vec() {
548 let names: Vec<Box<str>> = vec!["ACGT@IND_A".into(), "TGCA@IND_B".into(), "XXXX".into()];
549 let anno = infer_cell_annotations(&names);
550 let aligned = anno.to_column_aligned_vec(&names, "missing");
551 assert_eq!(aligned[0].as_ref(), "IND_A");
552 assert_eq!(aligned[1].as_ref(), "IND_B");
553 assert_eq!(aligned[2].as_ref(), "all");
554 }
555}