use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fgoxide::io::DelimFile;
use serde::Serialize;
use crate::countset::{CountKey, CountSet};
use crate::dedup::Stats;
use crate::sig::{FragmentSlot, PairSlot, Slot, stride_for};
struct CountTable<K> {
cells: Vec<CountSet<K>>,
}
impl<K: CountKey> CountTable<K> {
fn new_pair(bin_count: u32) -> Self {
let stride = stride_for(bin_count) as usize;
Self::with_cells(stride.saturating_mul(stride))
}
fn new_single_end(bin_count: u32) -> Self {
Self::with_cells(stride_for(bin_count) as usize)
}
fn with_cells(n: usize) -> Self {
Self { cells: (0..n).map(|_| CountSet::new()).collect() }
}
#[inline]
fn bump(&mut self, slot: Slot<K>) {
self.cells[slot.off].bump(slot.sig);
}
fn counts(&self) -> impl Iterator<Item = u16> + '_ {
self.cells.iter().flat_map(|c| c.counts())
}
}
pub struct CountsMap {
bin_count: u32,
has_pairs: bool,
pair_distinct: u64,
pairs: CountTable<u64>,
se_distinct: u64,
single_end: CountTable<u32>,
}
impl CountsMap {
pub fn new(bin_count: u32) -> Self {
Self {
bin_count,
has_pairs: false,
pair_distinct: 0,
pairs: CountTable::new_pair(bin_count),
se_distinct: 0,
single_end: CountTable::new_single_end(bin_count),
}
}
#[inline]
pub fn observe_pair(&mut self, slot: PairSlot, is_dup: bool) {
if !self.has_pairs {
self.has_pairs = true;
self.single_end = CountTable::new_single_end(self.bin_count);
self.se_distinct = 0;
}
if !is_dup {
self.pair_distinct += 1;
return;
}
self.pairs.bump(slot);
}
#[inline]
pub fn observe_single_end(&mut self, slot: FragmentSlot, is_dup: bool) {
if self.has_pairs {
return;
}
if !is_dup {
self.se_distinct += 1;
return;
}
self.single_end.bump(slot);
}
fn pair_histogram(&self) -> BTreeMap<u32, u64> {
histogram(self.pair_distinct, self.pairs.counts())
}
fn se_histogram(&self) -> BTreeMap<u32, u64> {
histogram(self.se_distinct, self.single_end.counts())
}
}
fn histogram(distinct: u64, side: impl Iterator<Item = u16>) -> BTreeMap<u32, u64> {
let mut hist: BTreeMap<u32, u64> = BTreeMap::new();
let mut side_len = 0u64;
for count in side {
*hist.entry(count as u32).or_insert(0) += 1;
side_len += 1;
}
let singletons = distinct.saturating_sub(side_len);
if singletons > 0 {
hist.insert(1, singletons);
}
hist
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CountHistogramRow {
pub sample: String,
pub library: String,
pub category: &'static str,
pub n_observations: u32,
pub n_molecules: u64,
}
pub fn histogram_rows(counts: &[CountsMap], stats: &Stats, sample: &str) -> Vec<CountHistogramRow> {
let mut rows = Vec::new();
for (i, ls) in stats.libraries.iter().enumerate() {
if ls.id_count == 0 {
continue;
}
let cm = &counts[i];
let category = ls.reported_category();
let hist =
if ls.both_mapped_id_count > 0 { cm.pair_histogram() } else { cm.se_histogram() };
for (n_observations, n_molecules) in hist {
rows.push(CountHistogramRow {
sample: sample.to_string(),
library: ls.name.clone(),
category,
n_observations,
n_molecules,
});
}
}
rows
}
pub fn histogram_path(prefix: &Path) -> PathBuf {
let mut name = prefix.as_os_str().to_owned();
name.push(".duplication-spectrum.tsv");
PathBuf::from(name)
}
pub fn write_histogram_rows(rows: &[CountHistogramRow], path: &Path) -> Result<()> {
DelimFile::default()
.write_tsv(path, rows.iter())
.with_context(|| format!("writing duplication-spectrum TSV to {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dedup::{LibraryStats, Stats};
fn cm() -> CountsMap {
CountsMap::new(0)
}
fn pair(sig: u64) -> PairSlot {
Slot { off: 0, sig }
}
fn se(sig: u32) -> FragmentSlot {
Slot { off: 0, sig }
}
#[test]
fn repeated_pair_accumulates_full_count() {
let mut c = cm();
c.observe_pair(pair(100), false);
c.observe_pair(pair(100), true);
c.observe_pair(pair(100), true);
assert_eq!(c.pair_distinct, 1);
assert_eq!(c.pair_histogram().get(&3), Some(&1), "one signature seen 3×");
}
#[test]
fn pair_histogram_recovers_singletons_by_subtraction() {
let mut c = cm();
c.observe_pair(pair(100), false);
c.observe_pair(pair(100), true);
c.observe_pair(pair(100), true);
c.observe_pair(pair(500), false);
c.observe_pair(pair(900), false);
let h = c.pair_histogram();
assert_eq!(h.get(&1), Some(&2), "two singletons");
assert_eq!(h.get(&3), Some(&1), "one triple");
}
#[test]
fn single_end_counted_when_library_has_no_pairs() {
let mut c = cm();
c.observe_single_end(se(100), false);
c.observe_single_end(se(100), true);
c.observe_single_end(se(500), false);
let h = c.se_histogram();
assert_eq!(h.get(&1), Some(&1));
assert_eq!(h.get(&2), Some(&1));
}
#[test]
fn first_pair_drops_single_end_counts_and_freezes_them() {
let mut c = cm();
c.observe_single_end(se(100), false);
c.observe_single_end(se(100), true);
assert_eq!(c.single_end.counts().count(), 1);
c.observe_pair(pair(200), false);
assert!(c.has_pairs);
assert_eq!(
c.single_end.counts().count(),
0,
"single-end table must be freed on first pair"
);
assert_eq!(c.se_distinct, 0);
c.observe_single_end(se(700), false);
c.observe_single_end(se(700), true);
assert_eq!(c.single_end.counts().count(), 0);
assert_eq!(c.se_distinct, 0);
assert!(c.se_histogram().is_empty());
}
#[test]
fn counts_saturate_at_u16_max() {
let mut c = cm();
c.observe_single_end(se(100), false);
for _ in 0..70_000 {
c.observe_single_end(se(100), true);
}
assert_eq!(c.se_histogram().get(&(u16::MAX as u32)), Some(&1));
}
#[test]
fn histogram_rows_report_pairs_for_a_paired_library() {
let mut counts = vec![cm()];
counts[0].observe_single_end(se(9), false);
counts[0].observe_pair(pair(1), false);
counts[0].observe_pair(pair(1), true);
counts[0].observe_pair(pair(3), false);
let stats = Stats {
libraries: vec![LibraryStats {
name: "libA".to_string(),
id_count: 4,
both_mapped_id_count: 3,
both_mapped_dup_count: 1,
mapped_orphan_id_count: 1,
..Default::default()
}],
clamped_template_count: 0,
};
let rows = histogram_rows(&counts, &stats, "NA12878");
assert!(rows.iter().all(|r| r.category == "pairs"));
assert!(rows.iter().all(|r| r.sample == "NA12878"));
assert_eq!(rows.iter().find(|r| r.n_observations == 1).unwrap().n_molecules, 1);
assert_eq!(rows.iter().find(|r| r.n_observations == 2).unwrap().n_molecules, 1);
}
#[test]
fn histogram_rows_report_single_end_for_a_se_only_library() {
let mut counts = vec![cm()];
counts[0].observe_single_end(se(1), false);
counts[0].observe_single_end(se(1), true);
counts[0].observe_single_end(se(5), false);
let stats = Stats {
libraries: vec![LibraryStats {
name: "se".to_string(),
id_count: 3,
mapped_orphan_id_count: 3,
orphan_dup_count: 1,
..Default::default()
}],
clamped_template_count: 0,
};
let rows = histogram_rows(&counts, &stats, "");
assert!(rows.iter().all(|r| r.category == "single_end"));
assert_eq!(rows.iter().find(|r| r.n_observations == 1).unwrap().n_molecules, 1);
assert_eq!(rows.iter().find(|r| r.n_observations == 2).unwrap().n_molecules, 1);
}
#[test]
fn empty_library_is_skipped() {
let counts = vec![cm()];
let stats = Stats {
libraries: vec![LibraryStats { name: "empty".to_string(), ..Default::default() }],
clamped_template_count: 0,
};
assert!(histogram_rows(&counts, &stats, "").is_empty());
}
#[test]
fn tsv_header_is_sample_first_with_expected_columns() {
let mut counts = vec![cm()];
counts[0].observe_single_end(se(1), false);
let stats = Stats {
libraries: vec![LibraryStats {
name: "lib".to_string(),
id_count: 1,
mapped_orphan_id_count: 1,
..Default::default()
}],
clamped_template_count: 0,
};
let rows = histogram_rows(&counts, &stats, "s");
let tmp = tempfile::NamedTempFile::new().unwrap();
write_histogram_rows(&rows, tmp.path()).unwrap();
let text = std::fs::read_to_string(tmp.path()).unwrap();
assert_eq!(
text.lines().next().unwrap(),
"sample\tlibrary\tcategory\tn_observations\tn_molecules"
);
}
#[test]
fn histogram_path_appends_suffix() {
assert_eq!(histogram_path(Path::new("out/x")), Path::new("out/x.duplication-spectrum.tsv"));
}
}