use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use crate::config::Limits;
use crate::modules::QCModule;
use crate::sequence::Sequence;
use crate::utils::base_counts::{count_acgt, IDX_A, IDX_C, IDX_G, IDX_T};
use crate::utils::phred;
const PUBLISH_INTERVAL: u32 = 256;
#[derive(Debug, Clone, Copy)]
pub struct BasicStatsCounters {
actual_count: u64,
min_length: usize,
max_length: usize,
total_bases: u64,
g_count: u64,
c_count: u64,
a_count: u64,
t_count: u64,
lowest_char: u16,
known_encoding: Option<phred::PhredEncoding>,
colorspace: Option<bool>,
median_length: usize,
}
impl Default for BasicStatsCounters {
fn default() -> Self {
BasicStatsCounters {
actual_count: 0,
min_length: 0,
max_length: 0,
total_bases: 0,
g_count: 0,
c_count: 0,
a_count: 0,
t_count: 0,
lowest_char: phred::NO_QUALITY_SEEN,
known_encoding: None,
colorspace: None,
median_length: 0,
}
}
}
impl BasicStatsCounters {
pub const MEASURES: [&'static str; 8] = [
"File type",
"Encoding",
"Total Sequences",
"Total Bases",
"Sequence length",
"Mean Length",
"Median Length",
"%GC",
];
pub fn rows(&self) -> Vec<(&'static str, String)> {
let file_type = match self.colorspace {
None => "null",
Some(true) => "Colorspace converted to bases",
Some(false) => "Conventional base calls",
};
let encoding = phred::resolve(self.known_encoding, self.lowest_char)
.map(|e| e.name.to_string())
.unwrap_or_else(|_| "Unknown".to_string());
let sequence_length = if self.min_length == self.max_length {
self.min_length.to_string()
} else {
format!("{}-{}", self.min_length, self.max_length)
};
let mean_length = self.total_bases.checked_div(self.actual_count).unwrap_or(0);
let total = self.a_count + self.t_count + self.g_count + self.c_count;
let gc = ((self.g_count + self.c_count) * 100)
.checked_div(total)
.unwrap_or(0);
let values = [
file_type.to_string(),
encoding,
self.actual_count.to_string(),
format_length(self.total_bases),
sequence_length,
mean_length.to_string(),
self.median_length.to_string(),
gc.to_string(),
];
Self::MEASURES.into_iter().zip(values).collect()
}
}
pub struct LiveStats {
snapshot: Mutex<Option<BasicStatsCounters>>,
wanted: AtomicBool,
}
impl Default for LiveStats {
fn default() -> Self {
Self {
snapshot: Mutex::new(None),
wanted: AtomicBool::new(true),
}
}
}
impl LiveStats {
pub fn new() -> Self {
Self::default()
}
pub fn request(&self) {
self.wanted.store(true, Ordering::Relaxed);
}
fn take_request(&self) -> bool {
self.wanted.load(Ordering::Relaxed) && self.wanted.swap(false, Ordering::Relaxed)
}
pub fn snapshot(&self) -> Option<BasicStatsCounters> {
*self.snapshot.lock().unwrap_or_else(|e| e.into_inner())
}
fn publish(&self, counters: BasicStatsCounters) {
*self.snapshot.lock().unwrap_or_else(|e| e.into_inner()) = Some(counters);
}
}
pub fn format_length(original_length: u64) -> String {
let mut length = original_length as f64;
let unit = if length >= 1_000_000_000.0 {
length /= 1_000_000_000.0;
" Gbp"
} else if length >= 1_000_000.0 {
length /= 1_000_000.0;
" Mbp"
} else if length >= 1_000.0 {
length /= 1_000.0;
" kbp"
} else {
" bp"
};
let raw = format!("{}", length);
let chars: Vec<char> = raw.chars().collect();
let mut last_index = 0;
for (i, &ch) in chars.iter().enumerate() {
last_index = i;
if ch == '.' {
break;
}
}
if last_index + 1 < chars.len() && chars[last_index + 1] != '0' {
last_index += 1;
} else if last_index > 0 && chars[last_index] == '.' {
last_index -= 1;
}
let truncated: String = chars[..=last_index].iter().collect();
format!("{}{}", truncated, unit)
}
pub struct BasicStats {
name: Option<String>,
counters: BasicStatsCounters,
length_counts: Vec<u64>,
live: Option<Arc<LiveStats>>,
}
impl BasicStats {
pub fn new(_limits: &Limits) -> Self {
BasicStats {
name: None,
counters: BasicStatsCounters::default(),
length_counts: Vec::new(),
live: None,
}
}
pub fn set_file_name(&mut self, name: &str) {
let name = name.strip_prefix("stdin:").unwrap_or(name);
self.name = Some(name.to_string());
}
fn publish(&mut self) {
if let Some(ref live) = self.live {
self.counters.median_length = median_length(&self.length_counts);
live.publish(self.counters);
}
}
}
fn median_length(length_counts: &[u64]) -> usize {
let rank50 = length_counts.iter().sum::<u64>() / 2;
let mut running = 0;
for (len, &count) in length_counts.iter().enumerate() {
running += count;
if running > rank50 {
return len;
}
}
0
}
impl QCModule for BasicStats {
fn cost_hint(&self) -> u32 {
2
}
fn process_sequence(&mut self, sequence: &Sequence) {
let seen = self.counters.actual_count;
if seen != 0
&& seen.is_multiple_of(PUBLISH_INTERVAL as u64)
&& self.live.as_ref().is_some_and(|live| live.take_request())
{
self.publish();
}
let c = &mut self.counters;
c.actual_count += 1;
c.total_bases += sequence.sequence.len() as u64;
let len = sequence.sequence.len();
if !sequence.is_filtered {
if self.length_counts.len() <= len {
self.length_counts.resize(len + 1, 0);
}
self.length_counts[len] += 1;
}
if c.actual_count == 1 {
c.colorspace = Some(sequence.colorspace.is_some());
c.min_length = len;
c.max_length = len;
} else {
c.min_length = c.min_length.min(len);
c.max_length = c.max_length.max(len);
}
let counts = count_acgt(&sequence.sequence);
c.a_count += counts[IDX_A];
c.c_count += counts[IDX_C];
c.g_count += counts[IDX_G];
c.t_count += counts[IDX_T];
if let Some(lowest) = sequence.quality.iter().copied().min() {
c.lowest_char = c.lowest_char.min(lowest as u16);
}
}
fn attach_live_stats(&mut self, live: Arc<LiveStats>) {
self.live = Some(live);
}
fn finalize(&mut self) {
if self.counters.known_encoding.is_none() {
if let Some(warning) = phred::phred64_suspicion(self.counters.lowest_char) {
crate::progress::log_line(&match &self.name {
Some(name) => format!("{name}: {warning}"),
None => warning,
});
}
}
self.publish();
}
fn set_filename(&mut self, name: &str) {
self.set_file_name(name);
}
fn set_phred_encoding(&mut self, encoding: phred::PhredEncoding) {
self.counters.known_encoding = Some(encoding);
}
fn name(&self) -> &str {
"Basic Statistics"
}
fn description(&self) -> &str {
"Calculates some basic statistics about the file"
}
fn reset(&mut self) {
self.counters.min_length = 0;
self.counters.max_length = 0;
self.counters.g_count = 0;
self.counters.c_count = 0;
self.counters.a_count = 0;
self.counters.t_count = 0;
}
fn raises_error(&self) -> bool {
false
}
fn raises_warning(&self) -> bool {
false
}
fn ignore_filtered_sequences(&self) -> bool {
false
}
fn ignore_in_report(&self) -> bool {
false
}
fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
phred::resolve(self.counters.known_encoding, self.counters.lowest_char)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(writer, "#Measure\tValue")?;
writeln!(writer, "Filename\t{}", self.name.as_deref().unwrap_or(""))?;
let counters = BasicStatsCounters {
median_length: median_length(&self.length_counts),
..self.counters
};
for (measure, value) in counters.rows() {
writeln!(writer, "{}\t{}", measure, value)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_length_bp() {
assert_eq!(format_length(16), "16 bp");
assert_eq!(format_length(80), "80 bp");
assert_eq!(format_length(999), "999 bp");
}
#[test]
fn test_format_length_kbp() {
assert_eq!(format_length(1000), "1 kbp");
assert_eq!(format_length(1500), "1.5 kbp");
assert_eq!(format_length(10000), "10 kbp");
}
#[test]
fn test_format_length_mbp() {
assert_eq!(format_length(1_000_000), "1 Mbp");
assert_eq!(format_length(1_200_000), "1.2 Mbp");
}
#[test]
fn test_format_length_gbp() {
assert_eq!(format_length(1_000_000_000), "1 Gbp");
}
fn sequences(count: usize) -> Vec<Sequence> {
(0..count)
.map(|i| {
let len = 40 + (i % 7);
let bases: Vec<u8> = (0..len).map(|p| b"ACGTGGCN"[(i * 3 + p) % 8]).collect();
let quality = vec![b'I'; len];
Sequence::new(format!("READ{}", i), bases, quality)
})
.collect()
}
fn text_rows(module: &BasicStats) -> Vec<(String, String)> {
let mut buf = Vec::new();
module.write_text_report(&mut buf).expect("text report");
String::from_utf8(buf)
.expect("utf8")
.lines()
.skip(2) .filter_map(|line| line.split_once('\t'))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn test_live_snapshot_matches_report() {
let limits = Limits::new();
let live = Arc::new(LiveStats::new());
let mut module = BasicStats::new(&limits);
module.set_file_name("sample.fastq");
module.attach_live_stats(Arc::clone(&live));
assert!(live.snapshot().is_none());
let seqs = sequences(PUBLISH_INTERVAL as usize * 4);
for seq in &seqs {
module.process_sequence(seq);
}
let mid = live.snapshot().expect("published during the run");
assert!(mid.actual_count > 0);
assert!(mid.actual_count <= seqs.len() as u64);
module.finalize();
let final_snapshot = live.snapshot().expect("published at finalize");
assert_eq!(final_snapshot.actual_count, seqs.len() as u64);
let expected: Vec<(String, String)> = final_snapshot
.rows()
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
assert_eq!(text_rows(&module), expected);
}
#[test]
fn test_live_stats_publish_on_request() {
let limits = Limits::new();
let live = Arc::new(LiveStats::new());
let mut module = BasicStats::new(&limits);
module.attach_live_stats(Arc::clone(&live));
let seqs = sequences(PUBLISH_INTERVAL as usize * 4);
let (first, rest) = seqs.split_at(PUBLISH_INTERVAL as usize * 2);
for seq in first {
module.process_sequence(seq);
}
let published = live.snapshot().expect("first snapshot").actual_count;
assert_eq!(published, PUBLISH_INTERVAL as u64);
let (unasked, asked) = rest.split_at(PUBLISH_INTERVAL as usize);
for seq in unasked {
module.process_sequence(seq);
}
assert_eq!(live.snapshot().unwrap().actual_count, published);
live.request();
for seq in asked {
module.process_sequence(seq);
}
assert!(live.snapshot().unwrap().actual_count > published);
}
#[test]
fn test_no_live_stats_by_default() {
let limits = Limits::new();
let mut module = BasicStats::new(&limits);
for seq in &sequences(100) {
module.process_sequence(seq);
}
module.finalize();
let rows = text_rows(&module);
assert_eq!(rows[2], ("Total Sequences".to_string(), "100".to_string()));
}
#[test]
fn test_counters_rows_placeholder_state() {
let counters = BasicStatsCounters::default();
let rows = counters.rows();
assert_eq!(rows.len(), 8);
assert_eq!(rows[0], ("File type", "null".to_string()));
assert_eq!(rows[2], ("Total Sequences", "0".to_string()));
assert_eq!(rows[7], ("%GC", "0".to_string()));
}
}