From 2f57c13e8b6d008198c9c393ef5f69747cc4be5e Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:15:35 +0200 Subject: [PATCH 01/22] refactor: move bam_flags, cpp_rng and preseq into src/common/ These three modules carry no RNA-specific logic and are needed by the forthcoming dna subcommand. src/rna re-exports them so every existing crate::rna::... path and the published 0.2.x library surface keep working. Co-Authored-By: Claude Opus 5 (1M context) --- src/{rna => common}/bam_flags.rs | 0 src/{rna => common}/cpp_rng.rs | 0 src/common/mod.rs | 10 ++++++++++ src/{rna => common}/preseq.rs | 0 src/lib.rs | 1 + src/rna/mod.rs | 8 +++++--- 6 files changed, 16 insertions(+), 3 deletions(-) rename src/{rna => common}/bam_flags.rs (100%) rename src/{rna => common}/cpp_rng.rs (100%) create mode 100644 src/common/mod.rs rename src/{rna => common}/preseq.rs (100%) diff --git a/src/rna/bam_flags.rs b/src/common/bam_flags.rs similarity index 100% rename from src/rna/bam_flags.rs rename to src/common/bam_flags.rs diff --git a/src/rna/cpp_rng.rs b/src/common/cpp_rng.rs similarity index 100% rename from src/rna/cpp_rng.rs rename to src/common/cpp_rng.rs diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 00000000..c5ec0872 --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,10 @@ +//! Analysis modules shared between the `rna` and `dna` pipelines. +//! +//! Nothing in this module is specific to a library preparation or an assay: +//! BAM flag helpers, the C++ RNG shim used for preseq bootstrap +//! reproducibility, the preseq `lc_extrap` implementation, read-level +//! alignment statistics, and the samtools-compatible output writers. + +pub mod bam_flags; +pub mod cpp_rng; +pub mod preseq; diff --git a/src/rna/preseq.rs b/src/common/preseq.rs similarity index 100% rename from src/rna/preseq.rs rename to src/common/preseq.rs diff --git a/src/lib.rs b/src/lib.rs index 9a228cae..bcb20cbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,7 @@ use clap::ValueEnum; use serde::Deserialize; +pub mod common; pub mod config; pub mod cpu; pub mod gtf; diff --git a/src/rna/mod.rs b/src/rna/mod.rs index 7dbcc094..29b50b64 100644 --- a/src/rna/mod.rs +++ b/src/rna/mod.rs @@ -3,10 +3,12 @@ //! Contains dupRadar duplication rate analysis, featureCounts-compatible output, //! and RSeQC tool reimplementations. -pub mod bam_flags; -pub mod cpp_rng; pub mod dupradar; pub mod featurecounts; -pub mod preseq; pub mod qualimap; pub mod rseqc; + +// These analyses are not RNA-specific and now live in `crate::common`. +// Re-exported here so existing `crate::rna::...` paths and the published +// 0.2.x library surface keep resolving. Drop the shims at 1.0. +pub use crate::common::{bam_flags, cpp_rng, preseq}; From f4721a24e843eb48047444f106124684140eb66d Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:17:40 +0200 Subject: [PATCH 02/22] refactor: move bam_stat and the samtools writers into src/common/ bam_stat is read-level and needs no annotation, and the samtools stats, flagstat and idxstats writers consume its result type, so all four move together into src/common/. src/rna/rseqc re-exports them. Co-Authored-By: Claude Opus 5 (1M context) --- src/{rna/rseqc => common}/bam_stat.rs | 0 src/common/mod.rs | 2 ++ src/{rna/rseqc => common/samtools}/flagstat.rs | 2 +- src/{rna/rseqc => common/samtools}/idxstats.rs | 2 +- src/common/samtools/mod.rs | 10 ++++++++++ src/{rna/rseqc => common/samtools}/stats.rs | 2 +- src/main.rs | 8 ++++---- src/rna/rseqc/mod.rs | 11 +++++++---- 8 files changed, 26 insertions(+), 11 deletions(-) rename src/{rna/rseqc => common}/bam_stat.rs (100%) rename src/{rna/rseqc => common/samtools}/flagstat.rs (99%) rename src/{rna/rseqc => common/samtools}/idxstats.rs (98%) create mode 100644 src/common/samtools/mod.rs rename src/{rna/rseqc => common/samtools}/stats.rs (99%) diff --git a/src/rna/rseqc/bam_stat.rs b/src/common/bam_stat.rs similarity index 100% rename from src/rna/rseqc/bam_stat.rs rename to src/common/bam_stat.rs diff --git a/src/common/mod.rs b/src/common/mod.rs index c5ec0872..7f927ebb 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -6,5 +6,7 @@ //! alignment statistics, and the samtools-compatible output writers. pub mod bam_flags; +pub mod bam_stat; pub mod cpp_rng; pub mod preseq; +pub mod samtools; diff --git a/src/rna/rseqc/flagstat.rs b/src/common/samtools/flagstat.rs similarity index 99% rename from src/rna/rseqc/flagstat.rs rename to src/common/samtools/flagstat.rs index cb1e370d..b611ae03 100644 --- a/src/rna/rseqc/flagstat.rs +++ b/src/common/samtools/flagstat.rs @@ -8,7 +8,7 @@ use std::path::Path; use anyhow::{Context, Result}; use log::debug; -use super::bam_stat::BamStatResult; +use crate::common::bam_stat::BamStatResult; // ============================================================================ // Output formatting diff --git a/src/rna/rseqc/idxstats.rs b/src/common/samtools/idxstats.rs similarity index 98% rename from src/rna/rseqc/idxstats.rs rename to src/common/samtools/idxstats.rs index 93c018fe..61f05d4f 100644 --- a/src/rna/rseqc/idxstats.rs +++ b/src/common/samtools/idxstats.rs @@ -8,7 +8,7 @@ use std::path::Path; use anyhow::{Context, Result}; use log::debug; -use super::bam_stat::BamStatResult; +use crate::common::bam_stat::BamStatResult; // ============================================================================ // Output formatting diff --git a/src/common/samtools/mod.rs b/src/common/samtools/mod.rs new file mode 100644 index 00000000..f9b1d860 --- /dev/null +++ b/src/common/samtools/mod.rs @@ -0,0 +1,10 @@ +//! samtools-compatible output writers. +//! +//! Reproduce the exact output formats of `samtools stats`, `samtools flagstat` +//! and `samtools idxstats` from the counters gathered in +//! [`crate::common::bam_stat::BamStatResult`], so that MultiQC and +//! `plot-bamstats` parse RustQC output as if samtools had produced it. + +pub mod flagstat; +pub mod idxstats; +pub mod stats; diff --git a/src/rna/rseqc/stats.rs b/src/common/samtools/stats.rs similarity index 99% rename from src/rna/rseqc/stats.rs rename to src/common/samtools/stats.rs index 20abf8c0..948dcdef 100644 --- a/src/rna/rseqc/stats.rs +++ b/src/common/samtools/stats.rs @@ -10,7 +10,7 @@ use std::path::Path; use anyhow::{Context, Result}; use log::debug; -use super::bam_stat::{BamStatResult, GcDepthBin}; +use crate::common::bam_stat::{BamStatResult, GcDepthBin}; // ============================================================================ // Output formatting diff --git a/src/main.rs b/src/main.rs index 4c66c173..978dd309 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,7 @@ use std::path::Path; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use rustqc::io::{format_count, format_duration, format_pct}; -use rustqc::{config, cpu, gtf, rna, summary}; +use rustqc::{common, config, cpu, gtf, rna, summary}; use ui::{Ui, Verbosity}; @@ -1562,7 +1562,7 @@ fn write_rseqc_outputs( if params.config.flagstat.enabled { std::fs::create_dir_all(&samtools_dir)?; let flagstat_path = samtools_dir.join(format!("{}.flagstat", sample_name)); - rna::rseqc::flagstat::write_flagstat(result, &flagstat_path)?; + common::samtools::flagstat::write_flagstat(result, &flagstat_path)?; let p = flagstat_path.display().to_string(); ui.output_item("flagstat", &p); written.push(("flagstat".into(), p)); @@ -1572,7 +1572,7 @@ fn write_rseqc_outputs( if params.config.idxstats.enabled { std::fs::create_dir_all(&samtools_dir)?; let idxstats_path = samtools_dir.join(format!("{}.idxstats", sample_name)); - rna::rseqc::idxstats::write_idxstats(result, bam_header_refs, &idxstats_path)?; + common::samtools::idxstats::write_idxstats(result, bam_header_refs, &idxstats_path)?; let p = idxstats_path.display().to_string(); ui.output_item("idxstats", &p); written.push(("idxstats".into(), p)); @@ -1582,7 +1582,7 @@ fn write_rseqc_outputs( if params.config.samtools_stats.enabled { std::fs::create_dir_all(&samtools_dir)?; let stats_path = samtools_dir.join(format!("{}.stats", sample_name)); - rna::rseqc::stats::write_stats(result, &stats_path)?; + common::samtools::stats::write_stats(result, &stats_path)?; let p = stats_path.display().to_string(); ui.output_item("stats", &p); written.push(("samtools stats".into(), p)); diff --git a/src/rna/rseqc/mod.rs b/src/rna/rseqc/mod.rs index a4730ccb..3ac2ea74 100644 --- a/src/rna/rseqc/mod.rs +++ b/src/rna/rseqc/mod.rs @@ -7,14 +7,17 @@ pub mod accumulators; pub mod common; pub mod plots; -pub mod bam_stat; -pub mod flagstat; -pub mod idxstats; pub mod infer_experiment; pub mod inner_distance; pub mod junction_annotation; pub mod junction_saturation; pub mod read_distribution; pub mod read_duplication; -pub mod stats; pub mod tin; + +// bam_stat and the samtools writers are read-level and assay-agnostic; they +// now live in `crate::common`. Re-exported so existing +// `crate::rna::rseqc::...` paths and the published 0.2.x library surface +// keep resolving. Drop the shims at 1.0. +pub use crate::common::bam_stat; +pub use crate::common::samtools::{flagstat, idxstats, stats}; From 6d57f5f5003e609314a12e30dc9a38d1992f3fbc Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:20:42 +0200 Subject: [PATCH 03/22] refactor: lift BamStatAccum into src/common/bam_stat_accum.rs BamStatAccum gathers the read-level counters behind bam_stat and the samtools writers. Its process_read takes only a record and a MAPQ cutoff, so it is assay-agnostic and the dna pipeline will drive the same struct. The merge_vec_arrays helper moves with it, being its only consumer. rna::rseqc::accumulators re-exports the type. Co-Authored-By: Claude Opus 5 (1M context) --- src/common/bam_stat_accum.rs | 1313 +++++++++++++++++++++++++++++++++ src/common/mod.rs | 1 + src/rna/rseqc/accumulators.rs | 1303 +------------------------------- 3 files changed, 1317 insertions(+), 1300 deletions(-) create mode 100644 src/common/bam_stat_accum.rs diff --git a/src/common/bam_stat_accum.rs b/src/common/bam_stat_accum.rs new file mode 100644 index 00000000..f4f08408 --- /dev/null +++ b/src/common/bam_stat_accum.rs @@ -0,0 +1,1313 @@ +//! Read-level alignment statistics accumulator. +//! +//! [`BamStatAccum`] gathers, in a single pass over the records, every counter +//! consumed by RSeQC `bam_stat` and by the samtools-compatible `stats`, +//! `flagstat` and `idxstats` writers. It needs no annotation and no library +//! protocol, so both the `rna` and `dna` pipelines drive the same struct: each +//! parallel worker owns one, and they are merged before conversion. + +use std::collections::HashMap; + +use rust_htslib::bam; + +use crate::common::bam_flags::*; +use crate::common::bam_stat::{BamStatResult, GcDepthBin}; + +/// Default GC-depth bin size in base pairs (matches upstream samtools default). +const GCD_BIN_SIZE: u64 = 20_000; + +// =================================================================== +// Merge helpers for Vec<[u64; N]> per-cycle arrays +// =================================================================== + +/// Merge two `Vec<[u64; N]>` arrays element-wise, extending target if shorter. +fn merge_vec_arrays(target: &mut Vec<[u64; N]>, source: Vec<[u64; N]>) { + if source.len() > target.len() { + target.resize(source.len(), [0u64; N]); + } + for (i, arr) in source.into_iter().enumerate() { + for j in 0..N { + target[i][j] += arr[j]; + } + } +} + +/// bam_stat accumulator — simple flag/MAPQ counting. +/// +/// Also collects the additional counters needed for samtools-compatible +/// flagstat, idxstats, and stats output. +#[derive(Debug)] +pub struct BamStatAccum { + // --- RSeQC bam_stat fields (original) --- + /// Total BAM records seen (primary + secondary + supplementary + unmapped). + pub total_records: u64, + /// Records with QC-fail flag (0x200). + pub qc_failed: u64, + /// Records with duplicate flag (0x400). + pub duplicates: u64, + /// Secondary alignment records (0x100). RSeQC calls these "non-primary". + pub non_primary: u64, + /// Unmapped reads (0x4). + pub unmapped: u64, + /// Mapped reads with MAPQ < cutoff. + pub non_unique: u64, + /// Mapped reads with MAPQ >= cutoff (uniquely mapped). + pub unique: u64, + /// Among unique reads: read1 in a pair. + pub read_1: u64, + /// Among unique reads: read2 in a pair. + pub read_2: u64, + /// Among unique reads: forward strand. + pub forward: u64, + /// Among unique reads: reverse strand. + pub reverse: u64, + /// Among unique reads: has splice junction (CIGAR N). + pub splice: u64, + /// Among unique reads: no splice junctions. + pub non_splice: u64, + /// Among unique reads: in proper pairs (0x2). + pub proper_pairs: u64, + /// Among proper-paired unique reads: mates on different chromosomes. + pub proper_pair_diff_chrom: u64, + + // --- samtools flagstat additional fields --- + /// Secondary alignments (0x100) — counted independently of QC/dup. + pub secondary: u64, + /// Supplementary alignments (0x800) — counted independently of QC/dup. + pub supplementary: u64, + /// All mapped records (not 0x4), regardless of QC/dup. + pub mapped: u64, + /// Paired reads (0x1), regardless of QC/dup. + pub paired_flagstat: u64, + /// Read1 in pair (0x40), regardless of QC/dup — for flagstat. + pub read1_flagstat: u64, + /// Read2 in pair (0x80), regardless of QC/dup — for flagstat. + pub read2_flagstat: u64, + /// First fragments for samtools stats: primary reads that are not "last fragments". + pub first_fragments: u64, + /// Last fragments for samtools stats: primary reads with 0x80 flag. + pub last_fragments: u64, + /// Properly paired reads (0x1 + 0x2), regardless of QC/dup. + pub properly_paired: u64, + /// Both mates mapped (paired + both !unmapped). + pub both_mapped: u64, + /// Singletons (paired, this mapped, mate unmapped). + pub singletons: u64, + /// Paired, both mapped, different reference. + pub mate_diff_chr: u64, + /// Paired, both mapped, different reference, MAPQ >= 5. + pub mate_diff_chr_mapq5: u64, + + // --- samtools idxstats additional fields --- + /// Per-reference (tid) mapped and unmapped counts. + pub chrom_counts: HashMap, + /// Unmapped reads with no reference (tid < 0). + pub unplaced_unmapped: u64, + + // --- samtools stats SN additional fields --- + /// Sum of query sequence lengths for all primary reads (non-secondary, non-supplementary). + pub total_len: u64, + /// Sum of first fragment (read1 or unpaired) sequence lengths. + pub total_first_fragment_len: u64, + /// Sum of last fragment (read2) sequence lengths. + pub total_last_fragment_len: u64, + /// Sum of query lengths for mapped primary reads. + pub bases_mapped: u64, + /// Sum of M/=/X CIGAR operations for mapped primary reads. + pub bases_mapped_cigar: u64, + /// Sum of query lengths for duplicate-flagged primary reads. + pub bases_duplicated: u64, + /// Maximum query sequence length (among primary reads). + pub max_len: u64, + /// Maximum first-fragment sequence length. + pub max_first_fragment_len: u64, + /// Maximum last-fragment sequence length. + pub max_last_fragment_len: u64, + /// Sum of average per-read base qualities (for average-of-averages). + pub quality_sum: f64, + /// Number of reads contributing to quality_sum (primary, non-QC-fail). + pub quality_count: u64, + /// Sum of NM tag values across mapped primary reads. + pub mismatches: u64, + /// Insert size with orientation: abs_tlen → [total, inward, outward, other]. + /// Only one mate per pair contributes (upstream mate), capped at 8000. + pub is_hist: HashMap, + /// Inward-oriented pairs (FR). + pub inward_pairs: u64, + /// Outward-oriented pairs (RF). + pub outward_pairs: u64, + /// Other orientation pairs (FF, RR). + pub other_orientation: u64, + /// Total primary reads (non-secondary, non-supplementary). + pub primary_count: u64, + /// Primary mapped reads count (non-secondary, non-supplementary, !unmapped). + pub primary_mapped: u64, + /// Primary duplicate reads. + pub primary_duplicates: u64, + /// Primary mapped reads with MAPQ = 0 (matching upstream samtools stats). + pub reads_mq0: u64, + /// Primary non-QC-fail mapped paired reads where mate is also mapped. + pub reads_mapped_and_paired: u64, + + // --- samtools stats histogram/distribution fields --- + /// Read length histogram (all primary reads): length → count. + pub rl_hist: HashMap, + /// First fragment read length histogram: length → count. + pub frl_hist: HashMap, + /// Last fragment read length histogram: length → count. + pub lrl_hist: HashMap, + /// MAPQ histogram: primary, mapped, !qcfail, !dup (quality 0-255). + pub mapq_hist: [u64; 256], + /// Per-cycle quality for first fragments (primary, mapped, !qcfail, !dup). + /// Outer: cycle index. Inner: quality value → count (64 buckets covers Q0-Q63). + pub ffq: Vec<[u64; 64]>, + /// Per-cycle quality for last fragments. + pub lfq: Vec<[u64; 64]>, + /// GC content step-function for first fragments, 200 bins (matching samtools ngc=200). + /// Each bin i stores the number of reads with gc_count * 199 / seq_len <= i. + pub gcf: [u64; 200], + /// GC content step-function for last fragments, 200 bins. + pub gcl: [u64; 200], + /// Per-cycle base composition for first fragments (primary, mapped, !qcfail, !dup). + /// [A, C, G, T, N, Other] per cycle. + pub fbc: Vec<[u64; 6]>, + /// Per-cycle base composition for last fragments. + pub lbc: Vec<[u64; 6]>, + /// Per-cycle base composition (read-oriented) for first fragments. + /// Reverse strand reads contribute in reversed cycle order. + pub fbc_ro: Vec<[u64; 6]>, + /// Per-cycle base composition (read-oriented) for last fragments. + pub lbc_ro: Vec<[u64; 6]>, + /// Per-cycle base composition (reverse-complemented for reverse-strand reads, + /// combined first+last fragments). Used for GCT output. [A, C, G, T] only. + pub gcc_rc: Vec<[u64; 4]>, + /// Total base counters for first fragments: [A, C, G, T, N]. + pub ftc: [u64; 5], + /// Total base counters for last fragments: [A, C, G, T, N]. + pub ltc: [u64; 5], + /// Indel distribution by size: length → [insertions, deletions]. + pub id_hist: HashMap, + /// Indels per cycle: cycle → [ins_fwd, ins_rev, del_fwd, del_rev]. + pub ic: Vec<[u64; 4]>, + /// CRC32 checksum sums: [names, sequences, qualities]. + /// Each is the wrapping u32 sum of per-read CRC32 values. + pub chk: [u32; 3], + /// Coverage distribution: depth → number of reference positions at that depth. + /// Populated from a round buffer pileup during sorted BAM processing. + pub cov_hist: HashMap, + /// Circular buffer for coverage pileup, matching upstream samtools design. + /// `cov_buf[cov_buf_idx]` corresponds to reference position `cov_buf_pos`. + /// The buffer grows dynamically to accommodate `max_read_length * 5`. + cov_buf: Vec, + /// Index into `cov_buf` corresponding to `cov_buf_pos`. + cov_buf_idx: usize, + /// Reference position of the element at `cov_buf[cov_buf_idx]`. + cov_buf_pos: i64, + /// Current chromosome tid for round buffer tracking. + cov_buf_tid: i32, + + // --- GC-depth (GCD section) fields --- + /// Accumulated GC-depth bins (one per `GCD_BIN_SIZE`-bp genomic window). + gcd_bins: Vec, + /// Start position of the current GCD bin. + gcd_pos: i64, + /// Chromosome tid of the current GCD bin. + gcd_tid: i32, +} + +impl Default for BamStatAccum { + fn default() -> Self { + Self { + total_records: 0, + qc_failed: 0, + duplicates: 0, + non_primary: 0, + unmapped: 0, + non_unique: 0, + unique: 0, + read_1: 0, + read_2: 0, + forward: 0, + reverse: 0, + splice: 0, + non_splice: 0, + proper_pairs: 0, + proper_pair_diff_chrom: 0, + secondary: 0, + supplementary: 0, + mapped: 0, + paired_flagstat: 0, + read1_flagstat: 0, + read2_flagstat: 0, + first_fragments: 0, + last_fragments: 0, + properly_paired: 0, + both_mapped: 0, + singletons: 0, + mate_diff_chr: 0, + mate_diff_chr_mapq5: 0, + chrom_counts: HashMap::new(), + unplaced_unmapped: 0, + total_len: 0, + total_first_fragment_len: 0, + total_last_fragment_len: 0, + bases_mapped: 0, + bases_mapped_cigar: 0, + bases_duplicated: 0, + max_len: 0, + max_first_fragment_len: 0, + max_last_fragment_len: 0, + quality_sum: 0.0, + quality_count: 0, + mismatches: 0, + is_hist: HashMap::new(), + inward_pairs: 0, + outward_pairs: 0, + other_orientation: 0, + primary_count: 0, + primary_mapped: 0, + primary_duplicates: 0, + reads_mq0: 0, + reads_mapped_and_paired: 0, + rl_hist: HashMap::new(), + frl_hist: HashMap::new(), + lrl_hist: HashMap::new(), + mapq_hist: [0u64; 256], + ffq: Vec::new(), + lfq: Vec::new(), + gcf: [0u64; 200], + gcl: [0u64; 200], + fbc: Vec::new(), + lbc: Vec::new(), + fbc_ro: Vec::new(), + lbc_ro: Vec::new(), + gcc_rc: Vec::new(), + ftc: [0u64; 5], + ltc: [0u64; 5], + id_hist: HashMap::new(), + ic: Vec::new(), + chk: [0u32; 3], + cov_hist: HashMap::new(), + cov_buf: vec![0u32; 1500], // matches upstream samtools: nbases * 5 = 300 * 5 + cov_buf_idx: 0, + cov_buf_pos: 0, + cov_buf_tid: -1, + gcd_bins: Vec::new(), + gcd_pos: -1, + gcd_tid: -1, + } + } +} + +impl BamStatAccum { + /// Process a single BAM record. Called for EVERY record (before counting filters). + /// + /// Collects counters for: + /// - RSeQC bam_stat (original cascade with early returns) + /// - samtools flagstat (counts all records independently) + /// - samtools idxstats (per-reference mapped/unmapped counts) + /// - samtools stats SN section (sequence lengths, quality, insert size, etc.) + pub fn process_read(&mut self, record: &bam::Record, mapq_cut: u8) { + let flags = record.flags(); + self.total_records += 1; + + let is_secondary = flags & BAM_FSECONDARY != 0; + let is_supplementary = flags & BAM_FSUPPLEMENTARY != 0; + let is_unmapped = flags & BAM_FUNMAP != 0; + let is_paired = flags & BAM_FPAIRED != 0; + let is_dup = flags & BAM_FDUP != 0; + let is_qcfail = flags & BAM_FQCFAIL != 0; + let is_primary = !is_secondary && !is_supplementary; + let is_mapped = !is_unmapped; + let tid = record.tid(); + let mapq = record.mapq(); + + // ================================================================= + // samtools flagstat counters (count ALL records, no early returns) + // ================================================================= + if is_secondary { + self.secondary += 1; + } + if is_supplementary { + self.supplementary += 1; + } + if is_mapped { + self.mapped += 1; + } + // samtools stats: "1st fragments" / "last fragments" count primary reads only + // For paired reads: read2 flag -> last, everything else -> 1st + // For SE reads (no PAIRED flag): all counted as 1st fragments + if is_primary { + if flags & BAM_FREAD2 != 0 { + self.last_fragments += 1; + } else { + self.first_fragments += 1; + } + } + // samtools flagstat: paired-read metrics count PRIMARY reads only + // (secondary/supplementary are excluded from paired/read1/read2/properly-paired counts) + if is_paired && is_primary { + self.paired_flagstat += 1; + if flags & BAM_FREAD1 != 0 { + self.read1_flagstat += 1; + } + if flags & BAM_FREAD2 != 0 { + self.read2_flagstat += 1; + } + if flags & BAM_FPROPER_PAIR != 0 { + self.properly_paired += 1; + } + let mate_unmapped = flags & BAM_FMUNMAP != 0; + if is_mapped && !mate_unmapped { + self.both_mapped += 1; + if tid != record.mtid() { + self.mate_diff_chr += 1; + if mapq >= 5 { + self.mate_diff_chr_mapq5 += 1; + } + } + } + if is_mapped && mate_unmapped { + self.singletons += 1; + } + } + + // ================================================================= + // samtools idxstats counters (per-reference) + // ================================================================= + if is_unmapped { + if tid >= 0 { + // Unmapped read placed on a reference (has tid) + self.chrom_counts.entry(tid).or_insert((0, 0)).1 += 1; + } else { + self.unplaced_unmapped += 1; + } + } else if tid >= 0 { + // Mapped read + self.chrom_counts.entry(tid).or_insert((0, 0)).0 += 1; + } + + // ================================================================= + // CHK checksums: computed on ALL reads (including secondary and + // supplementary). Matches samtools stats.c update_checksum() which + // is called before the secondary-read early return. + // ================================================================= + { + let qname = record.qname(); + let name_crc = crc32fast::hash(qname); + self.chk[0] = self.chk[0].wrapping_add(name_crc); + + let seq_len = record.seq_len(); + if seq_len > 0 { + // SAFETY: We access the raw BAM record data to compute CRC32 + // checksums matching samtools' approach. The pointer arithmetic + // replicates htslib's bam_get_seq() macro: + // data + l_qname + (n_cigar << 2) + // The seq_len > 0 guard above ensures sequence data exists. + // The slice length seq_len.div_ceil(2) matches the BAM spec's + // 4-bit encoded sequence format: (seq_len+1)/2 bytes. + let seq_bytes = unsafe { + let inner = record.inner(); + let data = inner.data; + let seq_offset = + inner.core.l_qname as isize + ((inner.core.n_cigar as isize) << 2); + let seq_nbytes = seq_len.div_ceil(2); + std::slice::from_raw_parts(data.offset(seq_offset), seq_nbytes) + }; + let seq_crc = crc32fast::hash(seq_bytes); + self.chk[1] = self.chk[1].wrapping_add(seq_crc); + + let qual = record.qual(); + let qual_crc = crc32fast::hash(qual); + self.chk[2] = self.chk[2].wrapping_add(qual_crc); + } + } + + // Track gc_count from the primary-read per-cycle loop so the GCD + // section below can reuse it without re-scanning the sequence. + let mut primary_gc_count: u64 = 0; + + // ================================================================= + // samtools stats SN counters (primary reads only) + // ================================================================= + if is_primary { + self.primary_count += 1; + let seq_len = record.seq_len() as u64; + let mate_unmapped = flags & BAM_FMUNMAP != 0; + + self.total_len += seq_len; + let is_last_fragment = is_paired && flags & BAM_FREAD2 != 0; + if is_last_fragment { + self.total_last_fragment_len += seq_len; + if seq_len > self.max_last_fragment_len { + self.max_last_fragment_len = seq_len; + } + } else { + self.total_first_fragment_len += seq_len; + if seq_len > self.max_first_fragment_len { + self.max_first_fragment_len = seq_len; + } + } + if seq_len > self.max_len { + self.max_len = seq_len; + } + + // RL/FRL/LRL: read length histograms (all primary reads) + *self.rl_hist.entry(seq_len).or_insert(0) += 1; + if is_last_fragment { + *self.lrl_hist.entry(seq_len).or_insert(0) += 1; + } else { + *self.frl_hist.entry(seq_len).or_insert(0) += 1; + } + + if is_dup { + self.primary_duplicates += 1; + self.bases_duplicated += seq_len; + } + // "reads mapped and paired" for samtools stats: primary, non-QC-fail, + // mapped, paired, mate also mapped + if is_mapped && is_paired && !is_qcfail && !mate_unmapped { + self.reads_mapped_and_paired += 1; + } + if is_mapped { + self.primary_mapped += 1; + self.bases_mapped += seq_len; + + // samtools stats: reads MQ0 counts primary mapped reads with MAPQ=0 + // (upstream stats.c: MQ0 is counted inside collect_orig_read_stats, + // which is only called for IS_ORIGINAL reads = non-secondary, non-supplementary) + if record.mapq() == 0 { + self.reads_mq0 += 1; + } + + // NOTE: bases_mapped_cigar is now computed in the IC/ID CIGAR + // loop below (for all mapped non-secondary reads) to avoid a + // separate full CIGAR traversal here. + + // NM tag (edit distance) + if let Ok(rust_htslib::bam::record::Aux::U8(nm)) = record.aux(b"NM") { + self.mismatches += u64::from(nm); + } else if let Ok(rust_htslib::bam::record::Aux::U16(nm)) = record.aux(b"NM") { + self.mismatches += u64::from(nm); + } else if let Ok(rust_htslib::bam::record::Aux::U32(nm)) = record.aux(b"NM") { + self.mismatches += u64::from(nm); + } else if let Ok(rust_htslib::bam::record::Aux::I8(nm)) = record.aux(b"NM") { + if nm > 0 { + self.mismatches += nm as u64; + } + } else if let Ok(rust_htslib::bam::record::Aux::I16(nm)) = record.aux(b"NM") { + if nm > 0 { + self.mismatches += nm as u64; + } + } else if let Ok(rust_htslib::bam::record::Aux::I32(nm)) = record.aux(b"NM") { + if nm > 0 { + self.mismatches += nm as u64; + } + } + + // Insert size + orientation for paired primary reads where both + // mates are mapped. Matches samtools stats gate: + // IS_PAIRED_AND_MAPPED && IS_ORIGINAL + // if (isize > 0 || tid == mtid) + // Both mates contribute; samtools divides by 2 at output. + // We do the same in write_insert_size() and the SN section. + if is_paired && !mate_unmapped { + let tid = record.tid(); + let mtid = record.mtid(); + let tlen = record.insert_size(); + let abs_tlen = tlen.unsigned_abs(); + + if abs_tlen > 0 || tid == mtid { + let pos = record.pos(); + let mpos = record.mpos(); + + // Compute orientation (only meaningful for same-chromosome) + let pos_fst = mpos - pos; + let is_fst: i64 = if flags & BAM_FREAD1 != 0 { 1 } else { -1 }; + let is_fwd: i64 = if flags & BAM_FREVERSE != 0 { -1 } else { 1 }; + let is_mfwd: i64 = if flags & BAM_FMREVERSE != 0 { -1 } else { 1 }; + + // orientation_idx: 1=inward, 2=outward, 3=other + let orientation_idx = if is_fwd * is_mfwd > 0 { + self.other_orientation += 1; + 3usize + } else if is_fst * pos_fst > 0 { + if is_fst * is_fwd > 0 { + self.inward_pairs += 1; + 1usize + } else { + self.outward_pairs += 1; + 2usize + } + } else if is_fst * pos_fst < 0 { + if is_fst * is_fwd > 0 { + self.outward_pairs += 1; + 2usize + } else { + self.inward_pairs += 1; + 1usize + } + } else { + self.inward_pairs += 1; + 1usize + }; + + if abs_tlen > 0 { + // Cap at MAX_INSERT_SIZE (8000), matching + // samtools stats which accumulates overflow + // into the cap bucket. + let capped = abs_tlen.min(8000); + let entry = self.is_hist.entry(capped).or_insert([0; 4]); + entry[0] += 1; // total + entry[orientation_idx] += 1; + } + } + } + } + + // Average quality for primary non-QC-fail reads. + // Upstream samtools stats computes per-BASE quality average: + // sum of all individual base qualities / total bases. + // (Not a per-read average of averages.) + if !is_qcfail { + let quals = record.qual(); + if !quals.is_empty() { + let base_qual_sum: f64 = quals.iter().map(|&q| f64::from(q)).sum::(); + self.quality_sum += base_qual_sum; + self.quality_count += quals.len() as u64; + } + } + + // ============================================================= + // MAPQ histogram: primary + mapped + !qcfail + !dup + // (matches samtools stats.c:1239 five-flag exclusion) + // ============================================================= + if is_mapped && !is_qcfail && !is_dup { + self.mapq_hist[mapq as usize] += 1; + } + + // ============================================================= + // Per-cycle quality & base composition histograms: + // FFQ/LFQ, FBC/LBC, GCF/GCL, FTC/LTC, FBC_RO/LBC_RO + // + // Upstream samtools stats includes duplicates, unmapped, and + // qcfail reads in these histograms (collect_orig_read_stats + // has no such checks). Only secondary+supplementary are + // excluded (via IS_ORIGINAL), which is already handled by + // the outer is_primary guard. + // ============================================================= + { + let is_reverse = flags & BAM_FREVERSE != 0; + + let seq = record.seq(); + let quals = record.qual(); + let read_len = seq.len(); + + // Determine which arrays to use (first vs last fragment) + // If paired: read2 = last, read1 = first. If SE: all = first. + let (qual_arr, base_arr, base_ro_arr, gc_arr, tc_arr) = if is_last_fragment { + ( + &mut self.lfq, + &mut self.lbc, + &mut self.lbc_ro, + &mut self.gcl, + &mut self.ltc, + ) + } else { + ( + &mut self.ffq, + &mut self.fbc, + &mut self.fbc_ro, + &mut self.gcf, + &mut self.ftc, + ) + }; + + // Ensure per-cycle arrays are large enough + if read_len > qual_arr.len() { + qual_arr.resize(read_len, [0u64; 64]); + } + if read_len > base_arr.len() { + base_arr.resize(read_len, [0u64; 6]); + } + if read_len > base_ro_arr.len() { + base_ro_arr.resize(read_len, [0u64; 6]); + } + if read_len > self.gcc_rc.len() { + self.gcc_rc.resize(read_len, [0u64; 4]); + } + + let mut gc_count: u64 = 0; + + // Pre-built lookup tables for the per-cycle inner loop, + // avoiding branches and match overhead on every base. + // + // BAM 4-bit encoding: A=1, C=2, G=4, T=8, N=15, others=0,3,5..14 + // BASE_IDX[nibble] → 0=A, 1=C, 2=G, 3=T, 4=N, 5=Other + const BASE_IDX: [u8; 16] = [5, 0, 1, 5, 2, 5, 5, 5, 3, 5, 5, 5, 5, 5, 5, 4]; + // RC_IDX[base_idx] → reverse-complement base_idx (A↔T, C↔G) + // Only meaningful for base_idx 0-3 (ACGT). Index 4/5 not used. + const RC_IDX: [u8; 6] = [3, 2, 1, 0, 4, 5]; // A→T, C→G, G→C, T→A + + // Hoist the is_reverse branch outside the inner loop so the + // compiler can version the loop and potentially auto-vectorize + // each variant independently. + if !is_reverse { + for i in 0..read_len { + let q = quals[i] as usize; + qual_arr[i][q.min(63)] += 1; + + let base_idx = BASE_IDX[seq.encoded_base(i) as usize] as usize; + base_arr[i][base_idx] += 1; + base_ro_arr[i][base_idx] += 1; + if base_idx < 4 { + self.gcc_rc[i][base_idx] += 1; + } + if base_idx == 1 || base_idx == 2 { + gc_count += 1; + } + if base_idx < 5 { + tc_arr[base_idx] += 1; + } + } + } else { + for i in 0..read_len { + let ro_cycle = read_len - 1 - i; + let q = quals[i] as usize; + qual_arr[ro_cycle][q.min(63)] += 1; + + let base_idx = BASE_IDX[seq.encoded_base(i) as usize] as usize; + base_arr[i][base_idx] += 1; + base_ro_arr[ro_cycle][base_idx] += 1; + if base_idx < 4 { + self.gcc_rc[ro_cycle][RC_IDX[base_idx] as usize] += 1; + } + if base_idx == 1 || base_idx == 2 { + gc_count += 1; + } + if base_idx < 5 { + tc_arr[base_idx] += 1; + } + } + } + + // Save gc_count for GCD section below (avoids re-scanning the sequence). + primary_gc_count = gc_count; + + // GC content: cumulative step function with ngc=200 bins. + // Matches samtools stats.c:925-941. For a read with gc_count G/C + // bases out of read_len total, increment bins gc_idx_min..gc_idx_max. + let ngc: usize = 200; + if let (Some(gc_idx_min), Some(gc_idx_max)) = ( + (gc_count as usize * (ngc - 1)).checked_div(read_len), + ((gc_count as usize + 1) * (ngc - 1)).checked_div(read_len), + ) { + let gc_idx_max = gc_idx_max.min(ngc - 1); + for item in gc_arr.iter_mut().take(gc_idx_max).skip(gc_idx_min) { + *item += 1; + } + } + } + } // if is_primary + + // ============================================================= + // Indel distribution (ID) and indels per cycle (IC) from CIGAR. + // + // Upstream samtools stats calls count_indels() AFTER the + // secondary-read early return (line 1206-1210) and the + // IS_UNMAPPED return (line 1255), but OUTSIDE IS_ORIGINAL(). + // This means: all mapped, non-secondary reads are included + // (supplementary, duplicate, qcfail all contribute). + // + // IC uses first-fragment/last-fragment read order (not + // forward/reverse strand) and read-oriented cycle indices, + // matching upstream count_indels(). + // ============================================================= + // ============================================================= + // Combined single-CIGAR-pass block for IC/ID (indel distribution), + // bases_mapped_cigar, and COV (coverage ring-buffer pileup). + // + // Both IC/ID and COV apply to the same read set (mapped, + // non-secondary). Merging them into one CIGAR traversal + // eliminates two redundant record.cigar() calls per read. + // + // IC/ID: Upstream samtools stats calls count_indels() outside + // IS_ORIGINAL() — supplementary/dup/qcfail all contribute. + // IC uses first/last-fragment order and read-oriented cycles. + // + // COV: Circular-buffer pileup; buffer flushed up to read start + // before CIGAR walk; M/=/X blocks inserted as ranges. + // Buffer grown to max_read_len * 5 as needed. + // ============================================================= + if is_mapped && !is_secondary { + use rust_htslib::bam::record::Cigar as C; + let is_reverse = flags & BAM_FREVERSE != 0; + let read_len = record.seq_len(); + let tid = record.tid(); + let pos = record.pos(); // 0-based + + // Upstream order: paired ? (read1?FIRST:0)+(read2?LAST:0) : FIRST + let order: u32 = if is_paired { + (if flags & BAM_FREAD1 != 0 { 1 } else { 0 }) + + (if flags & BAM_FREAD2 != 0 { 2 } else { 0 }) + } else { + 1 // unpaired → FIRST + }; + + // COV buffer setup (must happen before CIGAR walk). + // Skip reads with no sequence (upstream samtools early-return). + let do_cov = read_len > 0; + let buf_size = if do_cov { + // Grow buffer to max_read_len * 5 if needed. + // When growing, linearise the circular data just like + // upstream samtools: copy [idx..old_size] then [0..idx] + // into a fresh buffer, and reset idx to 0. + let need = read_len * 5; + if need > self.cov_buf.len() { + let old_size = self.cov_buf.len(); + let mut new_buf = vec![0u32; need]; + let head = old_size - self.cov_buf_idx; + new_buf[..head].copy_from_slice(&self.cov_buf[self.cov_buf_idx..]); + new_buf[head..head + self.cov_buf_idx] + .copy_from_slice(&self.cov_buf[..self.cov_buf_idx]); + self.cov_buf = new_buf; + self.cov_buf_idx = 0; + } + let bs = self.cov_buf.len(); + // Flush entire buffer on chromosome change + if tid != self.cov_buf_tid { + self.flush_cov_buf_all(); + self.cov_buf_tid = tid; + self.cov_buf_pos = pos; + self.cov_buf_idx = 0; + } + // Flush positions from cov_buf_pos up to read start + self.cov_buf_flush_to(pos, bs); + bs + } else { + 0 + }; + + // Single CIGAR traversal serving IC/ID + bases_mapped_cigar + COV + let cigar = record.cigar(); + let mut icycle: usize = 0; + let mut cigar_mapped: u64 = 0; + let mut ref_pos = pos; + + for op in cigar.iter() { + match op { + C::Ins(n) => { + let ncig = *n as usize; + let len = *n as u64; + cigar_mapped += len; // I counts toward bases_mapped_cigar + + // ID: indel size distribution + let id_entry = self.id_hist.entry(len).or_insert([0; 2]); + id_entry[0] += 1; // insertions + + // IC: indels per cycle (read-oriented index) + let idx = if is_reverse { + read_len.saturating_sub(icycle + ncig) + } else { + icycle + }; + if idx >= self.ic.len() { + self.ic.resize(idx + 1, [0u64; 4]); + } + if order == 1 { + self.ic[idx][0] += 1; // ins_1st + } + if order == 2 { + self.ic[idx][1] += 1; // ins_2nd + } + + icycle += ncig; // I advances query cycle; ref unchanged + // COV: I consumes no reference positions + } + C::Del(n) => { + let len = *n as u64; + // ID: indel size distribution + let id_entry = self.id_hist.entry(len).or_insert([0; 2]); + id_entry[1] += 1; // deletions + + // IC: indels per cycle (read-oriented index) + let idx = if is_reverse { + if icycle == 0 { + // Discard meaningless deletions at cycle 0 + // (upstream: "if (idx<0) continue;") + ref_pos += *n as i64; // still advance ref for COV + continue; + } + read_len.saturating_sub(icycle + 1) + } else { + if icycle == 0 { + ref_pos += *n as i64; + continue; + } + icycle - 1 + }; + if idx >= self.ic.len() { + self.ic.resize(idx + 1, [0u64; 4]); + } + if order == 1 { + self.ic[idx][2] += 1; // del_1st + } + if order == 2 { + self.ic[idx][3] += 1; // del_2nd + } + // D does NOT advance query cycle; does advance ref + ref_pos += *n as i64; + } + C::Match(n) | C::Equal(n) | C::Diff(n) => { + let len = *n as u64; + cigar_mapped += len; // M/=/X count toward bases_mapped_cigar + icycle += *n as usize; + // COV: M/=/X consumes reference positions + if do_cov { + let end = ref_pos + *n as i64; + self.cov_buf_insert(ref_pos, end, buf_size); + ref_pos = end; + } else { + ref_pos += *n as i64; + } + } + C::RefSkip(n) => { + ref_pos += *n as i64; // N advances ref (COV skips it) + } + C::SoftClip(n) => { + icycle += *n as usize; // S advances query cycle + // COV: S consumes no reference positions + } + C::HardClip(_) | C::Pad(_) => {} + } + } + self.bases_mapped_cigar += cigar_mapped; + } // if is_mapped && !is_secondary (IC/ID + COV combined) + + // ============================================================= + // GCD: GC-depth accumulation (no-reference path). + // + // Matches upstream samtools stats without --ref-seq: bins of + // GCD_BIN_SIZE bp, depth incremented for each read, GC fraction + // accumulated from the read's sequence. + // + // Included reads: mapped, non-secondary (same as COV). + // + // NOTE: gc_count_for_gcd is set from the primary-read per-cycle + // loop above (when is_primary is true), or computed here only for + // non-primary mapped reads, avoiding a redundant full sequence scan. + // ============================================================= + if is_mapped && !is_secondary { + let tid = record.tid(); + let pos = record.pos(); + let seq_len = record.seq_len(); + + if seq_len > 0 { + // Start a new bin on: first read, chromosome change, or + // read beyond current bin boundary. + let new_bin = self.gcd_pos < 0 + || tid != self.gcd_tid + || pos - self.gcd_pos > GCD_BIN_SIZE as i64; + + if new_bin { + self.gcd_bins.push(GcDepthBin { gc: 0.0, depth: 0 }); + self.gcd_pos = pos; + self.gcd_tid = tid; + } + + // Increment depth and accumulate GC fraction from read seq. + if let Some(bin) = self.gcd_bins.last_mut() { + bin.depth += 1; + // For primary reads, gc_count was already computed in the + // per-cycle base loop above. For non-primary mapped reads + // (supplementary, etc.) compute it here from the sequence. + let gc_count: u32 = if is_primary { + primary_gc_count as u32 + } else { + let seq = record.seq(); + let mut count: u32 = 0; + for i in 0..seq_len { + let base = seq.encoded_base(i); + if base == 2 || base == 4 { + count += 1; + } + } + count + }; + bin.gc += gc_count as f32 / seq_len as f32; + } + } + } // if is_mapped && !is_secondary (GCD) + + // ================================================================= + // RSeQC bam_stat cascade (original logic, with early returns) + // ================================================================= + + // 1. QC-failed + if is_qcfail { + self.qc_failed += 1; + return; + } + + // 2. Duplicate + if is_dup { + self.duplicates += 1; + return; + } + + // 3. Secondary (non-primary) — NOT supplementary + if is_secondary { + self.non_primary += 1; + return; + } + + // 4. Unmapped + if is_unmapped { + self.unmapped += 1; + return; + } + + // 5. MAPQ classification + if mapq < mapq_cut { + self.non_unique += 1; + return; + } + + // Uniquely mapped + self.unique += 1; + + if flags & BAM_FREAD1 != 0 { + self.read_1 += 1; + } + if flags & BAM_FREAD2 != 0 { + self.read_2 += 1; + } + if flags & BAM_FREVERSE != 0 { + self.reverse += 1; + } else { + self.forward += 1; + } + + // Splice detection: CIGAR N operation + let has_splice = record + .cigar() + .iter() + .any(|op| matches!(op, rust_htslib::bam::record::Cigar::RefSkip(_))); + if has_splice { + self.splice += 1; + } else { + self.non_splice += 1; + } + + // Proper pair analysis + if is_paired && flags & BAM_FPROPER_PAIR != 0 { + self.proper_pairs += 1; + if tid != record.mtid() { + self.proper_pair_diff_chrom += 1; + } + } + } + + /// Flush all remaining positions in the coverage round buffer into cov_hist. + /// Must be called after processing all reads (or when switching chromosomes). + /// Flush the circular buffer from `cov_buf_pos` up to (but not including) `pos`. + /// Each slot's depth is recorded in `cov_hist` and the slot is zeroed. + /// Matches upstream `round_buffer_flush` logic from samtools stats.c. + fn cov_buf_flush_to(&mut self, pos: i64, buf_size: usize) { + if pos - self.cov_buf_pos >= buf_size as i64 { + // Gap exceeds buffer size. Match upstream samtools exactly: + // flush `size - 1` positions (from cov_buf_pos to + // cov_buf_pos + size - 2), leaving the LAST slot untouched. + // Then advance idx by `size - 1` and jump pos. + // + // Upstream (stats.c round_buffer_flush lines 334-366): + // pos = rbuf.pos + size - 1; // cap at last slot + // ito = lidx2ridx(start, size, rbuf.pos, pos-1); + // // flush from start to ito (size-1 slots) + // rbuf.start = lidx2ridx(start, size, rbuf.pos, pos); + // rbuf.pos = new_pos; + let flush_count = buf_size - 1; // flush all but the last slot + for _ in 0..flush_count { + let depth = self.cov_buf[self.cov_buf_idx]; + if depth > 0 { + *self.cov_hist.entry(depth).or_insert(0) += 1; + self.cov_buf[self.cov_buf_idx] = 0; + } + self.cov_buf_idx += 1; + if self.cov_buf_idx >= buf_size { + self.cov_buf_idx = 0; + } + } + // idx now points to the ONE unflushed slot (the last position + // in the old window). Jump pos to the new read position. + self.cov_buf_pos = pos; + } else { + // Normal case: flush slot by slot. + while self.cov_buf_pos < pos { + let depth = self.cov_buf[self.cov_buf_idx]; + if depth > 0 { + *self.cov_hist.entry(depth).or_insert(0) += 1; + self.cov_buf[self.cov_buf_idx] = 0; + } + self.cov_buf_idx += 1; + if self.cov_buf_idx >= buf_size { + self.cov_buf_idx = 0; + } + self.cov_buf_pos += 1; + } + } + } + + /// Insert a contiguous reference range `[from, to)` into the circular buffer, + /// incrementing depth for each position. The range must fit within `buf_size`. + fn cov_buf_insert(&mut self, from: i64, to: i64, buf_size: usize) { + for ref_pos in from..to { + // Map ref_pos to buffer index: offset from cov_buf_idx by (ref_pos - cov_buf_pos) + let offset = (ref_pos - self.cov_buf_pos) as usize; + let idx = (self.cov_buf_idx + offset) % buf_size; + self.cov_buf[idx] += 1; + } + } + + /// Flush the entire circular buffer and reset tracking state. + pub fn flush_cov_buf_all(&mut self) { + for slot in self.cov_buf.iter_mut() { + if *slot > 0 { + *self.cov_hist.entry(*slot).or_insert(0) += 1; + *slot = 0; + } + } + self.cov_buf_idx = 0; + self.cov_buf_pos = 0; + self.cov_buf_tid = -1; + } + + /// Merge another accumulator into this one. + pub fn merge(&mut self, mut other: BamStatAccum) { + // Flush any remaining positions in the other's round buffer into its + // cov_hist before merging. Without this, positions still in the + // round buffer would be silently lost during parallel merges. + other.flush_cov_buf_all(); + + // RSeQC bam_stat fields + self.total_records += other.total_records; + self.qc_failed += other.qc_failed; + self.duplicates += other.duplicates; + self.non_primary += other.non_primary; + self.unmapped += other.unmapped; + self.non_unique += other.non_unique; + self.unique += other.unique; + self.read_1 += other.read_1; + self.read_2 += other.read_2; + self.forward += other.forward; + self.reverse += other.reverse; + self.splice += other.splice; + self.non_splice += other.non_splice; + self.proper_pairs += other.proper_pairs; + self.proper_pair_diff_chrom += other.proper_pair_diff_chrom; + + // samtools flagstat fields + self.secondary += other.secondary; + self.supplementary += other.supplementary; + self.mapped += other.mapped; + self.paired_flagstat += other.paired_flagstat; + self.read1_flagstat += other.read1_flagstat; + self.read2_flagstat += other.read2_flagstat; + self.first_fragments += other.first_fragments; + self.last_fragments += other.last_fragments; + self.properly_paired += other.properly_paired; + self.both_mapped += other.both_mapped; + self.singletons += other.singletons; + self.mate_diff_chr += other.mate_diff_chr; + self.mate_diff_chr_mapq5 += other.mate_diff_chr_mapq5; + + // samtools idxstats fields + for (tid, (m, u)) in other.chrom_counts { + let entry = self.chrom_counts.entry(tid).or_insert((0, 0)); + entry.0 += m; + entry.1 += u; + } + self.unplaced_unmapped += other.unplaced_unmapped; + + // samtools stats SN fields + self.total_len += other.total_len; + self.total_first_fragment_len += other.total_first_fragment_len; + self.total_last_fragment_len += other.total_last_fragment_len; + self.bases_mapped += other.bases_mapped; + self.bases_mapped_cigar += other.bases_mapped_cigar; + self.bases_duplicated += other.bases_duplicated; + if other.max_len > self.max_len { + self.max_len = other.max_len; + } + if other.max_first_fragment_len > self.max_first_fragment_len { + self.max_first_fragment_len = other.max_first_fragment_len; + } + if other.max_last_fragment_len > self.max_last_fragment_len { + self.max_last_fragment_len = other.max_last_fragment_len; + } + self.quality_sum += other.quality_sum; + self.quality_count += other.quality_count; + self.mismatches += other.mismatches; + for (isize_val, counts) in other.is_hist { + let entry = self.is_hist.entry(isize_val).or_insert([0; 4]); + for i in 0..4 { + entry[i] += counts[i]; + } + } + self.inward_pairs += other.inward_pairs; + self.outward_pairs += other.outward_pairs; + self.other_orientation += other.other_orientation; + self.primary_count += other.primary_count; + self.primary_mapped += other.primary_mapped; + self.primary_duplicates += other.primary_duplicates; + self.reads_mq0 += other.reads_mq0; + self.reads_mapped_and_paired += other.reads_mapped_and_paired; + + // Histogram/distribution fields + for (len, count) in other.rl_hist { + *self.rl_hist.entry(len).or_insert(0) += count; + } + for (len, count) in other.frl_hist { + *self.frl_hist.entry(len).or_insert(0) += count; + } + for (len, count) in other.lrl_hist { + *self.lrl_hist.entry(len).or_insert(0) += count; + } + for i in 0..256 { + self.mapq_hist[i] += other.mapq_hist[i]; + } + + // Per-cycle quality arrays (FFQ/LFQ) + merge_vec_arrays(&mut self.ffq, other.ffq); + merge_vec_arrays(&mut self.lfq, other.lfq); + + // GC content distributions (200 bins) + for i in 0..200 { + self.gcf[i] += other.gcf[i]; + self.gcl[i] += other.gcl[i]; + } + + // Per-cycle base composition (FBC/LBC and read-oriented) + merge_vec_arrays(&mut self.fbc, other.fbc); + merge_vec_arrays(&mut self.lbc, other.lbc); + merge_vec_arrays(&mut self.fbc_ro, other.fbc_ro); + merge_vec_arrays(&mut self.lbc_ro, other.lbc_ro); + merge_vec_arrays(&mut self.gcc_rc, other.gcc_rc); + + // Total base counters + for i in 0..5 { + self.ftc[i] += other.ftc[i]; + self.ltc[i] += other.ltc[i]; + } + + // Indel distribution + for (len, counts) in other.id_hist { + let entry = self.id_hist.entry(len).or_insert([0; 2]); + entry[0] += counts[0]; + entry[1] += counts[1]; + } + + // Indels per cycle + merge_vec_arrays(&mut self.ic, other.ic); + + // CHK checksums (wrapping u32 addition) + for i in 0..3 { + self.chk[i] = self.chk[i].wrapping_add(other.chk[i]); + } + + // COV histogram (additive merge) + for (depth, count) in other.cov_hist { + *self.cov_hist.entry(depth).or_insert(0) += count; + } + + // GCD bins (concatenate — bins from different chromosome workers + // are independent and will be sorted during output). + self.gcd_bins.append(&mut other.gcd_bins); + } +} + +impl BamStatAccum { + /// Convert accumulated counters into a `BamStatResult` for output. + pub fn into_result(mut self) -> BamStatResult { + // Flush remaining positions in the coverage round buffer + self.flush_cov_buf_all(); + BamStatResult { + // RSeQC bam_stat fields + total_records: self.total_records, + qc_failed: self.qc_failed, + duplicates: self.duplicates, + non_primary: self.non_primary, + unmapped: self.unmapped, + non_unique: self.non_unique, + unique: self.unique, + read_1: self.read_1, + read_2: self.read_2, + forward: self.forward, + reverse: self.reverse, + splice: self.splice, + non_splice: self.non_splice, + proper_pairs: self.proper_pairs, + proper_pair_diff_chrom: self.proper_pair_diff_chrom, + // samtools flagstat fields + secondary: self.secondary, + supplementary: self.supplementary, + mapped: self.mapped, + paired_flagstat: self.paired_flagstat, + read1_flagstat: self.read1_flagstat, + read2_flagstat: self.read2_flagstat, + first_fragments: self.first_fragments, + last_fragments: self.last_fragments, + properly_paired: self.properly_paired, + both_mapped: self.both_mapped, + singletons: self.singletons, + mate_diff_chr: self.mate_diff_chr, + mate_diff_chr_mapq5: self.mate_diff_chr_mapq5, + // samtools idxstats fields + chrom_counts: self.chrom_counts, + unplaced_unmapped: self.unplaced_unmapped, + // samtools stats SN fields + total_len: self.total_len, + total_first_fragment_len: self.total_first_fragment_len, + total_last_fragment_len: self.total_last_fragment_len, + bases_mapped: self.bases_mapped, + bases_mapped_cigar: self.bases_mapped_cigar, + bases_duplicated: self.bases_duplicated, + max_len: self.max_len, + max_first_fragment_len: self.max_first_fragment_len, + max_last_fragment_len: self.max_last_fragment_len, + quality_sum: self.quality_sum, + quality_count: self.quality_count, + mismatches: self.mismatches, + is_hist: self.is_hist, + inward_pairs: self.inward_pairs, + outward_pairs: self.outward_pairs, + other_orientation: self.other_orientation, + primary_count: self.primary_count, + primary_mapped: self.primary_mapped, + primary_duplicates: self.primary_duplicates, + reads_mq0: self.reads_mq0, + reads_mapped_and_paired: self.reads_mapped_and_paired, + // Histogram/distribution fields + rl_hist: self.rl_hist, + frl_hist: self.frl_hist, + lrl_hist: self.lrl_hist, + mapq_hist: self.mapq_hist, + ffq: self.ffq, + lfq: self.lfq, + gcf: self.gcf, + gcl: self.gcl, + fbc: self.fbc, + lbc: self.lbc, + fbc_ro: self.fbc_ro, + lbc_ro: self.lbc_ro, + gcc_rc: self.gcc_rc, + ftc: self.ftc, + ltc: self.ltc, + id_hist: self.id_hist, + ic: self.ic, + chk: self.chk, + cov_hist: self.cov_hist, + gcd_bins: self.gcd_bins, + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 7f927ebb..31c3a7d3 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -7,6 +7,7 @@ pub mod bam_flags; pub mod bam_stat; +pub mod bam_stat_accum; pub mod cpp_rng; pub mod preseq; pub mod samtools; diff --git a/src/rna/rseqc/accumulators.rs b/src/rna/rseqc/accumulators.rs index b91a409e..20f2b964 100644 --- a/src/rna/rseqc/accumulators.rs +++ b/src/rna/rseqc/accumulators.rs @@ -11,10 +11,8 @@ use anyhow::Result; use indexmap::IndexMap; use rust_htslib::bam; -use super::bam_stat::{BamStatResult, GcDepthBin}; - -/// Default GC-depth bin size in base pairs (matches upstream samtools default). -const GCD_BIN_SIZE: u64 = 20_000; +// BamStatAccum is read-level and assay-agnostic; it lives in `crate::common` +// and is shared with the dna pipeline. Re-exported so existing paths resolve. use super::common::{self, KnownJunctionSet, ReferenceJunctions}; use super::infer_experiment::{GeneModel, InferExperimentResult}; use super::inner_distance::{ @@ -25,6 +23,7 @@ use super::junction_saturation::SaturationResult; use super::read_distribution::{ChromIntervals, ReadDistributionResult, RegionSets}; use super::read_duplication::ReadDuplicationResult; use super::tin::TinAccum; +pub use crate::common::bam_stat_accum::BamStatAccum; use crate::rna::preseq::PreseqAccum; use crate::rna::bam_flags::*; @@ -116,1200 +115,6 @@ pub struct RseqcConfig { // Per-tool accumulators // =================================================================== -/// bam_stat accumulator — simple flag/MAPQ counting. -/// -/// Also collects the additional counters needed for samtools-compatible -/// flagstat, idxstats, and stats output. -#[derive(Debug)] -pub struct BamStatAccum { - // --- RSeQC bam_stat fields (original) --- - /// Total BAM records seen (primary + secondary + supplementary + unmapped). - pub total_records: u64, - /// Records with QC-fail flag (0x200). - pub qc_failed: u64, - /// Records with duplicate flag (0x400). - pub duplicates: u64, - /// Secondary alignment records (0x100). RSeQC calls these "non-primary". - pub non_primary: u64, - /// Unmapped reads (0x4). - pub unmapped: u64, - /// Mapped reads with MAPQ < cutoff. - pub non_unique: u64, - /// Mapped reads with MAPQ >= cutoff (uniquely mapped). - pub unique: u64, - /// Among unique reads: read1 in a pair. - pub read_1: u64, - /// Among unique reads: read2 in a pair. - pub read_2: u64, - /// Among unique reads: forward strand. - pub forward: u64, - /// Among unique reads: reverse strand. - pub reverse: u64, - /// Among unique reads: has splice junction (CIGAR N). - pub splice: u64, - /// Among unique reads: no splice junctions. - pub non_splice: u64, - /// Among unique reads: in proper pairs (0x2). - pub proper_pairs: u64, - /// Among proper-paired unique reads: mates on different chromosomes. - pub proper_pair_diff_chrom: u64, - - // --- samtools flagstat additional fields --- - /// Secondary alignments (0x100) — counted independently of QC/dup. - pub secondary: u64, - /// Supplementary alignments (0x800) — counted independently of QC/dup. - pub supplementary: u64, - /// All mapped records (not 0x4), regardless of QC/dup. - pub mapped: u64, - /// Paired reads (0x1), regardless of QC/dup. - pub paired_flagstat: u64, - /// Read1 in pair (0x40), regardless of QC/dup — for flagstat. - pub read1_flagstat: u64, - /// Read2 in pair (0x80), regardless of QC/dup — for flagstat. - pub read2_flagstat: u64, - /// First fragments for samtools stats: primary reads that are not "last fragments". - pub first_fragments: u64, - /// Last fragments for samtools stats: primary reads with 0x80 flag. - pub last_fragments: u64, - /// Properly paired reads (0x1 + 0x2), regardless of QC/dup. - pub properly_paired: u64, - /// Both mates mapped (paired + both !unmapped). - pub both_mapped: u64, - /// Singletons (paired, this mapped, mate unmapped). - pub singletons: u64, - /// Paired, both mapped, different reference. - pub mate_diff_chr: u64, - /// Paired, both mapped, different reference, MAPQ >= 5. - pub mate_diff_chr_mapq5: u64, - - // --- samtools idxstats additional fields --- - /// Per-reference (tid) mapped and unmapped counts. - pub chrom_counts: HashMap, - /// Unmapped reads with no reference (tid < 0). - pub unplaced_unmapped: u64, - - // --- samtools stats SN additional fields --- - /// Sum of query sequence lengths for all primary reads (non-secondary, non-supplementary). - pub total_len: u64, - /// Sum of first fragment (read1 or unpaired) sequence lengths. - pub total_first_fragment_len: u64, - /// Sum of last fragment (read2) sequence lengths. - pub total_last_fragment_len: u64, - /// Sum of query lengths for mapped primary reads. - pub bases_mapped: u64, - /// Sum of M/=/X CIGAR operations for mapped primary reads. - pub bases_mapped_cigar: u64, - /// Sum of query lengths for duplicate-flagged primary reads. - pub bases_duplicated: u64, - /// Maximum query sequence length (among primary reads). - pub max_len: u64, - /// Maximum first-fragment sequence length. - pub max_first_fragment_len: u64, - /// Maximum last-fragment sequence length. - pub max_last_fragment_len: u64, - /// Sum of average per-read base qualities (for average-of-averages). - pub quality_sum: f64, - /// Number of reads contributing to quality_sum (primary, non-QC-fail). - pub quality_count: u64, - /// Sum of NM tag values across mapped primary reads. - pub mismatches: u64, - /// Insert size with orientation: abs_tlen → [total, inward, outward, other]. - /// Only one mate per pair contributes (upstream mate), capped at 8000. - pub is_hist: HashMap, - /// Inward-oriented pairs (FR). - pub inward_pairs: u64, - /// Outward-oriented pairs (RF). - pub outward_pairs: u64, - /// Other orientation pairs (FF, RR). - pub other_orientation: u64, - /// Total primary reads (non-secondary, non-supplementary). - pub primary_count: u64, - /// Primary mapped reads count (non-secondary, non-supplementary, !unmapped). - pub primary_mapped: u64, - /// Primary duplicate reads. - pub primary_duplicates: u64, - /// Primary mapped reads with MAPQ = 0 (matching upstream samtools stats). - pub reads_mq0: u64, - /// Primary non-QC-fail mapped paired reads where mate is also mapped. - pub reads_mapped_and_paired: u64, - - // --- samtools stats histogram/distribution fields --- - /// Read length histogram (all primary reads): length → count. - pub rl_hist: HashMap, - /// First fragment read length histogram: length → count. - pub frl_hist: HashMap, - /// Last fragment read length histogram: length → count. - pub lrl_hist: HashMap, - /// MAPQ histogram: primary, mapped, !qcfail, !dup (quality 0-255). - pub mapq_hist: [u64; 256], - /// Per-cycle quality for first fragments (primary, mapped, !qcfail, !dup). - /// Outer: cycle index. Inner: quality value → count (64 buckets covers Q0-Q63). - pub ffq: Vec<[u64; 64]>, - /// Per-cycle quality for last fragments. - pub lfq: Vec<[u64; 64]>, - /// GC content step-function for first fragments, 200 bins (matching samtools ngc=200). - /// Each bin i stores the number of reads with gc_count * 199 / seq_len <= i. - pub gcf: [u64; 200], - /// GC content step-function for last fragments, 200 bins. - pub gcl: [u64; 200], - /// Per-cycle base composition for first fragments (primary, mapped, !qcfail, !dup). - /// [A, C, G, T, N, Other] per cycle. - pub fbc: Vec<[u64; 6]>, - /// Per-cycle base composition for last fragments. - pub lbc: Vec<[u64; 6]>, - /// Per-cycle base composition (read-oriented) for first fragments. - /// Reverse strand reads contribute in reversed cycle order. - pub fbc_ro: Vec<[u64; 6]>, - /// Per-cycle base composition (read-oriented) for last fragments. - pub lbc_ro: Vec<[u64; 6]>, - /// Per-cycle base composition (reverse-complemented for reverse-strand reads, - /// combined first+last fragments). Used for GCT output. [A, C, G, T] only. - pub gcc_rc: Vec<[u64; 4]>, - /// Total base counters for first fragments: [A, C, G, T, N]. - pub ftc: [u64; 5], - /// Total base counters for last fragments: [A, C, G, T, N]. - pub ltc: [u64; 5], - /// Indel distribution by size: length → [insertions, deletions]. - pub id_hist: HashMap, - /// Indels per cycle: cycle → [ins_fwd, ins_rev, del_fwd, del_rev]. - pub ic: Vec<[u64; 4]>, - /// CRC32 checksum sums: [names, sequences, qualities]. - /// Each is the wrapping u32 sum of per-read CRC32 values. - pub chk: [u32; 3], - /// Coverage distribution: depth → number of reference positions at that depth. - /// Populated from a round buffer pileup during sorted BAM processing. - pub cov_hist: HashMap, - /// Circular buffer for coverage pileup, matching upstream samtools design. - /// `cov_buf[cov_buf_idx]` corresponds to reference position `cov_buf_pos`. - /// The buffer grows dynamically to accommodate `max_read_length * 5`. - cov_buf: Vec, - /// Index into `cov_buf` corresponding to `cov_buf_pos`. - cov_buf_idx: usize, - /// Reference position of the element at `cov_buf[cov_buf_idx]`. - cov_buf_pos: i64, - /// Current chromosome tid for round buffer tracking. - cov_buf_tid: i32, - - // --- GC-depth (GCD section) fields --- - /// Accumulated GC-depth bins (one per `GCD_BIN_SIZE`-bp genomic window). - gcd_bins: Vec, - /// Start position of the current GCD bin. - gcd_pos: i64, - /// Chromosome tid of the current GCD bin. - gcd_tid: i32, -} - -impl Default for BamStatAccum { - fn default() -> Self { - Self { - total_records: 0, - qc_failed: 0, - duplicates: 0, - non_primary: 0, - unmapped: 0, - non_unique: 0, - unique: 0, - read_1: 0, - read_2: 0, - forward: 0, - reverse: 0, - splice: 0, - non_splice: 0, - proper_pairs: 0, - proper_pair_diff_chrom: 0, - secondary: 0, - supplementary: 0, - mapped: 0, - paired_flagstat: 0, - read1_flagstat: 0, - read2_flagstat: 0, - first_fragments: 0, - last_fragments: 0, - properly_paired: 0, - both_mapped: 0, - singletons: 0, - mate_diff_chr: 0, - mate_diff_chr_mapq5: 0, - chrom_counts: HashMap::new(), - unplaced_unmapped: 0, - total_len: 0, - total_first_fragment_len: 0, - total_last_fragment_len: 0, - bases_mapped: 0, - bases_mapped_cigar: 0, - bases_duplicated: 0, - max_len: 0, - max_first_fragment_len: 0, - max_last_fragment_len: 0, - quality_sum: 0.0, - quality_count: 0, - mismatches: 0, - is_hist: HashMap::new(), - inward_pairs: 0, - outward_pairs: 0, - other_orientation: 0, - primary_count: 0, - primary_mapped: 0, - primary_duplicates: 0, - reads_mq0: 0, - reads_mapped_and_paired: 0, - rl_hist: HashMap::new(), - frl_hist: HashMap::new(), - lrl_hist: HashMap::new(), - mapq_hist: [0u64; 256], - ffq: Vec::new(), - lfq: Vec::new(), - gcf: [0u64; 200], - gcl: [0u64; 200], - fbc: Vec::new(), - lbc: Vec::new(), - fbc_ro: Vec::new(), - lbc_ro: Vec::new(), - gcc_rc: Vec::new(), - ftc: [0u64; 5], - ltc: [0u64; 5], - id_hist: HashMap::new(), - ic: Vec::new(), - chk: [0u32; 3], - cov_hist: HashMap::new(), - cov_buf: vec![0u32; 1500], // matches upstream samtools: nbases * 5 = 300 * 5 - cov_buf_idx: 0, - cov_buf_pos: 0, - cov_buf_tid: -1, - gcd_bins: Vec::new(), - gcd_pos: -1, - gcd_tid: -1, - } - } -} - -impl BamStatAccum { - /// Process a single BAM record. Called for EVERY record (before counting filters). - /// - /// Collects counters for: - /// - RSeQC bam_stat (original cascade with early returns) - /// - samtools flagstat (counts all records independently) - /// - samtools idxstats (per-reference mapped/unmapped counts) - /// - samtools stats SN section (sequence lengths, quality, insert size, etc.) - pub fn process_read(&mut self, record: &bam::Record, mapq_cut: u8) { - let flags = record.flags(); - self.total_records += 1; - - let is_secondary = flags & BAM_FSECONDARY != 0; - let is_supplementary = flags & BAM_FSUPPLEMENTARY != 0; - let is_unmapped = flags & BAM_FUNMAP != 0; - let is_paired = flags & BAM_FPAIRED != 0; - let is_dup = flags & BAM_FDUP != 0; - let is_qcfail = flags & BAM_FQCFAIL != 0; - let is_primary = !is_secondary && !is_supplementary; - let is_mapped = !is_unmapped; - let tid = record.tid(); - let mapq = record.mapq(); - - // ================================================================= - // samtools flagstat counters (count ALL records, no early returns) - // ================================================================= - if is_secondary { - self.secondary += 1; - } - if is_supplementary { - self.supplementary += 1; - } - if is_mapped { - self.mapped += 1; - } - // samtools stats: "1st fragments" / "last fragments" count primary reads only - // For paired reads: read2 flag -> last, everything else -> 1st - // For SE reads (no PAIRED flag): all counted as 1st fragments - if is_primary { - if flags & BAM_FREAD2 != 0 { - self.last_fragments += 1; - } else { - self.first_fragments += 1; - } - } - // samtools flagstat: paired-read metrics count PRIMARY reads only - // (secondary/supplementary are excluded from paired/read1/read2/properly-paired counts) - if is_paired && is_primary { - self.paired_flagstat += 1; - if flags & BAM_FREAD1 != 0 { - self.read1_flagstat += 1; - } - if flags & BAM_FREAD2 != 0 { - self.read2_flagstat += 1; - } - if flags & BAM_FPROPER_PAIR != 0 { - self.properly_paired += 1; - } - let mate_unmapped = flags & BAM_FMUNMAP != 0; - if is_mapped && !mate_unmapped { - self.both_mapped += 1; - if tid != record.mtid() { - self.mate_diff_chr += 1; - if mapq >= 5 { - self.mate_diff_chr_mapq5 += 1; - } - } - } - if is_mapped && mate_unmapped { - self.singletons += 1; - } - } - - // ================================================================= - // samtools idxstats counters (per-reference) - // ================================================================= - if is_unmapped { - if tid >= 0 { - // Unmapped read placed on a reference (has tid) - self.chrom_counts.entry(tid).or_insert((0, 0)).1 += 1; - } else { - self.unplaced_unmapped += 1; - } - } else if tid >= 0 { - // Mapped read - self.chrom_counts.entry(tid).or_insert((0, 0)).0 += 1; - } - - // ================================================================= - // CHK checksums: computed on ALL reads (including secondary and - // supplementary). Matches samtools stats.c update_checksum() which - // is called before the secondary-read early return. - // ================================================================= - { - let qname = record.qname(); - let name_crc = crc32fast::hash(qname); - self.chk[0] = self.chk[0].wrapping_add(name_crc); - - let seq_len = record.seq_len(); - if seq_len > 0 { - // SAFETY: We access the raw BAM record data to compute CRC32 - // checksums matching samtools' approach. The pointer arithmetic - // replicates htslib's bam_get_seq() macro: - // data + l_qname + (n_cigar << 2) - // The seq_len > 0 guard above ensures sequence data exists. - // The slice length seq_len.div_ceil(2) matches the BAM spec's - // 4-bit encoded sequence format: (seq_len+1)/2 bytes. - let seq_bytes = unsafe { - let inner = record.inner(); - let data = inner.data; - let seq_offset = - inner.core.l_qname as isize + ((inner.core.n_cigar as isize) << 2); - let seq_nbytes = seq_len.div_ceil(2); - std::slice::from_raw_parts(data.offset(seq_offset), seq_nbytes) - }; - let seq_crc = crc32fast::hash(seq_bytes); - self.chk[1] = self.chk[1].wrapping_add(seq_crc); - - let qual = record.qual(); - let qual_crc = crc32fast::hash(qual); - self.chk[2] = self.chk[2].wrapping_add(qual_crc); - } - } - - // Track gc_count from the primary-read per-cycle loop so the GCD - // section below can reuse it without re-scanning the sequence. - let mut primary_gc_count: u64 = 0; - - // ================================================================= - // samtools stats SN counters (primary reads only) - // ================================================================= - if is_primary { - self.primary_count += 1; - let seq_len = record.seq_len() as u64; - let mate_unmapped = flags & BAM_FMUNMAP != 0; - - self.total_len += seq_len; - let is_last_fragment = is_paired && flags & BAM_FREAD2 != 0; - if is_last_fragment { - self.total_last_fragment_len += seq_len; - if seq_len > self.max_last_fragment_len { - self.max_last_fragment_len = seq_len; - } - } else { - self.total_first_fragment_len += seq_len; - if seq_len > self.max_first_fragment_len { - self.max_first_fragment_len = seq_len; - } - } - if seq_len > self.max_len { - self.max_len = seq_len; - } - - // RL/FRL/LRL: read length histograms (all primary reads) - *self.rl_hist.entry(seq_len).or_insert(0) += 1; - if is_last_fragment { - *self.lrl_hist.entry(seq_len).or_insert(0) += 1; - } else { - *self.frl_hist.entry(seq_len).or_insert(0) += 1; - } - - if is_dup { - self.primary_duplicates += 1; - self.bases_duplicated += seq_len; - } - // "reads mapped and paired" for samtools stats: primary, non-QC-fail, - // mapped, paired, mate also mapped - if is_mapped && is_paired && !is_qcfail && !mate_unmapped { - self.reads_mapped_and_paired += 1; - } - if is_mapped { - self.primary_mapped += 1; - self.bases_mapped += seq_len; - - // samtools stats: reads MQ0 counts primary mapped reads with MAPQ=0 - // (upstream stats.c: MQ0 is counted inside collect_orig_read_stats, - // which is only called for IS_ORIGINAL reads = non-secondary, non-supplementary) - if record.mapq() == 0 { - self.reads_mq0 += 1; - } - - // NOTE: bases_mapped_cigar is now computed in the IC/ID CIGAR - // loop below (for all mapped non-secondary reads) to avoid a - // separate full CIGAR traversal here. - - // NM tag (edit distance) - if let Ok(rust_htslib::bam::record::Aux::U8(nm)) = record.aux(b"NM") { - self.mismatches += u64::from(nm); - } else if let Ok(rust_htslib::bam::record::Aux::U16(nm)) = record.aux(b"NM") { - self.mismatches += u64::from(nm); - } else if let Ok(rust_htslib::bam::record::Aux::U32(nm)) = record.aux(b"NM") { - self.mismatches += u64::from(nm); - } else if let Ok(rust_htslib::bam::record::Aux::I8(nm)) = record.aux(b"NM") { - if nm > 0 { - self.mismatches += nm as u64; - } - } else if let Ok(rust_htslib::bam::record::Aux::I16(nm)) = record.aux(b"NM") { - if nm > 0 { - self.mismatches += nm as u64; - } - } else if let Ok(rust_htslib::bam::record::Aux::I32(nm)) = record.aux(b"NM") { - if nm > 0 { - self.mismatches += nm as u64; - } - } - - // Insert size + orientation for paired primary reads where both - // mates are mapped. Matches samtools stats gate: - // IS_PAIRED_AND_MAPPED && IS_ORIGINAL - // if (isize > 0 || tid == mtid) - // Both mates contribute; samtools divides by 2 at output. - // We do the same in write_insert_size() and the SN section. - if is_paired && !mate_unmapped { - let tid = record.tid(); - let mtid = record.mtid(); - let tlen = record.insert_size(); - let abs_tlen = tlen.unsigned_abs(); - - if abs_tlen > 0 || tid == mtid { - let pos = record.pos(); - let mpos = record.mpos(); - - // Compute orientation (only meaningful for same-chromosome) - let pos_fst = mpos - pos; - let is_fst: i64 = if flags & BAM_FREAD1 != 0 { 1 } else { -1 }; - let is_fwd: i64 = if flags & BAM_FREVERSE != 0 { -1 } else { 1 }; - let is_mfwd: i64 = if flags & BAM_FMREVERSE != 0 { -1 } else { 1 }; - - // orientation_idx: 1=inward, 2=outward, 3=other - let orientation_idx = if is_fwd * is_mfwd > 0 { - self.other_orientation += 1; - 3usize - } else if is_fst * pos_fst > 0 { - if is_fst * is_fwd > 0 { - self.inward_pairs += 1; - 1usize - } else { - self.outward_pairs += 1; - 2usize - } - } else if is_fst * pos_fst < 0 { - if is_fst * is_fwd > 0 { - self.outward_pairs += 1; - 2usize - } else { - self.inward_pairs += 1; - 1usize - } - } else { - self.inward_pairs += 1; - 1usize - }; - - if abs_tlen > 0 { - // Cap at MAX_INSERT_SIZE (8000), matching - // samtools stats which accumulates overflow - // into the cap bucket. - let capped = abs_tlen.min(8000); - let entry = self.is_hist.entry(capped).or_insert([0; 4]); - entry[0] += 1; // total - entry[orientation_idx] += 1; - } - } - } - } - - // Average quality for primary non-QC-fail reads. - // Upstream samtools stats computes per-BASE quality average: - // sum of all individual base qualities / total bases. - // (Not a per-read average of averages.) - if !is_qcfail { - let quals = record.qual(); - if !quals.is_empty() { - let base_qual_sum: f64 = quals.iter().map(|&q| f64::from(q)).sum::(); - self.quality_sum += base_qual_sum; - self.quality_count += quals.len() as u64; - } - } - - // ============================================================= - // MAPQ histogram: primary + mapped + !qcfail + !dup - // (matches samtools stats.c:1239 five-flag exclusion) - // ============================================================= - if is_mapped && !is_qcfail && !is_dup { - self.mapq_hist[mapq as usize] += 1; - } - - // ============================================================= - // Per-cycle quality & base composition histograms: - // FFQ/LFQ, FBC/LBC, GCF/GCL, FTC/LTC, FBC_RO/LBC_RO - // - // Upstream samtools stats includes duplicates, unmapped, and - // qcfail reads in these histograms (collect_orig_read_stats - // has no such checks). Only secondary+supplementary are - // excluded (via IS_ORIGINAL), which is already handled by - // the outer is_primary guard. - // ============================================================= - { - let is_reverse = flags & BAM_FREVERSE != 0; - - let seq = record.seq(); - let quals = record.qual(); - let read_len = seq.len(); - - // Determine which arrays to use (first vs last fragment) - // If paired: read2 = last, read1 = first. If SE: all = first. - let (qual_arr, base_arr, base_ro_arr, gc_arr, tc_arr) = if is_last_fragment { - ( - &mut self.lfq, - &mut self.lbc, - &mut self.lbc_ro, - &mut self.gcl, - &mut self.ltc, - ) - } else { - ( - &mut self.ffq, - &mut self.fbc, - &mut self.fbc_ro, - &mut self.gcf, - &mut self.ftc, - ) - }; - - // Ensure per-cycle arrays are large enough - if read_len > qual_arr.len() { - qual_arr.resize(read_len, [0u64; 64]); - } - if read_len > base_arr.len() { - base_arr.resize(read_len, [0u64; 6]); - } - if read_len > base_ro_arr.len() { - base_ro_arr.resize(read_len, [0u64; 6]); - } - if read_len > self.gcc_rc.len() { - self.gcc_rc.resize(read_len, [0u64; 4]); - } - - let mut gc_count: u64 = 0; - - // Pre-built lookup tables for the per-cycle inner loop, - // avoiding branches and match overhead on every base. - // - // BAM 4-bit encoding: A=1, C=2, G=4, T=8, N=15, others=0,3,5..14 - // BASE_IDX[nibble] → 0=A, 1=C, 2=G, 3=T, 4=N, 5=Other - const BASE_IDX: [u8; 16] = [5, 0, 1, 5, 2, 5, 5, 5, 3, 5, 5, 5, 5, 5, 5, 4]; - // RC_IDX[base_idx] → reverse-complement base_idx (A↔T, C↔G) - // Only meaningful for base_idx 0-3 (ACGT). Index 4/5 not used. - const RC_IDX: [u8; 6] = [3, 2, 1, 0, 4, 5]; // A→T, C→G, G→C, T→A - - // Hoist the is_reverse branch outside the inner loop so the - // compiler can version the loop and potentially auto-vectorize - // each variant independently. - if !is_reverse { - for i in 0..read_len { - let q = quals[i] as usize; - qual_arr[i][q.min(63)] += 1; - - let base_idx = BASE_IDX[seq.encoded_base(i) as usize] as usize; - base_arr[i][base_idx] += 1; - base_ro_arr[i][base_idx] += 1; - if base_idx < 4 { - self.gcc_rc[i][base_idx] += 1; - } - if base_idx == 1 || base_idx == 2 { - gc_count += 1; - } - if base_idx < 5 { - tc_arr[base_idx] += 1; - } - } - } else { - for i in 0..read_len { - let ro_cycle = read_len - 1 - i; - let q = quals[i] as usize; - qual_arr[ro_cycle][q.min(63)] += 1; - - let base_idx = BASE_IDX[seq.encoded_base(i) as usize] as usize; - base_arr[i][base_idx] += 1; - base_ro_arr[ro_cycle][base_idx] += 1; - if base_idx < 4 { - self.gcc_rc[ro_cycle][RC_IDX[base_idx] as usize] += 1; - } - if base_idx == 1 || base_idx == 2 { - gc_count += 1; - } - if base_idx < 5 { - tc_arr[base_idx] += 1; - } - } - } - - // Save gc_count for GCD section below (avoids re-scanning the sequence). - primary_gc_count = gc_count; - - // GC content: cumulative step function with ngc=200 bins. - // Matches samtools stats.c:925-941. For a read with gc_count G/C - // bases out of read_len total, increment bins gc_idx_min..gc_idx_max. - let ngc: usize = 200; - if let (Some(gc_idx_min), Some(gc_idx_max)) = ( - (gc_count as usize * (ngc - 1)).checked_div(read_len), - ((gc_count as usize + 1) * (ngc - 1)).checked_div(read_len), - ) { - let gc_idx_max = gc_idx_max.min(ngc - 1); - for item in gc_arr.iter_mut().take(gc_idx_max).skip(gc_idx_min) { - *item += 1; - } - } - } - } // if is_primary - - // ============================================================= - // Indel distribution (ID) and indels per cycle (IC) from CIGAR. - // - // Upstream samtools stats calls count_indels() AFTER the - // secondary-read early return (line 1206-1210) and the - // IS_UNMAPPED return (line 1255), but OUTSIDE IS_ORIGINAL(). - // This means: all mapped, non-secondary reads are included - // (supplementary, duplicate, qcfail all contribute). - // - // IC uses first-fragment/last-fragment read order (not - // forward/reverse strand) and read-oriented cycle indices, - // matching upstream count_indels(). - // ============================================================= - // ============================================================= - // Combined single-CIGAR-pass block for IC/ID (indel distribution), - // bases_mapped_cigar, and COV (coverage ring-buffer pileup). - // - // Both IC/ID and COV apply to the same read set (mapped, - // non-secondary). Merging them into one CIGAR traversal - // eliminates two redundant record.cigar() calls per read. - // - // IC/ID: Upstream samtools stats calls count_indels() outside - // IS_ORIGINAL() — supplementary/dup/qcfail all contribute. - // IC uses first/last-fragment order and read-oriented cycles. - // - // COV: Circular-buffer pileup; buffer flushed up to read start - // before CIGAR walk; M/=/X blocks inserted as ranges. - // Buffer grown to max_read_len * 5 as needed. - // ============================================================= - if is_mapped && !is_secondary { - use rust_htslib::bam::record::Cigar as C; - let is_reverse = flags & BAM_FREVERSE != 0; - let read_len = record.seq_len(); - let tid = record.tid(); - let pos = record.pos(); // 0-based - - // Upstream order: paired ? (read1?FIRST:0)+(read2?LAST:0) : FIRST - let order: u32 = if is_paired { - (if flags & BAM_FREAD1 != 0 { 1 } else { 0 }) - + (if flags & BAM_FREAD2 != 0 { 2 } else { 0 }) - } else { - 1 // unpaired → FIRST - }; - - // COV buffer setup (must happen before CIGAR walk). - // Skip reads with no sequence (upstream samtools early-return). - let do_cov = read_len > 0; - let buf_size = if do_cov { - // Grow buffer to max_read_len * 5 if needed. - // When growing, linearise the circular data just like - // upstream samtools: copy [idx..old_size] then [0..idx] - // into a fresh buffer, and reset idx to 0. - let need = read_len * 5; - if need > self.cov_buf.len() { - let old_size = self.cov_buf.len(); - let mut new_buf = vec![0u32; need]; - let head = old_size - self.cov_buf_idx; - new_buf[..head].copy_from_slice(&self.cov_buf[self.cov_buf_idx..]); - new_buf[head..head + self.cov_buf_idx] - .copy_from_slice(&self.cov_buf[..self.cov_buf_idx]); - self.cov_buf = new_buf; - self.cov_buf_idx = 0; - } - let bs = self.cov_buf.len(); - // Flush entire buffer on chromosome change - if tid != self.cov_buf_tid { - self.flush_cov_buf_all(); - self.cov_buf_tid = tid; - self.cov_buf_pos = pos; - self.cov_buf_idx = 0; - } - // Flush positions from cov_buf_pos up to read start - self.cov_buf_flush_to(pos, bs); - bs - } else { - 0 - }; - - // Single CIGAR traversal serving IC/ID + bases_mapped_cigar + COV - let cigar = record.cigar(); - let mut icycle: usize = 0; - let mut cigar_mapped: u64 = 0; - let mut ref_pos = pos; - - for op in cigar.iter() { - match op { - C::Ins(n) => { - let ncig = *n as usize; - let len = *n as u64; - cigar_mapped += len; // I counts toward bases_mapped_cigar - - // ID: indel size distribution - let id_entry = self.id_hist.entry(len).or_insert([0; 2]); - id_entry[0] += 1; // insertions - - // IC: indels per cycle (read-oriented index) - let idx = if is_reverse { - read_len.saturating_sub(icycle + ncig) - } else { - icycle - }; - if idx >= self.ic.len() { - self.ic.resize(idx + 1, [0u64; 4]); - } - if order == 1 { - self.ic[idx][0] += 1; // ins_1st - } - if order == 2 { - self.ic[idx][1] += 1; // ins_2nd - } - - icycle += ncig; // I advances query cycle; ref unchanged - // COV: I consumes no reference positions - } - C::Del(n) => { - let len = *n as u64; - // ID: indel size distribution - let id_entry = self.id_hist.entry(len).or_insert([0; 2]); - id_entry[1] += 1; // deletions - - // IC: indels per cycle (read-oriented index) - let idx = if is_reverse { - if icycle == 0 { - // Discard meaningless deletions at cycle 0 - // (upstream: "if (idx<0) continue;") - ref_pos += *n as i64; // still advance ref for COV - continue; - } - read_len.saturating_sub(icycle + 1) - } else { - if icycle == 0 { - ref_pos += *n as i64; - continue; - } - icycle - 1 - }; - if idx >= self.ic.len() { - self.ic.resize(idx + 1, [0u64; 4]); - } - if order == 1 { - self.ic[idx][2] += 1; // del_1st - } - if order == 2 { - self.ic[idx][3] += 1; // del_2nd - } - // D does NOT advance query cycle; does advance ref - ref_pos += *n as i64; - } - C::Match(n) | C::Equal(n) | C::Diff(n) => { - let len = *n as u64; - cigar_mapped += len; // M/=/X count toward bases_mapped_cigar - icycle += *n as usize; - // COV: M/=/X consumes reference positions - if do_cov { - let end = ref_pos + *n as i64; - self.cov_buf_insert(ref_pos, end, buf_size); - ref_pos = end; - } else { - ref_pos += *n as i64; - } - } - C::RefSkip(n) => { - ref_pos += *n as i64; // N advances ref (COV skips it) - } - C::SoftClip(n) => { - icycle += *n as usize; // S advances query cycle - // COV: S consumes no reference positions - } - C::HardClip(_) | C::Pad(_) => {} - } - } - self.bases_mapped_cigar += cigar_mapped; - } // if is_mapped && !is_secondary (IC/ID + COV combined) - - // ============================================================= - // GCD: GC-depth accumulation (no-reference path). - // - // Matches upstream samtools stats without --ref-seq: bins of - // GCD_BIN_SIZE bp, depth incremented for each read, GC fraction - // accumulated from the read's sequence. - // - // Included reads: mapped, non-secondary (same as COV). - // - // NOTE: gc_count_for_gcd is set from the primary-read per-cycle - // loop above (when is_primary is true), or computed here only for - // non-primary mapped reads, avoiding a redundant full sequence scan. - // ============================================================= - if is_mapped && !is_secondary { - let tid = record.tid(); - let pos = record.pos(); - let seq_len = record.seq_len(); - - if seq_len > 0 { - // Start a new bin on: first read, chromosome change, or - // read beyond current bin boundary. - let new_bin = self.gcd_pos < 0 - || tid != self.gcd_tid - || pos - self.gcd_pos > GCD_BIN_SIZE as i64; - - if new_bin { - self.gcd_bins.push(GcDepthBin { gc: 0.0, depth: 0 }); - self.gcd_pos = pos; - self.gcd_tid = tid; - } - - // Increment depth and accumulate GC fraction from read seq. - if let Some(bin) = self.gcd_bins.last_mut() { - bin.depth += 1; - // For primary reads, gc_count was already computed in the - // per-cycle base loop above. For non-primary mapped reads - // (supplementary, etc.) compute it here from the sequence. - let gc_count: u32 = if is_primary { - primary_gc_count as u32 - } else { - let seq = record.seq(); - let mut count: u32 = 0; - for i in 0..seq_len { - let base = seq.encoded_base(i); - if base == 2 || base == 4 { - count += 1; - } - } - count - }; - bin.gc += gc_count as f32 / seq_len as f32; - } - } - } // if is_mapped && !is_secondary (GCD) - - // ================================================================= - // RSeQC bam_stat cascade (original logic, with early returns) - // ================================================================= - - // 1. QC-failed - if is_qcfail { - self.qc_failed += 1; - return; - } - - // 2. Duplicate - if is_dup { - self.duplicates += 1; - return; - } - - // 3. Secondary (non-primary) — NOT supplementary - if is_secondary { - self.non_primary += 1; - return; - } - - // 4. Unmapped - if is_unmapped { - self.unmapped += 1; - return; - } - - // 5. MAPQ classification - if mapq < mapq_cut { - self.non_unique += 1; - return; - } - - // Uniquely mapped - self.unique += 1; - - if flags & BAM_FREAD1 != 0 { - self.read_1 += 1; - } - if flags & BAM_FREAD2 != 0 { - self.read_2 += 1; - } - if flags & BAM_FREVERSE != 0 { - self.reverse += 1; - } else { - self.forward += 1; - } - - // Splice detection: CIGAR N operation - let has_splice = record - .cigar() - .iter() - .any(|op| matches!(op, rust_htslib::bam::record::Cigar::RefSkip(_))); - if has_splice { - self.splice += 1; - } else { - self.non_splice += 1; - } - - // Proper pair analysis - if is_paired && flags & BAM_FPROPER_PAIR != 0 { - self.proper_pairs += 1; - if tid != record.mtid() { - self.proper_pair_diff_chrom += 1; - } - } - } - - /// Flush all remaining positions in the coverage round buffer into cov_hist. - /// Must be called after processing all reads (or when switching chromosomes). - /// Flush the circular buffer from `cov_buf_pos` up to (but not including) `pos`. - /// Each slot's depth is recorded in `cov_hist` and the slot is zeroed. - /// Matches upstream `round_buffer_flush` logic from samtools stats.c. - fn cov_buf_flush_to(&mut self, pos: i64, buf_size: usize) { - if pos - self.cov_buf_pos >= buf_size as i64 { - // Gap exceeds buffer size. Match upstream samtools exactly: - // flush `size - 1` positions (from cov_buf_pos to - // cov_buf_pos + size - 2), leaving the LAST slot untouched. - // Then advance idx by `size - 1` and jump pos. - // - // Upstream (stats.c round_buffer_flush lines 334-366): - // pos = rbuf.pos + size - 1; // cap at last slot - // ito = lidx2ridx(start, size, rbuf.pos, pos-1); - // // flush from start to ito (size-1 slots) - // rbuf.start = lidx2ridx(start, size, rbuf.pos, pos); - // rbuf.pos = new_pos; - let flush_count = buf_size - 1; // flush all but the last slot - for _ in 0..flush_count { - let depth = self.cov_buf[self.cov_buf_idx]; - if depth > 0 { - *self.cov_hist.entry(depth).or_insert(0) += 1; - self.cov_buf[self.cov_buf_idx] = 0; - } - self.cov_buf_idx += 1; - if self.cov_buf_idx >= buf_size { - self.cov_buf_idx = 0; - } - } - // idx now points to the ONE unflushed slot (the last position - // in the old window). Jump pos to the new read position. - self.cov_buf_pos = pos; - } else { - // Normal case: flush slot by slot. - while self.cov_buf_pos < pos { - let depth = self.cov_buf[self.cov_buf_idx]; - if depth > 0 { - *self.cov_hist.entry(depth).or_insert(0) += 1; - self.cov_buf[self.cov_buf_idx] = 0; - } - self.cov_buf_idx += 1; - if self.cov_buf_idx >= buf_size { - self.cov_buf_idx = 0; - } - self.cov_buf_pos += 1; - } - } - } - - /// Insert a contiguous reference range `[from, to)` into the circular buffer, - /// incrementing depth for each position. The range must fit within `buf_size`. - fn cov_buf_insert(&mut self, from: i64, to: i64, buf_size: usize) { - for ref_pos in from..to { - // Map ref_pos to buffer index: offset from cov_buf_idx by (ref_pos - cov_buf_pos) - let offset = (ref_pos - self.cov_buf_pos) as usize; - let idx = (self.cov_buf_idx + offset) % buf_size; - self.cov_buf[idx] += 1; - } - } - - /// Flush the entire circular buffer and reset tracking state. - pub fn flush_cov_buf_all(&mut self) { - for slot in self.cov_buf.iter_mut() { - if *slot > 0 { - *self.cov_hist.entry(*slot).or_insert(0) += 1; - *slot = 0; - } - } - self.cov_buf_idx = 0; - self.cov_buf_pos = 0; - self.cov_buf_tid = -1; - } - - /// Merge another accumulator into this one. - pub fn merge(&mut self, mut other: BamStatAccum) { - // Flush any remaining positions in the other's round buffer into its - // cov_hist before merging. Without this, positions still in the - // round buffer would be silently lost during parallel merges. - other.flush_cov_buf_all(); - - // RSeQC bam_stat fields - self.total_records += other.total_records; - self.qc_failed += other.qc_failed; - self.duplicates += other.duplicates; - self.non_primary += other.non_primary; - self.unmapped += other.unmapped; - self.non_unique += other.non_unique; - self.unique += other.unique; - self.read_1 += other.read_1; - self.read_2 += other.read_2; - self.forward += other.forward; - self.reverse += other.reverse; - self.splice += other.splice; - self.non_splice += other.non_splice; - self.proper_pairs += other.proper_pairs; - self.proper_pair_diff_chrom += other.proper_pair_diff_chrom; - - // samtools flagstat fields - self.secondary += other.secondary; - self.supplementary += other.supplementary; - self.mapped += other.mapped; - self.paired_flagstat += other.paired_flagstat; - self.read1_flagstat += other.read1_flagstat; - self.read2_flagstat += other.read2_flagstat; - self.first_fragments += other.first_fragments; - self.last_fragments += other.last_fragments; - self.properly_paired += other.properly_paired; - self.both_mapped += other.both_mapped; - self.singletons += other.singletons; - self.mate_diff_chr += other.mate_diff_chr; - self.mate_diff_chr_mapq5 += other.mate_diff_chr_mapq5; - - // samtools idxstats fields - for (tid, (m, u)) in other.chrom_counts { - let entry = self.chrom_counts.entry(tid).or_insert((0, 0)); - entry.0 += m; - entry.1 += u; - } - self.unplaced_unmapped += other.unplaced_unmapped; - - // samtools stats SN fields - self.total_len += other.total_len; - self.total_first_fragment_len += other.total_first_fragment_len; - self.total_last_fragment_len += other.total_last_fragment_len; - self.bases_mapped += other.bases_mapped; - self.bases_mapped_cigar += other.bases_mapped_cigar; - self.bases_duplicated += other.bases_duplicated; - if other.max_len > self.max_len { - self.max_len = other.max_len; - } - if other.max_first_fragment_len > self.max_first_fragment_len { - self.max_first_fragment_len = other.max_first_fragment_len; - } - if other.max_last_fragment_len > self.max_last_fragment_len { - self.max_last_fragment_len = other.max_last_fragment_len; - } - self.quality_sum += other.quality_sum; - self.quality_count += other.quality_count; - self.mismatches += other.mismatches; - for (isize_val, counts) in other.is_hist { - let entry = self.is_hist.entry(isize_val).or_insert([0; 4]); - for i in 0..4 { - entry[i] += counts[i]; - } - } - self.inward_pairs += other.inward_pairs; - self.outward_pairs += other.outward_pairs; - self.other_orientation += other.other_orientation; - self.primary_count += other.primary_count; - self.primary_mapped += other.primary_mapped; - self.primary_duplicates += other.primary_duplicates; - self.reads_mq0 += other.reads_mq0; - self.reads_mapped_and_paired += other.reads_mapped_and_paired; - - // Histogram/distribution fields - for (len, count) in other.rl_hist { - *self.rl_hist.entry(len).or_insert(0) += count; - } - for (len, count) in other.frl_hist { - *self.frl_hist.entry(len).or_insert(0) += count; - } - for (len, count) in other.lrl_hist { - *self.lrl_hist.entry(len).or_insert(0) += count; - } - for i in 0..256 { - self.mapq_hist[i] += other.mapq_hist[i]; - } - - // Per-cycle quality arrays (FFQ/LFQ) - merge_vec_arrays(&mut self.ffq, other.ffq); - merge_vec_arrays(&mut self.lfq, other.lfq); - - // GC content distributions (200 bins) - for i in 0..200 { - self.gcf[i] += other.gcf[i]; - self.gcl[i] += other.gcl[i]; - } - - // Per-cycle base composition (FBC/LBC and read-oriented) - merge_vec_arrays(&mut self.fbc, other.fbc); - merge_vec_arrays(&mut self.lbc, other.lbc); - merge_vec_arrays(&mut self.fbc_ro, other.fbc_ro); - merge_vec_arrays(&mut self.lbc_ro, other.lbc_ro); - merge_vec_arrays(&mut self.gcc_rc, other.gcc_rc); - - // Total base counters - for i in 0..5 { - self.ftc[i] += other.ftc[i]; - self.ltc[i] += other.ltc[i]; - } - - // Indel distribution - for (len, counts) in other.id_hist { - let entry = self.id_hist.entry(len).or_insert([0; 2]); - entry[0] += counts[0]; - entry[1] += counts[1]; - } - - // Indels per cycle - merge_vec_arrays(&mut self.ic, other.ic); - - // CHK checksums (wrapping u32 addition) - for i in 0..3 { - self.chk[i] = self.chk[i].wrapping_add(other.chk[i]); - } - - // COV histogram (additive merge) - for (depth, count) in other.cov_hist { - *self.cov_hist.entry(depth).or_insert(0) += count; - } - - // GCD bins (concatenate — bins from different chromosome workers - // are independent and will be sorted during output). - self.gcd_bins.append(&mut other.gcd_bins); - } -} - // ------------------------------------------------------------------- // infer_experiment accumulator // ------------------------------------------------------------------- @@ -2327,112 +1132,10 @@ fn point_in(region_map: &HashMap, chrom: &str, point: u6 region_map.get(chrom).is_some_and(|ci| ci.contains(point)) } -// =================================================================== -// Merge helpers for Vec<[u64; N]> per-cycle arrays -// =================================================================== - -/// Merge two `Vec<[u64; N]>` arrays element-wise, extending target if shorter. -fn merge_vec_arrays(target: &mut Vec<[u64; N]>, source: Vec<[u64; N]>) { - if source.len() > target.len() { - target.resize(source.len(), [0u64; N]); - } - for (i, arr) in source.into_iter().enumerate() { - for j in 0..N { - target[i][j] += arr[j]; - } - } -} - // =================================================================== // Converter methods: accumulator → result types for output functions // =================================================================== -impl BamStatAccum { - /// Convert accumulated counters into a `BamStatResult` for output. - pub fn into_result(mut self) -> BamStatResult { - // Flush remaining positions in the coverage round buffer - self.flush_cov_buf_all(); - BamStatResult { - // RSeQC bam_stat fields - total_records: self.total_records, - qc_failed: self.qc_failed, - duplicates: self.duplicates, - non_primary: self.non_primary, - unmapped: self.unmapped, - non_unique: self.non_unique, - unique: self.unique, - read_1: self.read_1, - read_2: self.read_2, - forward: self.forward, - reverse: self.reverse, - splice: self.splice, - non_splice: self.non_splice, - proper_pairs: self.proper_pairs, - proper_pair_diff_chrom: self.proper_pair_diff_chrom, - // samtools flagstat fields - secondary: self.secondary, - supplementary: self.supplementary, - mapped: self.mapped, - paired_flagstat: self.paired_flagstat, - read1_flagstat: self.read1_flagstat, - read2_flagstat: self.read2_flagstat, - first_fragments: self.first_fragments, - last_fragments: self.last_fragments, - properly_paired: self.properly_paired, - both_mapped: self.both_mapped, - singletons: self.singletons, - mate_diff_chr: self.mate_diff_chr, - mate_diff_chr_mapq5: self.mate_diff_chr_mapq5, - // samtools idxstats fields - chrom_counts: self.chrom_counts, - unplaced_unmapped: self.unplaced_unmapped, - // samtools stats SN fields - total_len: self.total_len, - total_first_fragment_len: self.total_first_fragment_len, - total_last_fragment_len: self.total_last_fragment_len, - bases_mapped: self.bases_mapped, - bases_mapped_cigar: self.bases_mapped_cigar, - bases_duplicated: self.bases_duplicated, - max_len: self.max_len, - max_first_fragment_len: self.max_first_fragment_len, - max_last_fragment_len: self.max_last_fragment_len, - quality_sum: self.quality_sum, - quality_count: self.quality_count, - mismatches: self.mismatches, - is_hist: self.is_hist, - inward_pairs: self.inward_pairs, - outward_pairs: self.outward_pairs, - other_orientation: self.other_orientation, - primary_count: self.primary_count, - primary_mapped: self.primary_mapped, - primary_duplicates: self.primary_duplicates, - reads_mq0: self.reads_mq0, - reads_mapped_and_paired: self.reads_mapped_and_paired, - // Histogram/distribution fields - rl_hist: self.rl_hist, - frl_hist: self.frl_hist, - lrl_hist: self.lrl_hist, - mapq_hist: self.mapq_hist, - ffq: self.ffq, - lfq: self.lfq, - gcf: self.gcf, - gcl: self.gcl, - fbc: self.fbc, - lbc: self.lbc, - fbc_ro: self.fbc_ro, - lbc_ro: self.lbc_ro, - gcc_rc: self.gcc_rc, - ftc: self.ftc, - ltc: self.ltc, - id_hist: self.id_hist, - ic: self.ic, - chk: self.chk, - cov_hist: self.cov_hist, - gcd_bins: self.gcd_bins, - } - } -} - impl InferExpAccum { /// Convert accumulated strand counts into an `InferExperimentResult`. pub fn into_result(self) -> InferExperimentResult { From c3f8c6423d5ada6c8c700f9c5a760719f73523b5 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:22:14 +0200 Subject: [PATCH 04/22] docs: describe the src/common module split Also corrects the AGENTS.md claim that the crate has no lib.rs, which has been untrue since #101. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 31 ++++++++++++++++++++----------- CHANGELOG.md | 9 +++++++++ src/lib.rs | 7 +++++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 108414ac..07dfe143 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,10 +62,21 @@ src/ config.rs — YAML configuration loading (serde), nested tool configs io.rs — Shared I/O utilities (gzip-transparent file reading) gtf.rs — GTF annotation file parser (with configurable attribute extraction) + common/ + mod.rs — Re-exports the shared modules + bam_flags.rs — BAM flag constants and aux-tag helpers + bam_stat.rs — bam_stat.py reimplementation, result types + bam_stat_accum.rs — Read-level counter accumulator feeding bam_stat and samtools + cpp_rng.rs — C++ RNG FFI shim for preseq bootstrap reproducibility + preseq.rs — preseq lc_extrap library complexity extrapolation + samtools/ + mod.rs — Re-exports the samtools writers + stats.rs — samtools stats full output (SN + all histogram sections) + flagstat.rs — samtools flagstat-compatible output + idxstats.rs — samtools idxstats-compatible output rna/ - mod.rs — Re-exports all submodules (dupradar, featurecounts, rseqc, bam_flags, cpp_rng, preseq, qualimap) - bam_flags.rs — BAM flag constants - cpp_rng.rs — C++ RNG FFI shim for preseq bootstrap reproducibility + mod.rs — Re-exports the RNA submodules (dupradar, featurecounts, rseqc, qualimap) + and re-exports the shared ones from `common` for compatibility dupradar/ mod.rs — Re-exports counting, dupmatrix, fitting, plots counting.rs — BAM read counting engine (largest module) @@ -75,7 +86,6 @@ src/ featurecounts/ mod.rs — Re-exports output output.rs — featureCounts-format output & biotype counting - preseq.rs — preseq lc_extrap library complexity extrapolation qualimap/ mod.rs — Re-exports all Qualimap modules accumulator.rs — Gene body coverage accumulation logic @@ -88,9 +98,6 @@ src/ mod.rs — Re-exports all RSeQC modules + common helpers accumulators.rs — Shared RSeQC accumulator infrastructure (read dispatch) common.rs — Shared junction/intron extraction, from_genes builders - bam_stat.rs — bam_stat.py reimplementation - flagstat.rs — samtools flagstat-compatible output - idxstats.rs — samtools idxstats-compatible output infer_experiment.rs — infer_experiment.py reimplementation inner_distance.rs — inner_distance.py reimplementation junction_annotation.rs — junction_annotation.py reimplementation @@ -98,7 +105,6 @@ src/ plots.rs — RSeQC plot generation (duplication, junctions, etc.) read_distribution.rs — read_distribution.py reimplementation read_duplication.rs — read_duplication.py reimplementation - stats.rs — samtools stats full output (SN + all histogram sections) tin.rs — TIN (Transcript Integrity Number) analysis tests/ integration_test.rs — 12 integration tests vs R dupRadar reference output @@ -107,9 +113,12 @@ tests/ create_test_data.R — R script to regenerate test data + references ``` -Nested module structure — top-level modules (`cli`, `config`, `io`, `gtf`, `rna`) declared -in `main.rs`, no `lib.rs`. The `rna` module contains sub-modules for each tool group. -Inter-module access uses `crate::` paths (e.g., `use crate::rna::dupradar::counting::GeneCounts;`). +Nested module structure. The library crate root is `src/lib.rs`, which declares +`common`, `config`, `cpu`, `gtf`, `io`, `rna` and `summary`; the binary +(`src/main.rs`) additionally declares `cli`, `citations` and `ui`. +Inter-module access uses `crate::` paths (e.g., `use crate::common::bam_stat_accum::BamStatAccum;`). +Assay-agnostic analyses belong in `common`; put new code under `rna` only if it +needs a gene annotation or a library strand protocol. The CLI uses a single subcommand: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b67cce4..469316e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # RustQC Changelog +## Unreleased + +### Changed + +- Internal: assay-agnostic analyses (BAM flag helpers, read-level statistics, + the samtools stats/flagstat/idxstats writers, preseq) moved from `rna` to a + new `common` module. The old `rustqc::rna::...` paths still resolve through + re-exports, so this is not a breaking change for library users. + ## [Version 0.2.1](https://github.com/seqeralabs/RustQC/releases/tag/v0.2.1) - 2026-04-09 ### Bug fixes diff --git a/src/lib.rs b/src/lib.rs index bcb20cbe..e823028f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,9 +23,12 @@ //! - [`config`] — configuration types that mirror the CLI's YAML config file. //! - [`summary`] — serializable types for the JSON run summary. //! - [`cpu`] — CPU feature detection and binary-target identification. +//! - [`common`] — analyses shared by every pipeline: BAM flag helpers, +//! read-level statistics ([`common::bam_stat`], [`common::bam_stat_accum`]), +//! the samtools-compatible writers ([`common::samtools`]), and preseq +//! library complexity extrapolation ([`common::preseq`]). //! - [`rna`] — the RNA-Seq analysis modules: -//! - [`rna::dupradar`], [`rna::featurecounts`], [`rna::qualimap`], -//! [`rna::preseq`], [`rna::rseqc`]. +//! - [`rna::dupradar`], [`rna::featurecounts`], [`rna::qualimap`], [`rna::rseqc`]. //! //! [`Strandedness`] lives at the crate root because it is used across most //! analysis modules. From 701ea6d6c1244f27b42fcd3c45654235fddb641b Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:25:48 +0200 Subject: [PATCH 05/22] test: guard the rna re-export shims against silent breakage Co-Authored-By: Claude Opus 5 (1M context) --- src/rna/rseqc/mod.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/rna/rseqc/mod.rs b/src/rna/rseqc/mod.rs index 3ac2ea74..6f38ecca 100644 --- a/src/rna/rseqc/mod.rs +++ b/src/rna/rseqc/mod.rs @@ -21,3 +21,20 @@ pub mod tin; // keep resolving. Drop the shims at 1.0. pub use crate::common::bam_stat; pub use crate::common::samtools::{flagstat, idxstats, stats}; + +#[cfg(test)] +mod compat_tests { + //! Guards the re-export shims that keep the published 0.2.x paths alive. + //! These are compile-time assertions; there is nothing to observe at runtime. + + #[test] + fn moved_modules_are_still_reachable_from_their_old_paths() { + let _: fn( + &crate::rna::rseqc::bam_stat::BamStatResult, + &std::path::Path, + ) -> anyhow::Result<()> = crate::rna::rseqc::flagstat::write_flagstat; + let _ = crate::rna::rseqc::accumulators::BamStatAccum::default(); + let _: u16 = crate::rna::bam_flags::BAM_FDUP; + let _: Option<&crate::rna::preseq::PreseqAccum> = None; + } +} From 8a9ab64192ec74e2431ac2c19a775b328744ffef Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:36:58 +0200 Subject: [PATCH 06/22] test: add the DNA test dataset and its reference outputs A real public human chr22 slice from nf-core/test-datasets, duplicate-marked locally with samtools, plus mosdepth 0.3.14 and samtools 1.24 reference outputs. The generation script pins both tool versions and refuses to run against others, so fixtures and tool versions cannot drift apart. 380 kB in total, well inside the 10 MB fixture budget. Co-Authored-By: Claude Opus 5 (1M context) --- tests/create_dna_test_data.sh | 59 + tests/data/dna/genome.fasta | 668 ++++++ tests/data/dna/genome.fasta.fai | 1 + tests/data/dna/test.dna.bam | Bin 0 -> 193636 bytes tests/data/dna/test.dna.bam.bai | Bin 0 -> 96 bytes tests/expected/dna/VERSIONS.txt | 2 + tests/expected/dna/test.flagstat.txt | 16 + tests/expected/dna/test.idxstats.txt | 2 + .../dna/test.mosdepth.global.dist.txt | 1094 ++++++++++ .../dna/test.mosdepth.region.dist.txt | 410 ++++ tests/expected/dna/test.mosdepth.summary.txt | 5 + tests/expected/dna/test.per-base.bed.gz | Bin 0 -> 4428 bytes tests/expected/dna/test.per-base.bed.gz.csi | Bin 0 -> 109 bytes tests/expected/dna/test.regions.bed.gz | Bin 0 -> 402 bytes tests/expected/dna/test.regions.bed.gz.csi | Bin 0 -> 107 bytes tests/expected/dna/test.stats.txt | 1916 +++++++++++++++++ tests/expected/dna/test.thresholds.bed.gz | Bin 0 -> 578 bytes tests/expected/dna/test.thresholds.bed.gz.csi | Bin 0 -> 108 bytes 18 files changed, 4173 insertions(+) create mode 100755 tests/create_dna_test_data.sh create mode 100644 tests/data/dna/genome.fasta create mode 100644 tests/data/dna/genome.fasta.fai create mode 100644 tests/data/dna/test.dna.bam create mode 100644 tests/data/dna/test.dna.bam.bai create mode 100644 tests/expected/dna/VERSIONS.txt create mode 100644 tests/expected/dna/test.flagstat.txt create mode 100644 tests/expected/dna/test.idxstats.txt create mode 100644 tests/expected/dna/test.mosdepth.global.dist.txt create mode 100644 tests/expected/dna/test.mosdepth.region.dist.txt create mode 100644 tests/expected/dna/test.mosdepth.summary.txt create mode 100644 tests/expected/dna/test.per-base.bed.gz create mode 100644 tests/expected/dna/test.per-base.bed.gz.csi create mode 100644 tests/expected/dna/test.regions.bed.gz create mode 100644 tests/expected/dna/test.regions.bed.gz.csi create mode 100644 tests/expected/dna/test.stats.txt create mode 100644 tests/expected/dna/test.thresholds.bed.gz create mode 100644 tests/expected/dna/test.thresholds.bed.gz.csi diff --git a/tests/create_dna_test_data.sh b/tests/create_dna_test_data.sh new file mode 100755 index 00000000..22289b74 --- /dev/null +++ b/tests/create_dna_test_data.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Regenerate the DNA test inputs and the reference outputs they are compared against. +# +# Inputs come from nf-core/test-datasets (a real human chr22 slice, 40 kb). +# The upstream BAM is not duplicate-marked, so this script marks duplicates +# with samtools; RustQC requires duplicate-marked input. +# +# The reference outputs are produced by the pinned tool versions recorded in +# tests/expected/dna/VERSIONS.txt. Regenerating with a different version will +# make the parity tests fail, which is the intended behaviour: fixtures and +# tool versions travel together. +set -euo pipefail + +MOSDEPTH_VERSION="0.3.14" +SAMTOOLS_VERSION="1.24" + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +data="$here/data/dna" +expected="$here/expected/dna" +base="https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/homo_sapiens" + +have() { command -v "$1" >/dev/null || { echo "missing tool: $1" >&2; exit 1; }; } +have samtools; have mosdepth; have curl + +check_version() { + local tool="$1" want="$2" got + got="$($tool --version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" + if [[ "$got" != "$want" ]]; then + echo "$tool version $got does not match the pinned $want" >&2 + echo "Install the pinned version, or update VERSIONS.txt and the fixtures together." >&2 + exit 1 + fi +} +check_version samtools "$SAMTOOLS_VERSION" +check_version mosdepth "$MOSDEPTH_VERSION" + +mkdir -p "$data" "$expected" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +curl -sSfL -o "$tmp/upstream.bam" "$base/illumina/bam/test.paired_end.sorted.bam" +curl -sSfL -o "$data/genome.fasta" "$base/genome/genome.fasta" +curl -sSfL -o "$data/genome.fasta.fai" "$base/genome/genome.fasta.fai" + +# Mark duplicates: name-sort, add mate tags, coordinate-sort, then markdup. +samtools sort -n -o "$tmp/ns.bam" "$tmp/upstream.bam" +samtools fixmate -m "$tmp/ns.bam" "$tmp/fm.bam" +samtools sort -o "$tmp/cs.bam" "$tmp/fm.bam" +samtools markdup -S "$tmp/cs.bam" "$data/test.dna.bam" +samtools index "$data/test.dna.bam" + +mosdepth --by 500 --thresholds 1,5,10,15,20,30,50 "$expected/test" "$data/test.dna.bam" +samtools stats "$data/test.dna.bam" > "$expected/test.stats.txt" +samtools flagstat "$data/test.dna.bam" > "$expected/test.flagstat.txt" +samtools idxstats "$data/test.dna.bam" > "$expected/test.idxstats.txt" + +printf 'mosdepth\t%s\nsamtools\t%s\n' "$MOSDEPTH_VERSION" "$SAMTOOLS_VERSION" > "$expected/VERSIONS.txt" + +echo "Regenerated $(find "$data" "$expected" -type f | wc -l | tr -d ' ') files." diff --git a/tests/data/dna/genome.fasta b/tests/data/dna/genome.fasta new file mode 100644 index 00000000..b0ea69be --- /dev/null +++ b/tests/data/dna/genome.fasta @@ -0,0 +1,668 @@ +>chr22 +ACTCAAGATAATGATGAGTAAAGAATATATTTCTAACAACAAAAAGGAAATTTGATAGTA +TTTCTAAAGACAAAAAGGAAATTTGTATTCACATTCAGTTAGTCATTCCACCAGAATGAC +TTCATCACACAATATTTTGTGACAAGAACCTGAACAGCCTCATGTTTTACAATATTCTTT +TCATCTTTTATTATATGCACCAAAATTTTCTTTTTTAAATTTTCTTGAACCTCTAAATCT +ACTTTAAAAATTTACCTGATACACTTTTTAAATGGACAAATGCTGAAGGTAGCTGTGTAT +ACAAATGTGACTAGAAGGAAAAAGATGATGTAGAAATACAATAACTCCTTGAGTTGATCA +TTCTGATTGGCATTTATAGAGTAGAAATGTTTTGTAATTACAGAGGAAAAAAGATGGCCT +TTCCTTCAACAGTTATGAGCCGTCAGAATTTTCAAAAATATTGCATTTTGACAATGTAGT +TTCTAGTTTGACAATGATATATTTATCTTCAAAACCAGGAAAATGTAGATAAGGATTTGG +TTTTATAATATTTAAATTCTTATTAAAATGTATAATAAAATTGTTTTCCCCATCACTTTA +TTCTTCTGTAAGTTATTTTACGTTTAAAATGTAAACAAATAAAAATAAGTAAATAAACAG +TAGCAGCTTCTTTTCCTGGTGAATCGAGGATTGAGTATGTATTATATCTTTCCTGGACTA +TTGGAATAACCTCTCCCTCCTTCCACAGAGAAGCCATAATAATCTTTATGAAATACAAAT +CAAATCATGGTATTCATTCTTTAAATAGTTATCAATAAAAATAAAATCCCAACTTTATAC +CCTGTTCTGCAAATTTTAACGTGGTCTGAATTCAGCTTACATTTCTTCTTTCCCTTGTCT +ATTGCCCATCAGGCTCACTGGCCTTATTCCTTCACACCAAACTAGTTATTTCCGGGGTGG +GAGGAAGGCTTGCAGTGTTTTCTCCATCTGCAATAGTCTTTCCCAAATCTTAGTGTGGAT +AAAGTTTCCTTCTTGTTACTTGAATCACAAATACTATGTTCTTAGTCATTCTCTGTTACA +TCATCCAGAGTACATTATATCAATTTTCCAATATTTTTATTTATTTGATTTCCCACTATA +ACAGAGGCTCTGTTAGTGCAGGGTCTTTTACTCTTTTGTAATCCCAACAGCAAGAACAAA +ACAAGGTACATAGTACATATTTAATAAATACCTGTTGAACAAATATGTGCCAGTAATATT +TCTTCATGCTGCTGAATAAGTTAACAGCATATAAACACATACAAACCAAGTGGCATGGAT +GTCTGCTTTGATTTTTAGCCATTTAAAAATATACGTAACCCATCCTAAGGGGTTTATATT +TGTTTTGCATAATACATTAATATGTACTCATTATTCATTACACAGTTAATATATCTATAT +TTGCAGGGAATATACATTGCTTGGAATTATACAAAAAAATATTATTTTTCGTTTTCTAAT +ATTCAGGATACAGTGTTTTAATGGGGGTGTTTCTTCATTCTTTTTTTCTTACTGGTTTTT +ACTTTTTAAATTTGAAAGCCTTGCAGTGATCATAAGGATCTGTTCAGGCAAAGAACATGA +AAGAGTTTAAATTTTTATCATTTTAGTGTTTCTTATTCTCTATATCAAAAACATTCACAG +GTAAGTTAACAAGATCCTCATCAGGAGGAAAAGTAAATTGTTCACTACCATCCTCTAGTA +TCCTAATCTGGTCTTGTTGTTGGCTAACTTCAGCAGTTACTATTCTGTGATTGGTGTAAT +ATTAACCAAATAAATTACTGGATTTGTTCCACAAATATTATATCTTAGATTGGTTCTTTC +CTGTCTCTGAAAATAAAGTCTTGCAATGAGAATAAATTATTTTACAACAGTTAATTAGCA +ATGTAAAGTTTATTGAAAATGTATTTGCTTTTTTTGTAAATCATCTGTGAATCCAGAGGG +GAAAAATATGACAAAGAAAGCTATATAAGATATTATTTTATTTTACAGAGTAACAGACTA +GCTAGAGACAATGAATTAAGGGAAAATGACAAAGAACAGCTCAAAGCAATTTCTACACGA +GATCCTCTCTCTGAAATCACTGCGCAGGAGAAAGATTTTCTATGGACCACAGGTAAGTGC +TAAAATGGAGATTCTCTGTTTCTTTTTCTTTATTACAGAAAAAATAACTGACTTTGGCTG +ATCTCAGCATGTTTTTACCATACCTATTAGAATAAATGAAGCAGAATTTACATGATTTTT +AAACTATAAACATTGCCTTTTTAAAAACAATGGCTGTAAATTGATATTTGTAGAAAATCA +TACTACATTTGTAGTTGGCACATTAAATGCTTTTTCTTACTCTGAATTCCTGATATGACT +TTCTTTAGGATTGTTTAAAATATTCTAGTAGTTTTAGGTCAATTTAGATGTGATTTAGTT +GCTCTAGATATTATAATTTTTAGGGGTTCCCTTTCATTTTTTTCTTACGTTTCTTCAAAT +AGTATAATGCCTTATTTTCATTTATGAAGAAATTACCCTGCTGTTGGTGATACGGGTATA +TTTAAATAAACCAGTTGCAGTGCATTTTTGCAGAAAGTCCATTAAGACATAAATTTTGTC +CAGTAACCACAGTAGAAGTGGTGACTCTATGATTCATTCATGTTGCATAAGTAGGTGAAA +AATATGAGCTATATTCTGTCTGTTAAATGGAATTCTAGAGATGAAGTAGCCCAGGTAAAT +GTATGTTTGAGATTACTAGATAACTGTTGTACAAATTGGTATGTCACTTAAATTGTTTTC +TCTCAGAAAGTCCACATAAATAAATGAAATAGACTAATAATAGTAATATGGTGTAGAAAA +AACTCCCTTAACATTATTTCCATAGATAAAACTAATTAGAACTGTAAATTCTAAGGAGAT +TATTTATCTAAACTAATTTTAAAATCAGAAGTTAAGGCAGTGTTTTAGATGGCTCATTCA +CAACTATCTTTCCCCTTTAAATATGATTTATTGTCTTTCTCATACACAGATGTATTGCTT +GGTAAAAGATTGGCCTCCAATCAAACCTGAACAGGCTATGGAACTTCTGGACTGTAATTA +CCCAGATCCTATGGTTCGAAGTTTTGCTGTTCAGTGCTTGGAAAAATATTTAACAGATAA +CAAACTTTCTCAGTATTTAATTCAGCTAGTACAGGTAAAATAATGTAAAATAGTGAATAA +TGTTTAATTACAATAATAATTTATTTTAGATCCATACAACTTCCTTTTAAAAAACCTACT +GCACTAACTAGTTTTATGCTTAAAAAAAATTATTACCAGTAATATCCACTTTCTTTCTGA +AAAAATTTTCTTTAGATCGGCCATGCAGAAACTGAACCTGATTTGTTTTTTTTGAATCAC +CTAGGTCCTAAAATATGAACAATATTTGGATAACTTGCTTGTGAGATTTTTACTGAAGAA +AGCATTGACTAATCAAAGGATTGGGCACTTTTTCTTTTGGCATTTAAAGTAAGTCTAATT +ATTTTCCCATTAAATTCTTAAGGTACATATTACTTGCTTTCTTAATAGATTTATAAATAT +GTATTACTTATATACTTTTGTTTATGTTTGGCTGGAAGAGTTTTCCATACTAAAACTATT +TTGTACCAGTGATGAGCTTCTCAACTTTTGCTCTTTGAAATTTAAAAAGTAATAAATTCA +AAACTAAATTTCAGTCATGAATGAGAGCTTAAATATTTTTAAAGATTTTTGTTCTACTTA +AGTAAAATTTTCTAGGTCCAGATGAATATTGCTGTAGGTTTCACTGTGTGTATGGATTAA +AATATCCCCAAAAAAAGAAAAAAAATGTTTTACCTTGAGATTCAGAACAATAATGTCAAA +CTCCCGTGGTTCTTACTGAAAAACAAGCTAATTAAGAATAAAAAATGTTTTGTAGAATGT +GATATATGCAGTACTCAAAAGTTACAGGTCATAAACCATATAACTTTTCATAAATTTAGA +AACAGATTTATATCTAATATGATATTTTAAGTGTTAAAATTTAAAAATGGAACCCAGAAG +TTAAGTTGAAAACAAGAAGCGTAGACGTGTGTCAGAAGAGTCAAACAGCATTCACTGAGC +GCTTTGTTCCCTCCCTCTTCATTTGATTATTTTTGTGCTCAATTTCCTTTTTTCATGCTT +TTATATCTTGTACTGAGATTAGTCAATGAAAACTAGTTGAAATAAACCTAAAAACTAGAT +GTTTATTTAATCACATATTCAGGAACTACCTGAAACTCATGGTGGTTTTGCTTCTAAATT +ACAGGTTTTGAATAATGTTATTATTAGTATGATTGTAACATTTATTGGATTTCAAAAATG +AGTGTTTAAATTGTTTAGCAAAGATTATTTGTATACTGATTTAAGACTATATATATATTT +TTCTAATTTTGCATGATTCTTTTAGATCTGAGATGCACAATAAAACACTTAGCCAGAGGT +TTGGCCTGCTTTTGGAGTCCTATTGTCGTGCATGTGGGATGTATTTGAAGCACCTGAATA +GGCAAGTCGAGGCAATGGAAAAGCTCATTAACTTAACTGACATTGTCAAACAGGAGAAGA +AGGATGAAACACAAAAGTTGTGTGACTCTAGTCTGTGTTTGAGACTCTTTTCACTGCAGT +GGGGCAGAGTTGTTTAGAAGCCCAGTGTATATACAGATCATGGTCCTTGGAATCAAGCAG +ATTAGGATTTGGAACCAAGTTCCACTGCCTCTCATCTGTGTAGTGTTAGACACGTTATGC +AGGCTCTCAAGACTCATTTTCTTTGTCTGTAAAATGGGAATAATACCTGCTTCGTAAGGC +CATTGTGAGAATTAAATTACATGAGATATGCAAAGAACCTATCACAATCCTTGGAACACA +GAAGGTGCCCAATAAATGTTAGATCCCTTTACTTTCCCTTCCTTTCTCTTATTCAGGTCC +CTAAGTATTTACAGTGATTATTTCCTTATTCTGTCATTTATTATCTCTCAGTAATGACCC +TGAAAATGAGTGGAAAGAAGTTAGTTTTTACATTTCCAAGTTTAAAATGGATTTCGAGTC +ACTCAGTAAATATATCACACCCTCTAGTCATCTGCTGTCTAGCTTAGTGTAACTAAGAGT +AGGAAATACAATGTAAACTTTTTTTTTTGAGACAGGGTCTGGCTCTTTTGCCCGGCCTGG +AATGCAGTGGTGCAATTTCGGCTCACTGCAGCCTTGACCTCCTGGGTTCAAGCCATCCTC +CCACCTCAGCCTCCTGAGTAGCTAGGACTATAGGAGCATGCCACCACTCCCAGCTAATTT +TTGTATTTTTAGTAGAGACAGTGTTCTATTCTGCTTTATATTAAAAGCCCCTTAGAAAAT +GGGAACCTGGTGAATATATAATGAATTGTAAAATATTTTAATGTGTAACTTTTTCAACTG +TGAAACTGACTACTGATTTTTTGATGAAAACAGCTGCTGATAAAGTATTTTGTGTAAAGT +GTAGTTCTTATTAATCAGGAAAATGATGACTTGATTAGACTGTATATGCCCTCTTGGATT +TTATTTTAAATGGATTGGTGACTTTCACATAGGTAAAACACAGTCCATCTGTATTCTTTT +TTCCATCAAAAAGCGAGTGATTTAGAATTATAAAAAAATTTGTGAGCAGCCTATTTGAAA +GGCATCATGGAAATTTCACAGCACAATAACATGGATTTGTTTTTTTCTTAATGATGTAAA +TCCGTTTAATTCATATTTTGATCAATAGCCCATGCTTGCCAACTCTGAAGAAATTTAATT +TCCAGCAGTATTTTAAAGCTAGCCTGTTAACTTTTTCTGAATATTTAAAGTTCCTCTTTT +TTCTATGTCTGCACAAACTGCAGACCTGGGCTGGACCCACATACTCAAGAGTCCACCTTA +AGAAATTATTTTGATGTCCAAGACATCACTAAAATATTTCAGTTTAAAGATAACATGTGG +TGTTAATAGATTGTGGTGCTTTTACTATTTAAAGACAACTTTCATACTTCAGATGTTTTT +GAGAAGAGGGGAATGTGAGGGGAGGGGGCAGAACAGGGAGGAGTTTGAATGAATTACATT +CTTTATATCCATCCTGCTCATTTGGGGCATGTCTTTAAGAGAAGGCTGAAAGTTGTGAGA +GTATATTGTATACCGTAAGAGAATCAACTCTTCATCATGGATGGGATTGTGAAGGCTGAA +CTGTAAAAGTCAGCATTGACAGCATCCTCAATTAATAATTCTTGGTGACAGAATAATACA +GCTGGGCTGTTTTATAAATATAAACAATACCATTTTTAATTATTACATTAAAAATTTTAA +ATATATCTATGTGCCATGGCCTGGGAAGCCTGTTTTCTATTTTCATAAAAATTATTTTTA +CTGTATGAAAAGATTATGGGGTTTAGCTCAAAATATCTGTGGTCCTGATAAAATTGGATT +GGTAACTCTACCTCAGAAGGAAAATGGGAAAAAAAAATAGATGAGTCACAATTCAATACT +TCAAGCTCAGAAACTGTGCAGATCACTGAATTTTAGATTTATAAAGTCAGAGTTGGCATG +CGTTGTTTTTAATGATATGGAAGACCTTAAGAAAAAAACTTGGCTGAAGTTTAATCGTTG +GTCCAGCCATTTGAAAAAGGCAATAGTTCGAGGAGGTTTCCGAATTCGGCATTTGAAATT +CATTTTGTTCTCTCTTCTTCATTATTAGTGCATTTGGTGTGTGTATACTTGCACACAATT +CTGTTTGTGTACACACTGCTTGCTAAGCCCTAGTCAAGAGGCATCTTTTATAAAAGGTGT +AAAGAAATATCAAGGTTCTAAAATTCGGAAGAGTTTAGAATTTATTAGGAGTTTCCCAAG +TTGGGATGTTAGTCTTTAAATAAACTTCATGCACCTATTCCACTTAAGGTTTTGCACCTC +CTTTTTATTAGTGCAGTGCCATTTCTTCTGCTTGATTTTAGGTATGTTAATATTCCAGCC +TTGCTAGTTAGCATAAAGTGACAGGTGTGAGCCATGAGGAAATTTTCTGACTTAATTTTT +ATACAACTACATATGAGTTTTAGTGGAGAAAAAAAATTAGTCCCTTGTGCATATATAGTA +GTTAGGTAAATGATTTTTCTACCAACAGTGTACTCCATTCCTCATGTAGGTAAGTACAGA +AAAGGTTTTTAAATGTATTTTGTTAGCCAGTTAAAGTCTATGAATCTATCTGCAACCTTA +TTTAATCTGTCACTACAATAATTTTGTGGTTATGCTAAGAACCATGTATACTTTTAGGTA +TTCTTATTTTTGTCAATTTTTCTAGGTTAGCAAGGAGGCAGAAAAGCTTCACTGTTTCAT +ATTAAAATATAATTAGACTAAACTTAATTCTAGTATGAATTTCCAAAATCATTATCTATT +TATTTCATTTTTATTTAATTTTGTTTTTAGTTCATTTTTAAAAGTCCCTTGTTCAATTTA +ATTTATGTTCCTAAGAGTGGTTGGAGAACTTGGCCTTCATCTGATTTCAAAAACATTTTG +AGTTTCAAATGAAGTTAATGGTTTCAGTGTGATTCAGTCCTCAGACCTAATTGGGTTGAA +TAAAATCTAAAAGAATATACCCTTTTGGAGCATAACATTTTAATACCTTGAGGAATGTGG +CACTACCAAAAGAAGACTACTAACACGTCAGATGTTCACCTGGAAGCTTTAACAAGAAAT +TCGAACCACCCTTTTGGCCCCATTAATTGTAGCAAGTTTATTTCTCTATATTTTGTCATT +CAGTGAATTGAAGTCCTGTGGTATACTGCATTCATTAGAAGAAAAACGTTTTTAATGTCC +TTTTAATGATGGCCCAGAAAGCATTTGACACAGCAAGATGCATGTATTATTATATTGAGA +ATACAGAATAATAACAGTATCACTAAATTTAAGACCTCTTCCCAGTCTTGCTGTTCCTAG +CAAGAAGTTTGGCCCGTGACTGCACTTACTGTTTATGCTCATCAGAAACTGTCAATGTCT +GCTTTTCTTTAACTCTGCAGTCTGTAACATCATGCTGTTTATTAAAAAAAAAGAAAAATT +ACTTTGACTTGTGTCCAAACAATCCTTAGTGTACTACATAAGCAAAAAACTGTGATAATT +CTCTTTTGCCATTCCTTTTGAAAAGCAAGCCAGTGTTGCTAAAATCAAAATTTAGCTGAA +TTTGAGTTCTTTTCAGTAATGACTAAGAATACTTGATTGAAAATCTGAAACTATTATACC +TTAAAAGCCAATTTTTCTGCCCCAGTAAAGTGATGAATATTAAAGAAATGTATGTTTAAA +TATTTACTTCCTTTAAGCATAAAGAATTATATGCTTGTATTTTAAGAAATATATGTATGT +ATACATACATATGAATGTATGTATATGCAATAGGTAAGTGGACTTTTTTCCAAGTCATTT +GAAGATCAGAACCTAGAAATGAAGTTAGGCTACAAGCAAACTGGTTTTGCTTTCAGTTCT +CATAAACATTGCAAAAGGTAAGTGTGGGCTTTTCTTTGACCATTAATGCACATAGGCATT +AACAACTTAGTATTTCTGAGCAATTAAGCAAATAATTACTTACATTTTATTTATTTGCCA +AATGGTTTAAATAATTTTGAATTGACTTTGCTCTCCAGGGATAATATCTCTCTTTGCTGG +AATGATTCAGGTAGCTCCTATCTAAATGGAAAACTGTGGTAATTGAAACACACACTTTAC +ATTTTAAATTAGCAGTTTTGAATTTGTTAGGGAAAAAAATCCCAGCAATTGCATATTGTT +AGGTAGAAGTCAAATTTACAAAGAAACGGAATAGAGATGTGCCCTTGAGAAAAGTGTAGA +ATCTCAATGTGCAGATGATTTAAAATGTGCGTGCATATAAAATGTTCATGTGTACTTACA +TACTTTATTACAGAGAAGTCTTTGGTATACAAAATAGTTTACCACAACCTTTTAAACAGC +AGGTTCTGGGCCTTAAATGCGTATCACATTTAGCCAAGAGAACTCGGGTAGGGGCATGGA +AAATGAACTGCAGCTCCCTATCCCTAGCCTCTATACCAGCTGTTCAATGAAAAGTACCAA +GGCTCACTGAATGTTATAACCTAGCAGATTGTTACATAAATGATCTAACATTTTTGAGCA +CCGCTACTGGATGCTAGAAGCTAAGCTAAAGTGTTTCACATGCCCTACTTTGCTTATTCT +ATAAAATAACTGCGTGAAAGAACAGGTTATCCCCATTTTATAGATGAGAAAAGAAAGGTT +TACACAGGTTAGCTTATTTGCCCAAAGTTGTGATTATGGCCTACAAAGTCAAATAAATCC +TACTCTGAGACACATGTTCTTTCCACCATTGCACACTAGAAAGGAAAACACCAAGATTAT +TCATTACTGATCAAGTCAATATTGCTGTATTCAGCTAATTTAGTAATATGTGTCTTGAAA +TTAATTGCTAAAAGGGATTAAACTGACTTAGAATCAGTTTTTTGTTTGATTACATCTACA +TACAAAAGTAGCTTCAAATGTCTCATTCTACTGTCCATAATTTAAGATTTTTGAGTATAA +TACAATTTTAAAGATACTTTGAGGCACTTTGGAAAATCAGACCAAAATCTCTTTTCCACT +CACAGATTCGGCTTAATCAATCTGGAAAGCATTTGTTGAGAGCCTTATGACATCATTTAA +TAACCACGGTTGATTCATTAATTAAAGTACAGACAATTGTTGACTATCCATGTGGGACTT +TTCTATTAGGTTGACGCAAAAATAATTGCGGTTTTTCGCCATTAAAGGTTAACAGCGAAA +ACTGGAATTACTTTTGCACCAGCCTAATACGATGTGGATCATCTGAGATGAATGTTGAAA +TCCAGTATAGCTTCTTCATATTTCTGGCCCATTTTTCCCACCAGAAAGTGCACAAAGTGA +AATGAGCTTATGAAAAGCTTAATTAACTAGAAAAATGTTACTGAAAGAAAAATTACATGG +TACATGACAAGGCTAAATACTAGTAACTCTAAACTTAGTGAATTTTCTAGGCAGCAGCTT +TCCTCTGCTGTCTAGACTGGTAAAGAACAAACTAAGGCCAGGCGCAGTGGCTCATGCCTG +TAATCCCAGCACTTTGGGAGGCTGAGGCGGCCAAATCACCTGAGGTCAGGAGTTCAAGAC +CAGCCTGATCAACATGGTGAAACCCTGTCTACACTAAAAATATAAAAATTAGCTGGGCGT +GGTGGTGCACACCTGTAATCCCAGCTACTTAGGAAGCTGAAGCAGGAGAATTGCTTGAAC +CCAGGAGGCAGAGGTTGCAGTGAGCCAAGATCACGCCACTGTGCTCCAGCCTGGGCTACA +AGAGCAAAACTCCATCTCAAAAAGGAAAAAAAAGAAAAAAACTATAATAAATATGTTAGG +TCCATGTTTTCTTAAGTTTTCTACCGGATTTTTATCTTCGTATAGTGAACGAACTGTTAA +GAACTTTTTTATGAGAAATATTTTAGTATGACTATATTGCATAGAGTTAGGCTGATGGTT +CAGTGTTCAGTAGGTTAGATACCCTCATTGTTTATTTCCATATTGACTGGTTCTAGCTAG +AGCTGAAATTAGGCAAAGAATATCTTGAACTCATTTTGCTATACAGGAAAAAAGTGCTTC +CTTAGCTCATTTGGAAAGAGATTGAGATTAGAAAAGATGGTTAATTTGTATGTATTTATA +GAAATAAATAGAATACAAAATGAGGCTTTTAAATTTTTTCCCACATGAAAATATGATACT +TTAATCATTACGTTTTACATTGTTAGTTTGCAGACAGGCATAATTAGGTCCTCAGTTGCA +GAAATCACAGACATCTGAAGGCCAGCCCTTTAATTTGGCCACCGTCTTAAGATTTCTCTG +CTCCTTCCTTTGCTCCTCCTCCTACTGCACAGTTTGAACTGATGCTGTTCTATATAAGGT +ACTTTTCCACCTACCTCATCTCTGACTACAGTGCTATATTTTTCACACAGTAAGGACAGG +TGTTGTGTTAATCTCACCATGCCAACAATCAGGGCACCACCTAGCAGAGTCAGTGAAGGC +CAAAATAAACAGTGGAAGATAGCCATTTGGTCATACTTTTTTATAAGAATGACATCTTCA +GATTGGCTGGCTGGACTGTAGAAGCATGAAAAGGGGGTTCCATTTTTGTGATCGAAGAAT +TCTTTTATGTCCAGAGCACTGTTGAGCAAATCATTTCTATCTTGGTGGCACTTAGGTGTG +TAAAAGCACTAGGAATATGGAAGAGGGAAAAAGATAAAGGCACTGTCACCAATACCAAAT +ACTTAACAGTTTCTAATTATGAAATAGCTTCAGGCTGAAGTTATTAGTGGGCAGTTTCAA +TCTTAGAAGGTGGTAAAATATTACATAGCTCATGGGAAAGGGTTGATTGGAGGGCCACAG +TGAAATGGCCATTTCCAGTCATTAAGCAAGGATGTGGAAGAGAATTCTTAGTTTATATGA +CATTGCAGGAGAGTCAGTGACCAATTTCATAAGGAATATGACTCCTCCCTACATGCAGGT +TCTTGGACTCTTGGACAGTATGAATCCGTTTGTCCATTGAACAAAAATGTATTGAGCCTT +ACTATGAGCTTTCAACACCTAGTAATGCCTCTGTGGTCTCTGTCTTGATCTCCTGTAGCA +AAATATTACCCTGAAGAAAAGCACGTTGAGGCTTTTGCTCTAGACTCACAGACAGGGAGC +CCCACCTGGACTTTGGTTCCTGGGAGACAGAACCAGTGGAGAAGGGAGCTCTGTCAGCTG +GTGACTTTTTTCAAAAAAGCTTGAGGTTTATTACCATATCCATTAGGTACTTGAGGTACT +GTGCTAAAGGCCTACAAACTGTTTGAAATCTTAAAAATCATTGCATCCAAAATAGAAAAC +AAAAGTCATCAGATTGAAATTGATGCTTAAAGACAATAAAGTGTAACATGTCAACTAATC +TAACACAACTCAACTTTTATAGTTAGGTATAAATATAAATTTTAAATCATATGAAAGACT +ATACTTTCAGGGATCATTTCTATAATTCGTTAAATCATATGAACCCATTGTGTAACTTAT +TAAAATAAAAATAATCTTTACATTTATTTGATAAGAAAAAATTACTCGCTTGATTCAAGG +GAGACTGTGGTACACTGTAGCATATGTTATATGGCGCGGAGTGGAATCTCCAAAAGAAAG +ACTCCCCACAAATGACTACTCATTGGCTCAGCCTATAAATTCCAGACACCAAGTTGTGAA +ATTGGAATAATTTCTCTCCTTTCTATATACCCCATTTCTCCACCAAGAAGAAAGCTTCAT +TTATCCTGATTTGATCACTATAAAAATGTTCACTCCAAAAAAATAGATTTATCCCTAAAG +ACAGCCCTGGGTTATTTATGTACCCTGCTAGGGACAGTCTGGCAGGGAAAGGTTGCTGTC +ATAAGAACTCTTTAAACTTTACAATACCTTGGGATTTATCTGGACAGCCTCTTCATTATA +ATGTAGGAGAGCTTTCTGAGCTGAATGGGTGAGGTTCACAAACACCCGAAGACACGAGTA +CTTCCCGTGACCACGGCAGTGCACACCACAGGTGAAGGCACAGTCCAGCCAGTCGTCCAT +GATATCTGTGTGGATGGCAGTGCAGGTTGATTCTTCTCTCCGAATGCTTCAATTTGAAAA +AAAAAAAAATGTTCTTCACTTACTAGAAAATTTCGTTCTACATTTTGGTGCGGTTATGAG +CTTATGTACACAATTAGCTGGGATTACAGGCGCTCAGCTGCCATGTCCAGCTAATTTTTG +TATTTTTAGTAGAGACAGGGTGTTGGCCAGGCTCGTCTCCAACTCCTGACCTCAAGTGAT +CCACCCACCTTGGCCTCCCAAAGTGCTGGGATTACAGGCATGAGCCACTGCACCTGGCCC +AAATACTATGTTTTATCAATTCTAAAGTGCACTTTAGTATTTACATTTTAATATAACTAA +AATCAATATGTATTTTGCAATCAATGGCATCTTGCTATTATTTGAAAACATTTCTTTAAT +AGTCTGTAAAATAATGGAACATGCCCAGATGCAGTGGCTTATGCCTGTAATCCCAGCACT +TTGAAGGGTCAAGATAGGAGGATCGCTTGAGCCCAGGAGCTGGAGACCAGCCTGGCCAAT +ATAGTGACAGAATAAATAAATAAGTAAATAAAATAATGGAAAATCTCACAAATGGTGATG +TTTTAGGTTCGACAAAATACATTAACTAGCCCATTTAGTTTTCTGAAATTATTTTGATGT +TATTGCTTACAATATTTGTTCTGTGGTACACAACCATAGGATTAATAATATTGATGAAAA +TAATAAAAGAATAATAAGCATGTATTGAGCTCTTCCTGTGTGAAGTTCTGGACAAATCCT +CATAAAGCCTTAAAAGGCAGATACTAGGCTGGGCACGGTGGCTCATGCCTGTAATCCCAG +CACTTTGGGAGGCCGAGGCAGGCAGATCACGCGGTCAGGAGATTGAGACCATCCTGGCTA +ACATGATGAAACACGGTCTCTACTAAAAATACAAAAAATTAGCCAGGCATGGTGGCACGT +GCCTGTAGTCCCAGCTACTCGGGAGGCTGAGGCAGGAAAATCGCTTGAACCTGGGAGGCT +GAGGTTGCAGTGAGCCAAGATCGCACCACTGCTCTCCAGCCTGGGCGACAGAGCAAGACT +CTGTCTTAAAAAAAAAAAAAAAAAAAAGAAAGAAACAGGCAGATACTAGCCCAGGCACGG +TGGCTCATGCCTGTAATCCCACACCTTCGAAGGCCCAGGCGGGTGGATTATCTGAGGTCA +GGAGTTTGAGACCAGCCTGACCAACATTGTGAAACCCTGTCTCTACTAAAAATACAAAAA +TATTAGCCAGGTGTGGTGACAGGTGCCTGTAATTCCAGCTACTCAGGAGGCTAAGGCAGG +AGAATCGCTTGAACCCGGGAGGCGGAGGTTGCAGTGAGCTGAGATTGTGCCACTTTACTC +CAGCCTAGGTGACAGAGGAAGACTCTGTCTCAAAAAAAACAAACAAACAACAACAACAAC +ATCAAAAAGAAACCTATAGTAATAAAATTGAAATAGAAGGAGGTTTGCAATCAAAATGAC +TGACTAGGAATGAAATAGGAAACATAATATTTTGCATCTGCATAGGGAAGTCTGAGATTG +GCTGATCTTGTTCTCTTCTGTAGGGGAAATACTAGTCCAGAACTTGGGGTGCCTGCCAAG +AGGGGAGCAGCCACAGTAGGAAAGGGGGACTCTGGAATGCTAGGGTTCTGGGGTCTGTGG +ACACAGGAGGCAGAGGACATGTGTTAAGATGTTTTAAGAAATGAATGTTGAACTGGATAT +GAAAATATTTTTCAGCCGGGCGCAGTGGCTCACGTCTGTAATCCCAGTACTTTGGGAGGC +TGAGGCGGGTGGATCATGAGGTCAGGAGATCGAGACCATCCTGGCTAACACGGTGAAACC +CCGTCCGTCTCTACTGAAAATACAAAAAGTTAGCCAGGCGTGGTGGCGGAGGCCTGTAAT +CCCAGTTACTCTGGCGGCTGAAGCAGGAGAATGGCGTGAACCTGGGAGACGGAGCTTGCA +GTGAGCCGAGATTGCACCAGTGCACTCTAGCCTGGGCGACAGAGGGAGACTCCATCTAAA +AAAAAAAAAAAAAGAAAGAAAATATTTTTCACTATAGAGAGGCATATGTCCCCTGAACTT +GCCGGGATCCACCTTTCCTGCTGGTGCATTCTGTGAGTTAGAAGAAAACTTCCAAAGAGC +CATTTTTTCCACCCTGTCTACTGTATAAAATTGCTTCTCAAACATGTGCTGCATTGCAGA +GGATTACCATTGTTTTGCTAACCAGCGTCTGGTCTTTCTTATGTGGCGCTGCAATTACTA +GTGTCAAACCCTGTTGGTAATACCCAGAGGACGGTGTCTGAAGTCTTTACTCAATATTCA +CATTTGGCCGGGTGTGGTGGCTCACACCTGTAATCCCAGCACTTTCGGAAGCAGAGGCAG +GCGGATCACTTGAGGTCAGGAGTTCAAGACCAGCCTGGCCAACATGGTGAAACTCCATCT +CTACTAAAAATACAAAAATTAGCCGGGTATGGTGGCGGGTGCCTGTAATCTCAGCTACTA +GGGAGGCTGAGACAGGAGAATCACTTGAACCCAGGAGGTGGAGGTTACAGTGAGCCAAGA +TTGTGCCACTGTACTCCAGCCTGGGGGAAAATTCACATTTGTAGAGAGTTTAAATTCTTT +TTTGATACGGAGTCTCGCTCTGTTGCCCGGGCTGGAGTGCAGTGGCAGGGTCTTGACTCA +CTACAACCTCTGCCTCCCAGGCTCAAGGGATTCTCCTGCTTTAGCCTCCTGAGTAGTTGG +GATTACAGGCACCCACCAAAACACCTGGGCAATTTTTGTATTTTTATTAGAGACAGGGTT +TCACCATGTTGTCCAGGCTGATCTGAAACTCCTGACCTCAGGTGATCTGCCTGCCCTGGC +CTCCCAAAGTGCTGGGATTACAGGCATGAGCCACCACGCCCGGCCGAGAGTTTAAATTCT +TAAGTCCTACACTCCAATGTGTGGGAAGTATTCGTGCTATGCTTTTATAACTAAATCATC +TCAGTATTTCTATTTCTAGCCCCCTTTTTCTGCCTGATGGTAAGATACTTAATCTAGTCA +ATTCCAGGTAAACTTTGGCCTTTTATGATTTTTCCTGATCAGGCCAAACCTCAACCAAGT +CCCTTCTTGATCTTCTCCTTCACCTCCTTCTCTCATTCACCCGACAATTAGCCTCCAGTC +CACGGGCTGATGCAGCATCTTGGTGTCCTGTGGTCTGAGGTCATTTTCTGTCTTTCTCAA +GCCTCAGCTAAAGTTTACAATCCTACCTTTTCTCATGACCTTGAAATGCCCTAAGGTTCA +GGGGCTTCATGGTTGCTGCTTCATGGGGGAACCTGGCTGTTCTCTGAGGCTGCTCGGCCG +CGAACACCCCATCAACTACCCGGGGCCCATCTACGCCCGAGGCCTCAGCCATTCCTGCTC +TACAGCTCTGCTGTCCCATTGGCACAGGGAACTTCTTGGGGCCCCAGGGTTCCAGATTGG +AAGCAGAGAATCTCCTCTGTTCTCAGACCCCCAAACTTTGTTGTGGATTCTAATTGTCCT +TTCCCCCATCTCACTCCTTGGAACCCACTGGGAGGTGAGTAGAATCCCTGTCAGAGATTC +TACCACCATCTCCCTCATTCTTACCCTAACTTTCTTCCTCTTCCTCCCTAGTTAGGAAAG +AGGATCTTTAGCCTGCGGCGGGGGGGTGGGGGTGGGGATGCTTGATGTTTCAGGGGAAAA +GGTGACTCAGCTACTTTTGGAATATCTGTCATACCTGTCTACTGGTGCAATGAGCTGGGA +TCACACCACTACACTCCAGCCTGGGTGACAGAGCAAGATTCCATCTCAAAAATAAATAAA +TAAATAAATAAAGACTCTGGAGAAACAACTCAATACACATGAGAAGAGGCTGGCCCATGT +AGGGAAAGGACTGGCAAACTATGACAACTCTTTTCTGTTGTTTTGTTTTCAATAGTCTCT +TCACAGTTCTTTTCACAGTTTGGAATTGATACCTTTTTCTCTTCATCAGAACTCCAATGT +TTTTGTAGATTGAAGTCTTTTTTTTTTTTTTTCTTGAGAAAGGGTCTCACTTTGTCACCC +AGGCTGGAGTGCAGTGGACCAATCACTGCTCACTGCAGCCTCGACTTCCTGGGCTCAAGA +AATCCTTCCACCTCAGCCCCCCAGTAGCTAGGACTACAGGTGTTCACCACCATGCCCAGT +TAATTTTTATTTTTTAATGTATTATTATTATTATTATTATTATTATTATTATTATTATTA +TTATTTTGAGATGGAGTCTTGCTCTGTTGCCCAGGCTGGAGTGCAGCGGCACCATCTCGG +CTCACTGCAACCTCTGCCTCCTGGGTTCAAGAGATTCTCTTGCCTCAGCCTTCCAAGTAA +GTGGGACTACAGGTGCATGCCCCCACACCTGGGTAATTTATTTTTTTGTAGAAAAGGGGT +ATCAGTGTGCTGTCCAGGCTGGTCTCAAACTCCTAACCTCGAGTGATCTGCCTGCCTTGG +CCTTCCAAACTACTGGGATTAGAGGTAATGAGTCACCATGACTGGCCTACGTATAGCCCA +AATGGATGAGCAGTTCCCAAGGCTCATTCCCAGCCTCCACTATCCAAGTCAGCCTCTCAT +CTCCTTCATTTCCCAGGACTTAGTTCTCATTTTCCTCCCCTGTTTTCTCCGGATTGTGGC +TATTGTTCCCTGGTTGCTAGATCAACCTGGAGCACAGTAAAGCAGTGTCACAAAGCTGGA +AGGGGTCTGGGATGAGTCCACCAGCTACAAGTTCTTATAGAAAACGTACTCCGGGGATGG +CCGGGCCCAGTGGCTCATGCCTGTAATCCCAGCACTTTGGGAGGCCGAGGCGGGCGGATC +CCCTGAGGTTGGGAGTTCGAGACCAGCCTGACCAACATGGAGAAACCCCGTCTCTACTAA +AAATACAAAATTAGCTGGGTGTGGTGGCACATGCCTGTAATCCCAGCTACTAGGGAGGCT +GAGGCAGGGGAATCGCTTGAACCTGGGAGGCGGAGGTTGCGGTGAGCCAAGATTATGCCA +TTGCACTCCAGCCTGGGCAACAAGAGTGAAACTCCATCTCAAAAAAAAAAAAAAAAAAGA +AAATGTACTCCAGGAATTGTCATTTCTGAAATTCAACAGCTTCTGGAATTGAAGCAAACA +GCTCATCTTGGAAGAGAAATATGTAGCCAACTCCAAAGCCAAAGCCTTTGAGTATTGAGA +CCTAGCATGCTAGGAGACCTTGATCCTGTAACCTCAGAAGAAGAATCTGGATCTGGCCAA +ATTGAGGTCAAATTCTGCTCAACTTCTCCATAGTCAGTAGGAGAAAAAAACCAACTTGAT +GTTTGAGTCATATGTTTTGACAACTAAAGAGGACACTTATGCTGGGGTCGGTGGTTCATG +CCTGTAATCCCAGCACTTTGGGAGGTCGAGGCGGGTGAATCATTTGAGGTCAGGGGTTCG +AGACCAGCCTGGCCAACATGGTGAAACCCCGTCTCTACAAAAAATTCAAAAAAATTGGCT +GGGGGCAGTGGCTCATGCCTGTAATCCCAGCACTTTGGGAGGCTGAGATGGGTGGATCAC +GAGGTCAGGAGTTCAAGACCAGCCTGGCCATTATGGTGAGACCCTGTCTCTACTAAAAAT +ACAAAAATGATCCGGGCATGGTGGCGCACGCCTGTGGTCCCAGCTACTCAGGAGGCTGAG +ACAGAAGAATCTCTTGAACCTGGGAGGTGGAGGTTGCAGTGAGCCGAGATCACGCCACTG +CACTCCAGGCTGGGTGACAGAGTGAGATGTCATCTCAAAAAATAAATAAATAAATAAATA +AAATTAGTCTGACTTAGTGGCGGGCCCCTGTAATCCCAGCTACTGGGAGGCTGAGGCAGG +AGAATCACTTGAACCCGGGAGGTGGATGCAGTGAGCCAAGATCATGCCACTGCACTCTAG +CCTGGGCGAGTGAGACTCCATCTCAAAAAAAAAAAAAAAAAAAAAAGACACTTAAAGATG +ACATTAAAGAGGATACTTAGATTCTAGACAAAATCAAGATATAGCAAATTGGGGTGGGAC +ACACCTGTAATCTCAGCATTTGGGGAGGCCGAGGCAGGTGGATCACCTGAGGTCCAAAGT +TTGAGACCACCCTGACCAACATGGCGAAACCCCGTCTCTACTAAAAATACAAAAATTAGC +CAGGCATGGTGGTGGACACCTGTAGTCCCAGCTACTCAGGAGGCTGAGGCAGGAGAATCA +TTTGAGCCCAGGAGGCAGAGGTTGCAGTGAGCTGAGACTGCACTGCTGCACTGGTGCCTG +GGCCACACCAGTCACTATGCCTGGGTGACAGAGCAAGACTCTGTCTCAAAATAAATAAAT +AAATAAATAAAATTTTGTTTTGCTGTGTTGCGGCTAATATGCGTGCTATAAGACAATGGT +TTCTTGAGTCTCATTCTCTCTGCATATGCCTAAAGCTTTTTTATTTTTATGATTCTAAAA +GATTGTACCTTCTCATCTCCTAGATTCTGTCCCATAGGTTCTGATTTTTCCTAGAGTAAC +TTGGAAGTTAAAAAAGTGGAAAAAGCTTTGCGTATTAGGTGCCAAACCCACTCAGCTCTG +CTCAAACCCCTTCTTTAATGCCCAAGGTTGTCCAATCCTAGCCCTTCCCCCTACCCTCAG +CTTTCTCCTCACCTACACAGCAACCTTAGTATAGTCCTAAAGTATGTGTTCTTATCTTCT +GTTATCTATGCCAAGGATGTTTGCTGGTTTTGTTTTGTTTTGTTGAGACAGGGTCTTGCT +CTGTCTCTTAGGCTGGAGTGCAGTGGCACAATCACAGCTCACTGCAACCTCGATCTCCTG +GGCTTAAGTGATCCCCCCACTCAGCCTCCTGAGTAGCTGGGACTACAGGTATGCATCACC +ACGCCTGGCTAATTTTTTTTTTTTTTTTTTTTTTTGAGGCAGAGTTTTGCTCTTGTTGCC +CAGGCTGGGGTACAATAGTGTCATCTCAGCTCACCACAACCTCTGCCTCCCAGGTTCAAG +CAATTCTCCTGCCTCAGCCTCTCAAATAGCTGGGATTACAGGCATGTGCCATCACATCCG +GCTACTGTTTTGTATTTTTAGTAGAGATGGGGTTTCTCCACGTTGGCCAGGCTGGTCTTG +AACTCCTGACCTCAGCTGATCCACCCACCTTGGGCTCCCAAAGTGCTGGGATTAAAGGCT +TGAGCCACCATGCCCGGCCCATGCCTGGCTAATTTTTTTTAATTTTTATTTTTGTAGAGA +TAGGGTCTCACTATGTTGTCCAGGCTAGTCTTGAACTCCTGGACTCAAGCGATCTTCCTG +TCTCAGCCTCCCAAAGTGCAGGAATTATAGGCATGAGCCACTTTGCCAGGCAAGGATTTT +TTTCTTTTTAAGTTACATTTCTGCCTGCCACCACAGCAGCTCTTTCTCCTGCTCTCTCTC +TCTCTCTGTGCTTTAAGATGATAGTCCCTTCTTTTTTTTCAAATAACCACAACAGGAAGG +ACTGACCACTCTTGTAAGCTGCAACTGATGTTTTCAGACTCCTAAAGTGACATCTAGACA +TAAGTCCATATATGTCAGAATATCATGCAGGGAATGCTCAAATAGTTGGGAAGAGATTGC +TGCACTGTGTTTTGCACGCCCAAAGCCCACATAGGTACTCAGTTTAAAAATCTTAATAGA +ATTGAATCCTGCTCTTATCATAGGAAAGGAAGAGCATCTGATAGAAACACAAAATGAAAA +GGTCAAGAACTGGCTGGGCACAGTGGCTCTCGCCTGTAATCCCAGCACTTTGGGAGGCTG +AGGCGGGAGGATCATGAGGTCAGGAGTTCGAGACCAGCCTGGTCAATATGGTGAAACCCC +GTCTCTACTAAAAATACAAAAAATAGCTGGGCGTGGTGGCGCGCACCTGTAGTCCCAGCT +ATTCAGGAGGCTGAGGCAGGAAAATCGCTTGAACCTGGGAGGCGGAGGTTGCAGTGAGCC +AAGATCACGCCACTGCACACCAGCCTGGGCAACAGAGCAAGACTCCGTCTCTCAAAAAAA +AAAAACAAAAAAAGTCGAGAACTGGAAAGGAACTAAGCGCATGAAAAGAAATTTTATGTT +CCTTCATGTTTTTATTTAAAGAAAGTGAATCAAGTACCAAACACGGAATAAAGGCAAACA +TTCATTTTTGGGGTGATTGTTCCCTTCTTGGCAATCCCTGTTTTATTGAGGGTATCACTA +GTTATTCAATCCAAGGATTTTTTTTGTTTCCACAGGAGGTGGGTGTTTCTTTGTCTTCTT +AGAGTCAGGATTCCAGATCTCCTGATGTGTGGGACTTTTCTTGGCCACTACGATTTCATC +TACAGTCACGAGCTGTAGCACCACCTCAGCCACTGCTCGAAATCCTTGGGCTTTGACTAT +TAGGGTGTCCCACACCCCTTCCTGGGCCACATTTATTATCCCTTCAGTTCCCACACCCAT +TAGGAGGTTCCCACCTTGGTGCACTCCACTCATTTCTGCCATCACGTCTGAGACAGCTAA +GCCTGCATTCTCTGCCAAAGTTTTAGGAAGATACTTCAGGGCCCAGGCAAATGCTAGGAA +TGCAGGCCCACTGGGCCCTTCCAATCTGCTTCCTTTATCAGAAAGCATTTTTGCCAAAGC +CATTTCTGTGGCCCCAGCTCCTGGAATCAGTCTGGGATCTTGACATAGCTGGAAATAGGC +ATCAATGCCGTGGTAGACGGCCTGCTCTGCACTCCGCAGCCCCTGGGTGGTGGCTCCCCT +GAGAACCACAGTGAGGGCAGGTGTGCCTGTACATTCCCATTCAAATACCACAGCCAAACC +ATCTCCCAGCTCCTGCCTGTAAACCCTCTGGCACTTGCCTGGCCTCTGGGGAGGGAGCAG +ACGAGGCAGCAGAGGTGTGTCCAACACCTCACTCAGGTAAATGATCTCCATCCAAGACCT +AGCTTGAATCACCACGATGCCATACTTGTCCGCCAGTGTGAGGGTCTCCTCGTCGACCTC +CCCCAACACCACTGCCACATTAATTCCTGCAGCTGCTAGCTGGCCTACTTGCTTTTCTAG +TAATTGATCGCTTCCTTTACTAAATTGAGCTAGATCAGCAGGACTAGAAAGACGGGCCGT +TGCTGGTGCATTTGGATGGGCAGGACCAAAGGGGCAAGCAAAGAGAGCCACCCTGGCACC +ACTTAACACTGTGGCCATTTGCCCACAGAGCTTCCCAGATATTGCTAACCCCGGGAGGAG +GCAGGAATCCTCCAGTGTCCCCCCGGGCAGCGCGCACACCCCAACACGCTCAGGCTTGAA +GCTGCCGTCTAGTTCCTTGATAGCCCAGCAGGCGTGGGCCACCAGCTTGGTCAAGTGGTC +CATGGGGGACAGGGTGTGGGTATTCATCACAGAATGGAGGGCCCAGGATGGATCTTCCAA +AGGCCCCAGAGATTGGATGGCCAGGGAGGGCAGTGTGGCCAGGACCTCTGCAGTGGCCGT +GGCGTAGGCCTCCCGGAGCTGCGGGCGAGGCAGGCCAGCCTTCAGCAGCTGCTCTGCCTG +TTCCAGCAAGGCTTCCGTCAGCAGAACCACGAAGGCTGTGCCGTCCCCACTATTCTCTGC +CTGGGTTTGTCCTGCTTCCCGGAGGAGCCATGCTGCTGGGTGCTCCAGCTCCAGGGCCCT +GAGGATGGCAGTGGCACACCCCGTGCACACTGTTTCTCCTTTCATGGTCACCAGGAACTT +CTGCCGGCCGTGGGGGCCATAGCAAGGCCGGATGACACTGGCCAGGGTCTGGACTGCAGC +CAAGCTGCTCAGCAGGTGGGGCTCCTCCTCTTCTGGACTCCTCGGGCTCTCCCTTGGGTT +CAGTGCCAGCCGCTGGGGCAGCTCCAGGGCTGAAGGGACTGTGCTGTCCATGGCCCGCAG +AGAGAGGAGAGGCCACCGTGGGTTGCAGAGATGCTCTAGAAACAGCAGCTGGGGCACTCC +TGACACCGATCGTTGAAAGTACTCAAGAGGTCAGTGGAAGCAAGGAGCCAAATGCCCATT +GATTGGTATCTGAAGACATCAGCACGGACCAGCACTCCACTGTGGGTCCAAGGATGAGCT +CCAAAGAGCCCAGTCCTAAAGCCACCCCAGGGTTGATTCTGTAAAGGAACTGGGTCTTGG +GGCCTCTCAACCTTGGTGGCTGAAATGGGATCTTTAACTGATGAAGTCACAAAGTGGAAA +ATGGAACCAGGATAGAGAATGAGGTCACAGAAGGCTGGTTAGAACTGAGGAGGCCCTACC +AGCAGGCAAAAGTCAGGCCTTGTCCAGCAATGGAGGTACATGCACCTCTGCACCAGGTTT +GAGACTTGTTTAAACGTAAGAGACAATGAGGAGGAGATCAAGTGAAAAACTACCCATTTC +ACCCTATCTGGAGTGCAGGGGCATAACCATGGTTCACTGCAGGCCCAGCTCCCTGGTCTC +AAGCAGTCCTCCTGCTCAGGTTCCCAAGTACCTGGGACTACAGGCACACACCACCACACC +TAGCTAGTTTTTTTATTTTTTGTAGAGACAGTGTTTCTGTCTGTTGTCCAGGCAGGTCTC +GAATTCCTAGCCTCAAGAGAGCCTTCCACCTTGGCCTCCCAAAGTGCTAGGACTACAGGT +GTGAGCCACCACCTCACCCACCCTTTTTTTTTTTTTTTTTTTGAGACAGAGTCACACTCT +GTTGCCCAGGCTGGAGTGCAGTGGTACAATCTTAGCTCACTGCAACCTCCACCTCCCAGG +TTCAAGCAGTTCTCCTGCCTCAGCCTCTCAGTAGCTGGGATTACAGGTGCCAGCCACCAC +GCCCGGCTAATTTTTTATATTTTTAGTAGAGATAGGGGGATTTCACCATGTTGGCCATGG +TTGGCCAGGTTAGTCTCAAACTCCTGGCCTCAAGTGATCCGCCCACCTCGGCCTCCAAAA +GTGCTGGGATTACAGGTGTGAGCCACTGCACCTGGCCTTTTTTTTTTATTTGAGAAGGAA +CTGAGAGATGATGTCTGTGTTTTGTTTTGTTTTGGTGTTACTTTCTCTTGCAGTACTGTG +TAATATTAGCCATGTTTTGCTGTCTGCCTTTGACTTTTTGGGTATCTTATCAGTTTGTGC +TTGTGTATCAGGTTTCTTAGGGTGTCTGTTGGTCTTTCAGGGTGCAGGTGTGGGAGGCTG +CACAGCGTGCATGCCTGTGCCACGACTCCCAACTCTGCCTCCCTGGCAGAGGCAGGGCAA +GACAAGTGGGGAAGGATGCTGACAGCTCACAGACAAATAGAAGTGAACCCAGAGGGGTGA +AAAGCAACCAGCCTCCCAGCGGTCAGGGAGGTAGAAGCCTAAATGGGGTCCTGAGATTTA +AATGCGAATCGCCTTCCCATCCTAACCTTCAATGCTTACAATTTAAGTCTCTTTTTTTCA +TTCTCTCTCCTTTCCTCACTTGTCTCCTCTTTCCTCCTATAGAGCCTACTCGGGTAATGA +TGCTTCTGCTTTAGTTTAACACATATTTAGTCTGGGCGTGGTGGCTCATGCATGTAATCC +CTGCACGTTGGGAGGCTGAGGCGGGAGGATTGCTTAAGCTCAGGAGGTTGAGGCTTCAGT +GAGCCATGATTGCACCACTGCATTCCAGCTAGGGCAACAGAGTGAGACTTGTCTCAAAAA +AAATAGGGGAAAGGTCATTTGGAATCCTAGTCCAGAGATAACCATTGTTTACAACTTGAT +GAACATTACTACTTTGCACATATTATATGCATACATAATTATAGATTTACACCATTTTAC +ATAAGATTATGATACATATATGCTATTCTGTGATCATTTCCCCCTCAACATTATCTTGGC +TCAGAGAAATGTTTCTTTTTTTGTTTGGACATGGAGTTTCGGAGTTTCGCTCTTGTCGCC +CAGGCTGGAGTACAATGGCGCAATCTCGGCTCACCCTCGGCTCACCACAGCCTCTGCCTC +CCGGGTTCAAGCAATTCTCTTGCCTCAGCCTCCTGAGTAGCTGGGACTGAGTAGCCATGT +GCCACCATGCCCGGCTAATTTTGTGTTTTTAGTAGAGACAGGGTTTCTCCATGTTAGTCA +GGCTGGTCTCAAACTCCTGACCTCAGGGGATCCACCCGCCTCGGCCTCCCAAAAGTGCTG +GGATTACAGGCGTGTGCCACTGTGCCTGGTCTGTGAGCCACTGTGCCCGGCCTGAGAAAT +GTTTCTTTTTTTCTTTCTTTTTTTTTTTTTAAGCAGAAACACATTCATTTATTAACCAAA +GGGATGATCCTAATGAATCCAACACACTTTGAAATAGCTGCATGTAAAATGTTTGTGATA +AAGATAATTGAACACAGTAATGAAAAAAAAAAAAGAAAGAAAGAAACGGTATGGAGATTT +GCTCATTGAACTGAGCTTGGTCATTCTCTTAGTTAACTCCTGTCCAAAGTGATGATGGAA +TCTTTATTGTACTTTTTCATAGATCCGAGTACAGGCGACATGGTTCATGACACAGTCCAC +CACTAATTTCCCATCTTTCAATGTTCTTGTTATTGTGCTTTCCTTCCCATCCCACTCCTG +ATGCTGAACCAATGCACCATCTGTAAAGTTGCACACAGTCTGAGTTTTTCTGCCATCAGC +TGTGGTTTCTTCAAACTTCTCTCCCAGGGTACAAGAAAACTGTGTTGTTTTCAAAGTGCT +CTCAGTTTTTATGGTGAGGTTTTTGCCATCACAAGTGATGATACAATCTGGCTTGGCCAT +TGCGCCCATTTTTTGCAAAGCTATTTCCTCCTAGCTCCTTCATGTATTCATCAAAGCCTT +CGCTGTCCACCAGGCGCCATCTTCCTTCCAGCTGCTGAACTGTGGCCATGGTGGGTGCAG +GGGGGCTGGTGTGCAGAGCAGGGTCTGCGTCGGCGTGGCAGCGTGCTGTCGAGAAATGTT +TCTAAGGAGATCTTATTTGGTCTGAGAACCATGAATGATTATTTTGAGCACTTTTGATTC +TGGAGACTCCATTTGGATCAGGCATGGTCCTCCAAATTCAGGCTTCTGAAAGCCTGTACC +TCAGAGTAGGCTTGATGTTCCATAAAAGATGTGGTTATGAGTGCAAAGATGACTTGCCTG +TATTGTTATACAAATGTAAAATGTAACAATCAACAAAAATGTAGCAAAGTATGCATGTAT +ACATTTTCTCTAAAGATACAGTTTCTTTTTTGAAAAAATAAACACATTAGGCAGGTGTGA +TGGCGGGTGCCTGTTATCCCAGCTACTCCGGAGGCTAAGGCACGAGAATCTCTTGAACCT +GGGAGGTGGACAAATTGCAGTGAGCCAAGATTGCGCCACTATACTCCAGCCTGGGCAATA +GAGCGAGACTCAGTCTCAAAAAATAAATAAATAAATAAATAAATAAATAAATAAATAAAA +TAAACACTACCGGCCAGTGGCCATGGCTCGAGCCTATAATCCCAGCACTTTGGGAGGCCT +GAGCCAGGTGGAGTTCAGGCATTCAAGACCAGCTTGGGCAATATGACAAGACCCCTGTCT +CTACTAAAAATACAAAACAATAGCCGGCCGTGGTGGTGTGTGCCTGTAGTCAGCTGCTTG +GGAGGCTGAGGTGGGAGGATTGCTTGAGCCCTGAAGGTGGAAGTTGCAGTGAGCTGAGAT +AGTGCCATTGCACTCCAGCCTGGGTGACAGAGTGAGACCCTGTCTCAAAAAATAAAATAA +AATAAACACTCCTATAAAGGATCCTCTTAGCTCTTTTTCTAACACCTAATCTACATTTTC +ATATTCATTTCAGTTACCCTACAACTGTTCACTGAGCTGCTGTTGAATAGGGGAAATAAG +GCAGATAACTACTGCCATCTCCGCTGGAGGGACGATACAGACATTAATCTGGGCACTTTG +ATTACAGGCAATGAGAGCTGTGAGTGGGGAAAGCACAAGGTTGGCAGAAGCATTTAGGGG +GACACAGCCATTCTCACGGAGGGCAGAGGTCTAAAGCAAGAGCTGAATAAAAAGTAGGAA +CTGGCCTCGTGGAAAGGGGAAGGGTGATGGGACAGCCTGGTGGTTTGTAGCCCACTGGAA +GGAGTTCTGAAAACTGGTGGTCAGGTGAGAAGGAAAGCTGGGGAAGAGATGAGCACGTTC +GCCAGAGGGTAGCAGGGGCTCTCCGGACCTAGTGAGTCAAGCCAAGGAATTAAGGCTTCA +GCCTGCAGGGTGATGAATAGGGCTGTCTATTCCATTTCTTCCTTCTTTCTTTCTTTTCTT +TCTTTTTTTGAGACAGCGTCTCACTCTGTCACCCAGGCTGGAGTGCAGTGGCACGATCCT +GGCTCACTGCAACCTCTGCCTCCCTGATTCAAGCAATTCTCCTGCTTCAGCCTCCAGAAT +AGCCGGGATTACGGGTGCCTGCTACCACGCCTGGCTAATTTTGTATTTTTAGTAGAGGCG +AGGTTTCACCATGTTGGTCAGGCTGGTCTCGAACTCCTGACCTCAAGTGATCTGCCTACC +TCGGCCTCCCAAAGTGCTGGGATTACAGGTGTAAACCACCGTGCCTGGCCTGAAAATTTC +TAGTTTATGATACTTGCCAGCAGAATGTGTTCTGTCACCCTCTTCTGAATAGATATGGTT +GTCTGCTATGACTTCTCCCACTGCTGCCCTTCCCCCTGAATCCACAGATGCATTTCTTTT +AAAACTATGATCTTGTACACAATGGATGTAAATATTTAATCTTTCTATTTGTATGTTTTT +CCATGTTTCTTTTCTTTCTTTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTGGAGGTGGTG +TCTGCCTCTATTGCCCACAGGCTGGAGTGCACTGGTACAATCTCGGCTCACTGCACCCTC +CGCCTCCTAGGTTCAAGGGATTCTGCTGCCTGAGCCTCCTGAGTAGCTGGGACTACAGGT +GTGCACCACCACGCCCGGCTAGTTTTTATATTTTTAACAGAGACAGGGTTTCACCATATT +GGCCAGGCTGGTCTCGAACTCCTGACCTCGTGATCCTCTCACCTCGTCCTCCCAAAGTGC +TGGGATTACAGGCATGAGCCACCGTGCCCGGCCTCCATGTTTATTTTCTAGTTGCTTACT +TGTCCTTTTGTGTTTATCCTTGTTAACTACTACTGCCAGGCTTAAAGTATAGACCCCTAG +AGGGCAAGATTTGTATCTATATAAAATGTACTGCAAAACATCTACTTAAGCCTCACATTC +TTAAACACAAATTACTTTTGAAGATGACTGTTCTGTTTGTTTCCTTCCTGGTTTCTTCCT +TTAACTTTTCCACCAAACAGGTACATGATATACTTTACTGAAATAACTTATATAGCAATA +TGAATTTTTTTTTTGAGGCGGAGTTTCGCTCTTGTTGCCCAGGCTAGAGTGCAATGGCGT +GATCTTGGCTCACTGCAACCTCCGCCTCCTGGGTTCAAACAATTCTCCTGTCTCAGCCTC +CAGAATAGCGGGGATTACAGGCGCACACCACCATGCCAGGCTAATTTTTGTATTTTTAGT +AGAGACGGGGGTTCACCATGTTGGCCACGCTGGTCTCGAACTCCTGACCTCAGGTGATCC +GCCTGCCTTGGCCTCCCAAAGTGCTGGGACTACAGGCATGAGCCACCGTGCCCGGCAAAT +TTGAGGTGGAGGTTGCAGTGAGCTGAGATCGCATCACTGCACTCTAGCCTAGGTGACAGA +GCAAGACTGTCTCCCACTTCAGCCTCCCAAGTAGCTGGGACTACAAGCATGTGCCACCAG +ACCTGGTTAATTTTTTTTTTTTTTTTTTTTGAGACGGAGTCTCGCTCCATCACCCAGGCT +GGAGTGCAGTGGCGCGATCTCAGCTCACTGCAAGCTCCCCCTCCCGGGTACACGCCACTC +TCCTGCCTCAGCCTCCCGAGTAGCTGGGACTACAGGCACCTGCCAGCACGCCCGGCTAAC +TTTTTGCATTTTTAGTAGAGACAGGGTTTCACCGTGTTAGCCAGGATGGTCTCGATCTCC +TGACCTCATGATCCACCTGCCTTGGCCTCTCAAAGTGCTGGGATTATAGGCGTGAGCCAC +CGCGCCCAGCCAGGCCTGGTTAATTTTCTTTGGTATTTTTTTGTAGAGACGGAGGTCTCA +CTATGTTGCCCAGGCTGGTCTCGAACTCCTGAGCTCAAGTGATCCACCTGCCTTGGCCTT +CCAAAGTGCTAGGATTACAGGCATGAGCCACGGTGCCCAGCCTACAGTGCAACTTTAATA +ATAACAATATGAACACAAAAATTCTAAGATCTAAAATTTAAGCTTTCAGTAGTCCTTCTA +TAACTGTGAAAGTTTGGTTCCTAAAAAGCCCTGAGGAATTTATGGGAAAACAAGAGAGAC +AACATTTAGTAGTGAACCTGTGCATTCTAAATAAAGACAATATCAATGACGTGTTATAGG +TCTTCAATTAGTAAGAATGAATATTGGACTATGAATTTTTATTCACTGTCACTTGTTTGC +TAGATGCTTTGAGAATCTTCCTTGCCTATATTTTCCTGAGATGTTGGTTTTTCTTTGTCA +CAGATAACAATGCTCATTCCCTCCCCATTAAAAACTAAATATATATATATATATATATAT +GATTAAACGATTACTACATGTGCTTTGAAATATTCAAATATTTTAGACAGTAAAAGTCCC +TTGTAATTCAACCCTTTGCAGATGATTGGTTAACAGGTTAGTACACATCTACCTAAATTT +AAAATCCCATATTTAACATGTATACTTATTAGAAAGTACACATTCTAATATTTTTCTATT +GTATTTGGTACTATTTTCAGATGCTCCTGCCTTTTTCTTTCGTAATTTTGAAGGACCTCA +GCTCCCTGCCTCCTAGATTTTTGCTACTATGGTCTCAGAGCTGTGTAATTTGGATGACTG +AGATGGAAAAACCTCTGGAAAACCTTTATTTATGTTGAATAAGTATTCCTTGAATCCTTC +CTCAGCATCCTGGGTTATATTTGATTTGCTCTGCTCATGATAACTTCATGCCAAGGAGAC +TGCTATCAGTTCTCTTAAAACAGATCCCAACTCCCTGCTCATAGTGGCCAAAGGAATGGA +GATTTCAGGCTGAGTTTACTTACGTGCATCATCTTCATCTATCCAGAAGCATCCCTGCAC +AAAACCTCTGTTTCTACCCTTCCATTCACTCGGCTCACTTTTCTGCTCTTAGTACCCTTT +GTTTCTTGTGAACTCTCCAGCAGGAGTGACTTGCAATTTGTATCCACTGACACTTAAGTT +CTCGGAAGTGCTGGAGAAGTGTATGGAAGTAAATTATCCTGATGTATAATTTTGTGCATG +TGAAACTCACCGTGGAAGTGCCTATCTAATTTCAGTATGGAACACAGCTAAACATTTGGA +TCAATAATCCAGTTTTGAAACCACACTTCATTTAAAGTACAATGTGCTGAAAAAAATGAA +AAAAGGGTGCTTTCAAATTTGTACTTAGTAAACTTTCACTAGATCACATCATATGTTTAT +CACTAGTCATGTTGTATTTCTATGTGTAATCGCCAGGCACTTTTAATTTCTAGTTTGCAT +TTACCATGCCAGCCTCCTCCTCAATCCCAAATTTCCTTTGGTTATAAATTTAGTAAATTT +GAAAGAGCCAGCAGGGATTAAACCCTGAAGGTATTCAAATGACTATCTGACGTTATTCCT +CATTTCAGCCATTTCGAAAAATTATGCTTTCATTTAGAATAGGCTCTGGGAATCAAAGTG +TGTGTATTTTGCCCAAGTAGAAGACACAGTTTAAAGTTAACATCCTAGCTACTAGAAGGG +AAAGCAAACAACATCGCTGCAAAAGGAGCCTATTTTTTTTTTACCTTACACTAAAACTAC +ATTGTGAAGATCAAACGAAATCAAGATGAGAGTGTGCCTCTTAACGCCAGGTCCAAAGTA +GATGCTTATTAAATGATAGTTTACCCCAATCCTTCACAAATGGTTGATAGGTCTTACTAT +TTCCCCCCTATTCAAATCTAGAATTTTTTCACTCCCATATACTAATCGATAGTTAATGGA +AAGCACAGAATAGATCATCGTCCAAGTGTTAGGTATTAGCCTGAGGAATCCGGAATCCCA +TATTTGTAACTGTCCTTCTTGAGAAAGTGCATTTTTCAGGCGGATTCTAGCCCCATTTTT +CCTTTTACCATTTTTACATGTTATGAGAGGTGGCTTAGAAATACTTCGATTTTTGCCTCT +TCATCACAACACACTGAACGTTAAAATCAAGTGGTTGGGTTTTTATTGGCTTATTTTGTC +TCTAACCGTTTTATTTCTCGAGCTGTCATCGTTCTTTTCGTCTTACATCCTTATGAACCT +TTTCTGGATTAAAAAAATGACGTTATAATAAGGAAACTGTAACTGGCGTTGGATTAGAAC +GAAGTTGACTCCATTCCTTTTCCTCCCCGTAGTGTGGGCGATACGAGGAAAGACCTCGGC +AAGAACCAGCGAAGCCCCGGCTGCCCTCGCCCTGCGGGCGCACACTTGCTCCTCGCGCCG +GGCTGCGCCGGGCGCCCGCGCCGCCTCGGCGTGTGTCCGCGGCTCCCTCCCGCCCTCGCC +CGCAGTCCCCCGATCCCGATCCCGGATCTCTGGGTCCACAGCTTGGCTCCCTCCCGAGCC +GGAGCCGGAGCCGGAGCCGAAGTCGCGGCTGGGCCCGGCCGCCCCGTCACAGGGGGAGGG +AACCCATGGGGAGGGGGAGGGGCGGTGAGGTCAGCGGCGGCGGCGCGTCCGCGGGCGGCG +GGAGCTTCGCATGCGCGGAGCGAGGCCCGTGAGTGGCAGCGGCGGCGCGCGGGGGGCGGG +CGAGGGGCCGAGAGTGGGGGAGCGGGCGGGGGCCGTCGAGGAGGCGTTGTGTGGGCGCGA +CGGCTGCGAGTTGGGGAGGTCTGTGGTGCGGGTCGCCCCGGGGGATCCCCGGCGCGGGCC +TCGCGCGACGGCCACGGTCGCGCGGCGTGTGTGGGGGGTCCACGCACACCCGCAAAACTT +CCTCCTCCCCTGCTCCGGGAGAGCGAGCGAGCGTGTGTGAGAGCGAGTGTGAGGAGCGAG +CCGCGGCCCGACGCCCAGCGCCGCCGCTGGAGCAGCTGTCAAAACTTCGCCGCCGCCCGG +GCCCCGCGGCCCGCCCTCCCCGCGCCGGGCCCCTTTCTCTTCCTGCTGCGGGCGGCCCGG +GGGAGGGGCCGCGGGCGGAGACCCCGGAGGCCGGCGCCCCTCACGCCGCCCGCCCGCCCG +CTCCCCGCCCGGCCCCTGCGCGCGTGCGTGTCCTGCTCGCTCCATGTTGCCGCCTCTCCC +GGTACCTGCTGCTGCTCCCGGGGCTTCGGGAAATGCGAGAGTCTGAGCCGGGGAGGAGGA +ACCCGAGCAGCGGCGGCGGCGGCCGCGGCGGCGGGAGCCCCCCAAGAGGAGGACCGGGAT +CCATGTGTCTTTCCTGGTGACTAGGATGTCGTCGGAGGAGAACAAGTGCGTGGAGCAGCC +GCAGCCACCACCCCCCGAGGAGCCTGGAGCCCCGGCCCCGAGCCCCCCAGCCGCAGACAA +AAGACCTCGGGGCCGGCCTCGCAAGGCGCTTCCCCTTTCCAGAGAGCCAGAAAGAAGTAA +GTTGAGTGCGAGGGAGCCAGGCCGGGAGCCAGCGGCGGCGCCGGGCCGGAGCTGCCACCG +GGCGCCCGCCCCGCGGCCTCCACGCCTTGGCGCCCCCCGGCGGGATGGGGGCGGGGCGGG +CCCGCGGGCGGCGGCAGCTCCCGGCCCCGGCCCCACGCCCCTCGGTAGCCGCCCGCGCCC +GGCCTCCCCCGCTCCGCGCCGCCCGCCCGGGCTCCCGTCGGCGCCCGGCTTCGCACACTT +TACTTTTCAGTCGGGCCTTTTCAGTGGGTCTTCTCCGCGACTCTTCTTTTGGAGAAATTT +CTCGTAGCCGCGTCTTGGCCTAGCTGGATCATTGAGAAAACAAGCCCGGAGCGCGCGCAG +GTAGTCCCCGGACGGACTCCGAGCGAACCGCCGAGCCGTGGGCGCTCGGGAAACTCGGAG +CTGTCAAAACGCCCGGGCCAGGTGGTCTCGGGGCGCGGGCTGGGGGCGAGAAGAAAGCGG +CCGGGCGAGTGCAGCTTTTGTTTGTCAGCGACTCGTTCGTGGAACTTTTCCTGGTCCCAA +ACCTGTGTTTTCTTCTTTTGATGATATATTAGGAAGCCATTTGGCTTCTTCCTTCCCCCT +CCCCCAACACCCAGCACCGCACTCCCGGGCTCCGAAAGCACAAGTCCTGTGGGAACCCCC +AGCTTCGGGGAACGGCCTGCCTAAGTTTTGGAGACGTAGCCAGCGTCCCCTCGTAAGGCA +GAATACCAAGAGCACTTATTCAGAGAGAGTGCAGATGTAAATGTCGTTTCCCTCGTAAGT +CTTAGCTGTAAGGGGCTTGGGAATAGGGTCGCCTGCCTTTGACCGACCGTACTGTAGGGC +TGGACACCGGCTTATTAGAGGACCAGAAATGTCTTCTTACAGAACGGTTATTTGACGGCT +TTGCTTGTAAATTAAGACACCGTTTTAGTGCCAGCGAGCTGCTCGGCTTCTGTGGCTCTC +GCGTGTGCCGTGGAAGAACTGTGAATGTCTTTCGAAGTTGTAGAATGGCGTGTGTGCTTA +CTCATTTCATGAGATGATATTCTCATTGAACTGTCGGGAGTGGAAGGGTGCGCTGGGACG +TGAAGGAAGCCAGCACGTTTATGGATAGGCTGTTTCTTTGGTTCGGGTGCATTCACTTAG +TAATAGTGTTGTTTGGTGATTTGTAGTAAAAATAGTAGCGTGAACTGAGGCATAGCAGAG +CTGGGTTGTGGGAACCCATTAAGCTCTTGACTTGAATGTGCTCTTTTCTTGCCCCGCTGT +CCTTTTACTATGAAAATGATTCAGGGCCTTCAACTTGCCTCCATATTTTATTGCCAGCTC +TTACCTAGCTATGATAATCGTGAGGGAGGCAAGTACAGGATGTGTGTACGTTATTACATT +AGCTTCTTCGTGATACAAAGTTAGGACTTACTTATGCCACTTGCGTTGTAATACAATGGC +AAATATAAAATGCCCTTATTCTATATTAACTGAAATTTGGAGAAGGAAGTGGAGGTTTAA +GTAATTTTTAGACGTCTAAGCCACTTTTTTGCATCCTTTAAAGCAACTCAGGACAAGCCA +TATTGGGGGTTTTACCTTGATTGCCTCCCATTTCACTATTTGCAAAGCATTTCTTCATCT +CTTACTGAACATTAATTTGCAATTTTTTTTTTTAATTTGCATTTGAATTCTTACTCCAGA +AAGATTAGATCTGTGTTGTCACACCCCACACCCCATACTCCTGTAAGGGCGTGCTTGTGC +ACGCGCACACGCTCACACGCACGCGCACACTCGCACACACCCTACTTTTGAAATGAGCTC +ATTTGTATTAGTGCAGCTCCTGAGTGCACTGGACGATTAGGGTATTGCCACTTTATTATT +TTAATTCTTAATCTCATATTATGAAGAAATAGGTAGCCTTTGGAGAAGATAAAAAATTTC +TGCTGAATAACAGTATAATCTAACTATGAAACATCAAAACTTTTGGAAATATTTAGAACA +AATGTAAGTCTGTAGAGAGCTTTTTCTTTTAGATTTGAAAACTAGTACTGCTTTCTTTAT +AGGAAAGTAAAGTCTACTGGTAAATTTCACGGGTCTAAACTTTTTAGAGCTTTTTTTTGA +AATTGTGTCTTTTGAAGGGAGTGGAATCTCCAGTTGTTTTTAGAAACATGTAAATGGAAA +CTAACATATGAATTGGAAAGCAAAGAGAAAGTTTTTCAATTGTGTATCTCTATACTGTAT +AAGAATCCATGCAGAAAAGACCCTGTAGTTGGATAGTAAAGACCCTGAAGGTGAAACTTA +TGTGTAACCAGTGTAAATTAGGTTTGTAACCAGTGAAATTATGTGAAATTGCAAATAATT +CACCTGAGAAATGAAAATTAATCTTCTTTGCTAAATGCCATAGAGATATTTTAAGTTGCT +AATGTTACTTAGATGTTCATTAACTTAGTGAGTTACATTAAGTAGAGAAGATGCCTTTTT +TTTTTTTCTGTACGAAGTCTTGCTCTGTAGCCCAGTGTAGTGGTATGATCTCGGCTCACC +ACAACCTCCGCCTCCTGTATTCAAGCGACTCTCCTGCCTCAGCCTCCAGAGTAGCTGGGA +TTACAGGTGTGCACCATCGCACCTAGCTAATTTTTTGTATTTTTAGCAGAGACAGCATTT +CACCATGTTGGCCAGGCTGTTCTTGAACCCCCGACCTCAGGTAATCCACCCTCCTTAGCC +TCCCAAAGTGCCAGGATTACAGGCGTGAGCCACTGCACCCTGCTGAGAAGATGCCTTTTG +ACAATGAAGTGGATTTGTATATTTATCTTTGGCTTAAAAAAACATGCACCACCAATTACA +CTTTCCTCAAGTTTAAATTTTTAATAATTAGGAAAATAAAGCATTTTCTTGTCTTATAGT +GTTAGCTAGATTGTTTTTGTGTATTTTGTCATGAATAAAAAGCATAGCTATATAGTTACT +GCTTTTACATTAACTATAAATATCTTAAAATTTTACTACCTAAAATCAGGAAACTTGAAC +TGAAGCTACTAATCTTAGAGTTGGAAAAGTAAATACATAGAGGTTTCCTGTTGTACAAAT +GTCAAGTGGCACAGTGAAATTTACATTCATTTGAAAGTTTTCCTTAACTGTAAAAAGTAT +CAAATTACTTGATACTTTGGAGTAGTTCATCATCTTTATCAGAGGCACAGGTCTTAACCA +TTGGCAAGCCTCTGTCAGAATATGCACATATTAAAGATCTGATTATTTTTGTGTTAATGT +TAAAAAATTTTTCTGAAGCTTTTATCTTATTTTTTCCATCCTTACACCGTAAATTCACAT +TACCAAGTTGGGAAGCCAAAGAAACATTCTACTCTACTATGTTTCTTACCAGTTCATGAA +AGTTGATGTTAGAAATGGGTGTGGGTGTGGGGGATGGGGGTGGTTGTACAGAAGCAGCAG +GTGGTAGGGATAGGATTTCTGAAGCACTATCCTTGGCCTTTTTTGAGTAAACTCTTTATA +CCCTGAGCCACTTTCTTTTCAGAGGGCAATTGCTATTATTAGAGAGCCACCTTAAGCATT +ATTGTTGTAGAAAAATTAGGCACAACCAGTGATTGTCATTACAAGGACCAGCAAAAATGG +CTAGGTTGCTACTCTGTATTTGTAACGCCCTTCCCCCAACAAAATTTCTCCTTTTCATAT +CTGTGAATTAGAAATAAGTGATAGAAAACTGTACTGCATTACAATATATACCATTTAATA +AAACAAGTTTATAGTTGAGAGCACTATTCATGCTTTTTGAGATAATGCAAATTTGTAATT +TTTATGATAGCAATTCTTAATAATTTATTGTCCAAGAGATTTGATAAAATTTTTGATAGT +TATTGGTCTCTGGGACTCAATAGGCACTGAAATGTTTTAATTCAGTTGAAAAGTTGGTTC +AGGATTGCTACCCTCTCTTACCTGTTAGGAGGTTGTTGTTTAACCTGACCTGAAATTCCC +ATGAATAAGAACCTGTTTTTTTTTTTTTTTTCTTTGACAGAGTCTTGCTCTGTCGCCCAG +GCTGCAGTGCAGTGGTGCGATCTTGGCTCGCTGCAAGTTCCGCCTCCCAGGTTCAAGCGA +TTCTCCTGTCTCAGCCTCCCAAGTAGCTGGAGTAGCTGGGACTGCAGGCACGTACCACCA +TGCCTGACTAATTTTTGTATTTTTAGTAGAGACGGGGTTTCACCGTGTTAGCCAGGATGG +TCGCAATCTCTTGACCTCATGATCTGCCTGCCTTGGCCTCCCAAAGTGCTGGGATTACAG +GTGTGAGCCACCGCACCTGGCCCAGGGAATTTCTAATATTTGAGAAGATGTTATTTTTAG +TCTATTATACAAATTTATATATTGTTTACTAATATATAAATTTACATATTGGTTACTAAT +ATGTAAACACCAATTTACATATTGGTTACTAATATGTAAACTTGATAAACATGGATTTCC +ATGGAAATTTAAAAGTATCACAACAATTTGTTTTCCCATTCTGAAACTTGTGATTTATTA +CATTTTCCTACTATTTCAGTTAATTCCATAATGCCAGATTTGTTGTCAATTTGCCGAGTG +ACAAGCCACACTGCTTCCTCTCATTCCTCTATTCCGCAAAACTGCAAAGTTTCCCAGACC +ACAGTCAGGTTTCTCTGGGTTGTCCAACTCTGTAAACTTACAGAGTGGTTGTCCAACTCT +GTAAACTTACAGAGTGGTTGTCCAACTCTGTAAACTTACAGAGTGGTTGTCCAACTCTGT +AAACTTAAGTCACTTTAAGTTTATGACGGAGGGGCTTCGTGAAACTTCATTGACCTTCCA +AGGTGAAAATTGGTCAGTTTTCAGTTATAAAGGACATTAAGGATGGGTGTGGTGGCTGAT +ACATGTAATCCCAGCACTTTCGGGAGACTGAGTCAGGAGGATCACTTAATCCTCATTTAA +AAGGAGTTTGAGACCAGCCTGGGCAACAAAGTGAGGCCTTGTCTCTACAAAAAAATTAGC +TGGGTGTGGTGGTAGGCACTTGTAATCCCAACTACTCTGGAGACTGAGCTGAGAGAAGAT +TGTGTGAGGCTTGGAGGTTGAGGCTGCAGTGAACGGACATCACACCACTACACTCTAGTC +AGGTGACAGAGCAAGACTCTAAATAAATAGGAACATTAGATGGTCTCTCTGCACTCTTGC +CTGGTGGGGACGTGTTAGATACCCTCGTTAGGTTGTGATTTAGTTTTTAATCTGTGAGAT +GTTTGGGTCAAACAATTTTTAGCTGCCATGGAATAAACTTTCCAGTCAGCGTGTGAGTTT +GTGTTTGCCTTTACTTTTTTTTTTCTATATTGTTTTGGTCTATTTTTATCTTTTAATTTC +AGAAAGCTGATTAATCTCTTCCTTTTCTCTTTAAAAATTTTCTTTATCATGTTTGTGCTA +CAGTGGTTATTTTGAGAACTTGTTGGCAGGATAAGTTGCAAAAGTTATGAAGTAGAATAG +GGATGATTTCTGTTTTTGTTTTTTTTTTTTTCAGACAGAGTCTCACTCTCTTGCCTAGGC +TGGAGTGCAGTGGCGTGATCCTGGCTCACTGCAGCCGCCGCCCTCCGGATTCAAGTGATT +TGCCTGGCTCAGCCTCCCAAAAAGCTGGGATTACAGGTGCATGCCACCACACCCAGCTAA +TTTTTGTGTTTTTAGTAGAGATGGGTGTTCACCATGTTGGCCAGGCTGGTCTCAAACTCC +TGACCTCAGGTGATCTGCCTGCCTCCGCACTCCCAAAGTGCTGGGATTACAGACGTGAGC +CACCATGCCTGGCTGAGATTATTTCTTTTTTTATTATAGCCATTGCTTGTAGATATATGC +TGGTGGTTATCTGTAAAAATGTAATAGAAAGGCCGGGCACGGTGGCTCACACCGGTAATC +CCAGCACTTTGGGAGGCTGAGGTGGGCGGATCACAAGGTCAGGAGTGGGAGACCAGCCTG +GCCAATATGGTGAAACCCCGTCTCTACCAAAAATACAAAAATTAGCTGGGCATAGTGGCG +GGCACCTATAGTCCCAGTGACTCGGGAAGCTGAGGCAGGACAATCGCTTGAACCCAGGAG +GCAGAGGTTGCAGTGAGCTGAGATCGTGCTATTATTGCACACCAGCCTGGGCGACAGAGT +GAGACTCCGTCTCAAAAAGAAAAAAGTAATAGACCAATCTTGAATTTATAATTGGAAGTG +TTGATCCCTTTATTTGCAGAATTTATTTATTTGTGACGCAGCTGTTGCTACCTCGCCTTT +TCTTTTGTTGAGCTTAATCTCATGTCAAGTCATTCAACCAACTCAAAAGCGATGAAGACA +TTATTGAATCAACCTGAACTAAATCAGACCTAGGCTTCTTAAAATATACAGCTTAATGCT +TCCAAATGATTTAGAAAACTAAAAAACCTAGCTACGCTGTAGGACACACAGTGGCCAATA +ATACAGGACCCCCAAACTGGCCAGTGGACCACTGCAACCACTATTTACTTCCTCCGTGTT +TAGGAATGTTCAACGCTCCAAGCCCCATAGGCTGATTCAAGAAGATAAAGTGAGACTCAA +GGAATTTCGAAGTGGAACAATACACCAAAGCCTTAAACCTGAAATGACTCTCCTTTTCTG +GGGGGTGAGGGGGAAAGAAAAAGAAAAAGTTTCTAGGGCTCTCGGGGTGGCCTGGATGCC +AGGGTCCCAGAAGTGGCCTTTTCTAGCTCCTGTAACTAAACCTGGCGGAAAACTCCCCGC +CTGCTCACTCCACCCCCACCCGCCCAAGAATGCGTCTTCCCGTCTTCGGTGGCCCTACCC +AGAATCCCAAAATGTGGGTTCCAACCCGGGCCCTGAATGTCTTCTCAAATCCCCGGGACC +CAGGTTCCGGTGCGTGCCTTGCGTGCCGGGTCTTGCCCCTCGGGCGGTACCACCCAGGCA +GCCCTAAATCCAGCCTCCCGGGCCCCCAGCAGCGCCCTCCGCCCCTCCACTATCCGGTCC +GGCTCGAAGTCGGGGCCAAATCCAGAGACAAGAGGGCTGTGCCTGAAACTGAGCAGTTTC +ACCACTCGGCACTCCTGGCGGAAACTTCCCTTTAAAAAAAAGAAAAGAAAAGAAAAGCAA +CAGCACTTTTGGGCTAGCATTTCAATCCTTCCTGCCCTTTAGAGTTCCCAGTTCTGCTTC +CAGCTGGCTTTGGGTGTTCCACTAGAATTGAGTTGTAAAGATATTCTTTAAGTGTTTATA +GAACATTAAGACTTAAAAAAAATCTTTAAAATTAGAGGAGGGAAAAAGCCACCTTATCGC +ACACATCCAGGAAATGCAGCCCCGTGCATCCCTGCTCAGGGATGAGCAGGCGCCCCAGGA +CTCCCGGAGACAGATTTTTGGGCACCCGAGGGAGTCACCGGGCGCGTGTCGGGGTCCGCG +GTGAGGCCCAGCCCCTCCGGCGGTCCCTTAGACGCGCCCTCTGCCCGGCCGGTGTGGACC +GTCCCGGCCATTGTTTACGGGGGATGCCCGTCCAGACGCATTGTTTTGGCCGTTTCCAAC +TTGCCCCGGCCCTTTCCGGGGCATCGCGGGGGACCCTACACCGACGTCCCCCCTCCGCCC +GCGCCCCAAGGGCTGACTGGGCAAATTGGCAGATCCGCCCCGCGGGGCGACCCAACTTTT +CGGAACAGCCCCCCACCGCCCACCCCTGCAGATCCCCGGACCCCCGCTCCCGGCGGAGAT +TCAGGGAACCCCGCATCCCAAGCCCTTCTAAATCGTGCGGCCTGAGTGTGACGGCCAAGA +GCGGATGCAGCCCGGGATCGCCCGCACCTTCCCGTGGGCGG diff --git a/tests/data/dna/genome.fasta.fai b/tests/data/dna/genome.fasta.fai new file mode 100644 index 00000000..b542e338 --- /dev/null +++ b/tests/data/dna/genome.fasta.fai @@ -0,0 +1 @@ +chr22 40001 7 60 61 diff --git a/tests/data/dna/test.dna.bam b/tests/data/dna/test.dna.bam new file mode 100644 index 0000000000000000000000000000000000000000..b1f4af3afb84e7c7af2c84ad650df9e2e56d572f GIT binary patch literal 193636 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

~EN8QMD0~H{s=TQS8_( zvU^Pz|9Tg1M>>fHIt>2|ueKQxGtaw)$p>sVX-bLGdp2PtFvuEh8eL@uobKWX+uBS1 zVk8iQo0-c6|D5D@v)x%UH}Y>DKI?mqCbpA6?~DGCrj9`FFaMF}SH z!hggD)i|wqCgG<~n(NTDT#AEtXjGqaqF?T>i~B=S4+zz}2l6WnbSfLS%s*%+8qtcn z7;{gn#1zZc(*P$#TUvq^H^e=WSua`^lK)QcoR&knuabs(?XVTpVu;$>Z&jWgze~lJ z5pTr|#r>j~RLlSEO#-+Mc4$Ge-DB@1Db-$$eNio-USMab(WrlND-xo8kg1S{YUjWlTEMROFBs0r6K)a4 z{z}pQKT|bSTN&U2d4LYPG9#K=&z50}KLWZ<6va*+@KzF{9dL45odA~N;+Wi9c}bKK640aH^PziLYsjJOH}|4wPAo00Qd zK?UrU`aFv9DDG2l58^5ZV~l&R7_2z&XB7CsHm~~*c;OG{MnC9uptx@>*D60_SDj-3 zD6W<>+XF(}W3M^DxjER)&20E%0_5N{C#6i{>f;@2H08DS>Wk@t#dvm>u4jBZInO zpe!oWWv*-}P1MM&%r{@mCmq70p!riV(`;umoJz6Jv+2-BB>utOAc_a}10Dov*-;Mh zn{SkT795^qxEUqTH&Ec(-Ah@L6oxV~W6soT%J2#lRo%F>wH($M@)q=r2Wame_OM$E zmR+4J*K!$TAT{BIGTgrOJ>56gvI_RlpX_Bkj>TeG=+3XX39Fee^d*R2h=*$K1#}}< zH0n%S57O$zV*`e6HGTG(F)4;}ry=i&@<=OFZ6mXae_~=`C=t+4 v7Q`?;4DFsmb`M(i)RfnB()oGx z5ITWwgP|yqZ-tZxk~J;iD&sw(2Z#Qs$f7^!CGoqf+$4@|+z$&tpgo@3PA)Yf+Bo@$ z%0Qw@sJBuj=#q8CD>m=tvwUp(KBK&qTwu7vHa|BpcQ=SMXAq8OyoviFqegYK8PRRr zvwVqN#V<+x14#Z8__z;DcmPaTc%7}G?!m9H=}rnKpcBDA{(__W6zB;;pC9aC-;a_n zPC|MlBsdln5Rau6S)bf=WaN6R_1}KqVLUzjJQ{^bGXdM0_}Q_3vf3yivxLY|mbF=2 z93*o9Wqv67LctPy9c%xEwHpDgyn$Rn0M-JtKoIT$&ju2%F9u=2j6p#l5PC@ir92KG zJff3NUK^3#fL#7dF8StN4>lXv%T>SVlG8Kv8NWDP%{B!B^btT9u@~X_u6^w9nt|gB z@62}x+Gm`_Wb;@HwP_<~GnnT6V-|I-JM{FkOUwQccaf#OW@asCYm09)u(3hkqJP4_ zKBK>o%cpf8%9>`HY5r|Xar-(KjQR3yA>XW9+94AKD)3R@r~O9TR8|iYYSzK^{1V*A zNOS}S@B|V(5qFO$a+vfilap*Qzwo0d(DH_Pj1t}X^)^Ev+PL_JAzb-TyVWL_!beFK zHSm0RZWGsUTwETjUsWFtC77@4?~~ZUTDoqr=F4sLnd6=P#nv5+uomWQWYamgGVWl@ zwy=IvhSuI^!@Vw?v_sIf-y~@OAI?)L6+a?swK<09dg9elC03>1a@k(wXtjQWIK!4} zX7j-kr0)v_$#=oJ;cv3^EDQR^)+4ok%LDsT?}zt6e8K}Ys`(dbvSCG@g2WoGJ9C}s zxcz`yBX8@F&B0uLl0%Yz3n{ql!k-C(w%kT;Q@hxJ!;bj)1Q2I;kzkDWIr<5$-u)F3 zzpcn~Wb8fl>JNW)lbhFV#xp5=78TZ@z zglp%^Jr_lxT`O=&ZQY+&0Go_VbhLg^ipe`|VtBU%50fk@+&z6)H{6XjTTZ-io))hg z&h{7u5BS~jGWF{F%jv2=-;+Ta`e0TdWF9)?q)%*X@1?&{N#7O1$_s1ZfjKv~pSGPU z<(#?IYTSj8)(r80Vd56#qnTjoB-DQ@Emk`FPD?U#sBh*>L8=`3Lv-sSKnIEk-pr3O zIaK6KZqPJ>;QKjgN3ate@uGopJsY%b7hKpA@S!BvOp$5viz}!q+H8JHJ_bPLD?0;7(H+5WZASz zOsWk6#&bNCh2hDxrm2V$21{~1!bFSrS3aV7ZX>*E9f1YrXM_vfkmns zF+S&K(1o>!cHlo-LlDm|c??-mue}8oRXAJA1oJxQixm&W6k)K+Co8tsF~q*hs8l%t zuHk7GC@~V%?^T!FyDo~P{w_Qx5xPGGY3V`>MsT71YTQHO$@kqG+CN=xHQkyVvh^qVSgcOUr`bW~9Qr0lP$YjS5T?XoyICeL?4D{f zgwxSq{;->@hF1GZtqtQQd-9FmcTB?kquz|to6=Idet!j)aO<8Yb;JNmBr6t>EC{O? z6Y{w4-glg{-VBS+lcIo#hD{C>+ud&1a?DL%mid?I$ACn|n=BvGc-^RA&GiJ$7I;aF zj34|}>Wuq8dSGadYVqF(H&F8Ud5NVZ-4NR-)aDm+h(BWrJKU#_vT1c?yq_l=wbSmExNnlDu}FHcnd~N>?74?T~yWeg;mWPaYGyBg|>_IFFsYj^}mk<=Y}e zDj1f%E9S1#_1nfBjUR=D`eSY0CQu3(mMF!9x#i1!UV2KXy)?$?!2S#o^Ign zWkS1_f`Y4_4U|CA9s&k#rNyxIWDeM-0d;|Sa)z+9+~nY9b$`Gc=?Fj`PQWO^W`MwF zsK77S+#ZD;ca2@aGQX!`+}|;A{#zf%d{mknqcC&KfUy4u_RK#en`)U4vk8c#v{Oa? z_HG~CW8-6d@r<-;fuvBmJ2Or~$Ev$AoRWDUd#geS>ER6>E>P(;t6oI- z$5xFb{Nr4sJQ*GF;1}p;^)Yn=_t_W3NM|ctpb4mn%V_$<13ukS%m&`kWF2IJ%wss` zZDKfqo2CbG>uyZM_kc^htY*5woTSu=B6AvuPi@lL8P)EE`c+*ai^NS9G`WQfHndQ^ z0=46lnZ?qiiNfI8Oy*FIHS6$?V1J9Hvq@IZ$T!N;NjM$~$f1!lY3B4j(r~}V25y73 zkuk1qtY{NgEr5Mw@g!Uqg`mV1UX#L|`9fanBn(M(naPzFD)~xjecHEeqA*OQh}g%a z)dT2%8i75 zw4?`rQs+d#^N(UBhcX#gz8eq|k?;=_hsc@z&1T6mOo{71W;&Wc2I>UxfqX*!mthny zH=y5G3ccW^llBNbE453cqN0|%^((e62#vrT7LaO$Fb#rV;`t36-RQ5 zcc)3r`2Ya34(7Z{3wMcJBb=Ak2JTcfckntcuk&47_{ci}WUX=jEoT3+Ywm1d)NpSq zGdFz_&_$^-auew&Zf}rHQ=QBQCu%Q3BAleR15z~4of^IJ>!A8WB1hk zCRTjEXk`2B{K(50*kkAZ!sqh&eh0Ji54W5by zA<~SYoo8*F1)*!r7Y|*fpo_IB7k_CDvowh)P2dA@sG*LbUHQ)roZhq=nhBg$llkyj zE}q&BUQRMMHqb2BI(7psC@GT7A{&IsYrKj91i!L?p+wgd;^l%;nlPLx{(=Q?1!HZr z=;!f;-rRb4@&%<`8smrSj}<0qLKI22wD{6W79r_ES{_4|CGm#d!-I*Tb(6gt6()&d zi@6#E{G?l%L|7A>(6Tan!vDJFHxU5&N(4l17@poedT6E1pttdb{lY40_l6koLVv^p zN9&$i$wl)l91ai<3vY&tr9l3~wSS5&VN{f(uw7LIP((}`kp^-*O+ZuQ4ukSBl-EC+ zxhCWRbLHvjzjeAvTNW`*GCWLPGc&Llw9adPMt$XGtE3h77xZ8}ZC5F9SkFkn6ws&L zRjcr>g_iQL=$`1I@~NBkg4Htl;5oK+*M8z;y7qSIZaTjDbZyQezLg2?QUUt@DaqHq zV{^YheEY{v{Ve9+vJ&QxBQr#XjL)s>6!z+pG5T-w-y2bIl7W}?Yxe(#VAQUd-?s_& z5f52viP1cp;=GxqZn7%X*-9iRU{U15a3w}~;W^(V3NHwO{PELE z*@g`)+Ox}|rbBeiF*!Z;K!a?znGUW}`_y=P54MhcA`aga%zABsy_L@6NbM^WvQqW3kZJe@+GUSN@-yrOTMQli8uZ_?+j1kH`QMBoJsLc>wyQto zSR1<$A&M<&9vDkkzogQw!rr(w!NA<=T}YuHCj+0ty&#|G2?RwQu&Yi^n)jm^7T14& zg;7ne(gju9=JJ zwIfdLm@0#^YOX%__x6?p$quv0SQ_h(!sN6f#?loKh0s(h3Vk9aRa7Xum=SUDTlZ|T z5h-yf^-|&m$qF6-0wzHTaH+V4I9)P~wF+*zg=OYmC$|}W?%rnZvt_DPK3%I#ryF%$ zzY@RS?yoFEG`l_V@%J1$3A$pbV=?(NzP~#`VY}YTWXD!9*?O{)Bk390Q_UE0viRD( zZnV~zQJN%O?WPUexw$s-^w{bGzOr7LD~oMsw>@h3Bz8Jo~rhv9Kn?1hJSd$iZk|su6l7wG)MxZiefbzUmP8xTRI1onA+Q z1xBIMg-hzIC~bQH@hf^nlygQg}dH-1ZT*hV32Lmzz4 zPXa+k_zRO@7|mk%3+6`F8rs?{HxEBc?H?F(z9JQ!-5@%qe9n zw!(`eZ!!p`mq*BjA!e6!_!I{$LJc?zmyjYr$8}59e3zxvJndiuw4B;iM=V#8sBezE z!&CXBT5t$Lp&5Yb?Mn-?pFB}Za*DaouU7uKW^tC)6*hwp073z5610;8TpX~&GBEOM zVFeX9SPl#shCtaizB2TE3?WRn-^@WY?TYJ>a+lWUS5uU$FTZ}AWe9Fg`n8E(NsD^d zGPY*Ea+w3jhuI4dc3^J7G%7%nStP+0A`4akR%(OTejQLOJ)Qz!i6aBSlsJUG2P3q> zhjji)-qs$aHDutLpWE< zisn6@?Uj+4hh0Q>ZZKe{C7D&Mb9!i}6JKW%Rxf|IcV8GcNc)=CHG$aHJI)%|A&bcQv=__G9h}4%Iv2v?z~=8Zub?qxy6GzNB+*5H4#@P zswmyAfa;=NcV>^s67v0WD9E*YX}7iP5~lY#z4Q#NJt9hYHi!*mMbdL?E_i)>7xwra6T%rViP)1{BhCk(!2igqkd&-g7t239^0RN3ht24yM&QX|aQcBac%EfTj`p10< zLY{F8Y;p@th{faTCHzR3PK6y;3&0|QOY8v4V)TgrGi}WH9z)(wAC1qK!@%zs1SI(O zPl{&BU?|C?uYQJc>LBAvuOOrPXcY0WCtH4?REIsIy-A8rJ*|V7e-PClR!*(T-AR^K zi$f|;uS(!5p2X{Q-bBbvr)+3_`!EIL|I<0uhFY);rx665usG?Z1*k7!vnvoAUouVw zW75o5mC{pTl&>0YXrjfe6iH0ViPhQk zVOPJ~qyV|CnN*n8;mQD8Y~(DzTtK@OS71K)0RyyTHtvNGN$n=D;xjN}OOEg#j`)+B zXO#Fl%(pAtQ|pCO>WRb9MRsdj-i0CqNAV--^bEEF- z>(n_%SMN8~=s5Mp9;^Z8$2@}VCNbe*>7fB88Hh=3hE(BMH|k3q_(WLB4ee%SEDpOB z8EwF=3<@k8321^+tUM64=??xjp_`HNg>ZK8sQgI_JN2g!MQq#CvWQcKdNn}pl?K3L z)~JL#H)|Z11R|grz67Q1R1X-%H}B#-=VaBL=o5G{NNJ>H5Dzft(N{wVeafXeo*qVf-WiVy^7Ps3%1M~P^Kd#J~ z9`8T0Ba0dRR12$4m0{)?7gx6>-#)gzro{a^^kB}az>T`3uI5W4lxOW_5-9kT)JCd+ z+cIcF_1V04TWtKMzDIeQh`@JBIVdC{Uh?5{fBl=d^2w zLRT06HXT5T9J(6yo}ZB-l&)J?QYG3&pHPMF)v+jHFV?+U-0(+O3w0=D*Ohb#`v#}K zWx(C4Y@UyQYN!BS1GQ|i-kfzIHI1@liB>Q4lJQ05!i5F%(qN@p@w<_aR2tXYlY)sc zT-yV6PZjB6WySA2X3Wx@k0#j_isWQ{L>()Kz z?k&-Y)}-6-eYSkkXEUqeN)PlA#hsN*50m5|@E&Wg<=lfVXWog++k_vuIihHmRb-Q9 zD(Cuimd?Zdcg}dbP$`pMwW(Fl+3BC? zEec0UnpAnV2xq?4@|7gm9QhDg^&1qXa+a+}Oqh(AzfD#Go2&u{IL1&m&FZg5l=!oT z>2KIH`G=HOde#Fc-~FmeOQhqE%9ncLK4;|8I%)5Q+~bAy{=WIQIeTN^7bUNi4aDf2 zOFp5`xKbdua${ko(TbH+rG+|KO8)8M#w&ok?&PH0@KmNmTa5;#CIRw|MuR#Gk;xAI z<`T!s5f(cIE!6@NpTy6;YcO5d+#;udhaQ&Nl~qBc@(CQpM7d4Ep^Jjn-tn1 z`L_xCHJ#Bykn*#gob+(3I9A2HDhY5$TIgxbbj>tH1v~=5FFoZY*oD@XmyDwd3cPIu z-|(TL3QE24WW~4vmw#Ps)6x#W${X~A8>?x}x7R@~OD(p2A2|uYl8<2(Tp?yN-1B2u5mmJgT6~M^hw4tyI}|R|w-m+!MbF0>lGs7;+<{fYFP0 z@J$}Uaafa$92!a;cm>d)F(VDNAAs%9@=s`>y%6(mv>Pakr=H01nXXKy!GoKY-a~g7E)IdE}KAv z(UVsJYFkkJ^~rFjX@}Jd!A(Y~nuwp|4*OMC{n0M9v|(+~B^bF=@rujK0?*VKuNU}? z0w||oteasC+XxCIe-(QVdp_bPBY7Jx-PX5aDm3;9{O$Nh1n?czZ1FA(QBCyqifh8I z2BLcx(Qlz-JGV^=({sG5YHee%VJFep3&!<1tE#!ioJ$Uyn zDxj61n@ELYju*0H;6Zue<8o;h)~A1arpgBf+02}eAF-v#r{C`35vNbuPEqdBxdKPa z4*BWWd0}TEt(9|T(2p+oMWeqCVJ?7=n0d`Kn6S z7b54@JUAU!T}QBR38h!GC*FWU$8#L2HNmhnOPyad`?6i7O0=PwjDHoU<*o0!ZwF=l zzDP%S!4(VN&lH2po6PIz%*dQJN+TyXMPvGh3Sn~-u43NzDnwt|%*oI-qb6+}%X;6w zCYmG1sQZ@4LS*Wb(MY5GA_kh-?3KFA^q@`uS~OHqk=eXyq>+%&uH$#JX{B0!70Ypk z3yssp`k-|?ZMz35d1Xv)@D)O%ts$-P?S(rt{dg5r=}sAGZZ1`&Z-gb0R)WJrUa+IP z%Cgk8`{SEZM~HmVx%d8VBU?@PV?dicSQS%3Aet#D**=D~&A+LCU0$rk$GC#in;`n? z#o6P3&s$Zwh_?UjW&0<@>RsqZty<`wNzK_UG__%yFLULjDzs;mOrCXcVU;dA)T}CEYYlldJ5t>J7UXp z!rP#cnbN{1@t>9Hda<8v;`F^7ph{%k!Z13jTX@`!2K72?umig_RXR(jDCFhz+Xj)H z$D93`c%hslNlucd1L<>=ng!}FJzP7Kv~N%Pm!PudC>X^W33hjB;zGdXk1wfKV0(8XtHhe0k{tF^lJ z6S6>$9B@k4Az3UUH-rj?sC+bi1cIR=R|z*mAR;>jU-N(lHP1imXk4YVlmDV?)cp3A z79xN)jipqq)@RfpI7?FwLvoV3Hz?n7Cm%=WY0ST_93fHVt#=3Aho2S*b4*sYKY~&u z6;~*CdhjTOt970ue`7ukuH>IyeoP@es- zvNwTEK7f`{-oL{6tt3>~?YLey?)0eU>=^b7WctM6h`cw|ih>Qv0^|vz*1iW@?2;=& z?~5rKZ&h5kV8|?O>|9^Z8$Ol8It9mB~Y!(pG11CzvI*`BZ5q zfov`C%92(uM_gu-CVjeVQ1i0MqCQcr*$QqnD4oTqiQJ=ag(A~pqR4Ygk}u^vn-pEd z(!*PzLgA3cu7@U!Bz0UKYzmnrrHMRHNAig2S9wj2z+@3^>Nq#Zg=6YCOE{s0+F0Je znA;m;O7UnljTzgYaq_>8^5X1{SCLc9_nR2sjwJJFEzU*#UcSn!+Ep3^x7lKjQ*z=P zfg!^g495>lM5)%0BR-@cYR||=fPsT|Iwyt6^)YV~>X7mdEq48mm>#${cCzYq@AtPO zh9axJVg_N%&-MkHNa}N5cf7T3-|Ko_n2k+N1$L;{X32;u?gc9jd3aOqACdA*L7X&v zr$juRY#So*$S!_ucz`_75laYgJz}T=2SXf%44P?us3TDKbGz+X;p;0&Qq0>zO!%^w zt*r9wv>n^%!%)IgU#rfy$^fm)K5;%UPyTZ~EdAc~niWsg>nkuE#s)Ik5H+c=WCCZ# zB)nx`yy1aS^nue10q@dCb%qazjSH1wi;3=QqjB4gYD#we_=m{nDg-OLs!Lt@e zIfC!-j;dq$S{Nd?c-$`rJt;cHcR7hyJM^urT{xtcijhC`ECJ%lj&x^5ztSs5XHcXQ zS05UXP@mCTpXXOHyaLL0AQl8ZdWN4}bD^38+b#t(5=Y^zl?w;(?eZR5@K*MJIvzF# z`e(5FLRnR=*iIy+h4?bF`PxB9+QbqXs|U9I13PS5X6)VSz+ccoV!)xfXG$gO6LcvV zJMH3+wvU;;N_1cwjbVIUhrqNlW%P6)PcW%?u_ouva?oJDB$=O|=c2c*Dz0j8{kC=Z z2t^ZiJ5_YErE}8Jr8cXa9bFf$>*nx-i!Ed>XhV~Mr z(jCM?08s(@wTxIASy}tq;1`ST*Y8f3dnGYqXV89)BTQ^5gdUY(P9uo4nn? z6Bjd0nZiza`ShI2M zc=8M_#)RGIN&XE#e;NyvI1fdv!e#P+-?Junq-1g9Cn(1s60sA3ZSDXiVx)5anQyPJ ziwgK_F?oZ0{L!fJE#&DhB()jV3DLCSu#S^4Sr2o(DZ+#>=6qKjde3H&s>E*(yCot% zP&~oJE|7*HPI9Rxau7*UDF=i2BC=7^NfnH5a3T@+yA)mkB|-^AthooT^&MYB-lal7 zx8>ig0#+0&1#;0-e*OV;zB4-^SEy*XUd9`0O-C)V@OI5H(M4C_ja9>H7fgDXykwJK8?Ar?`$%HDDewCxiPvEI!yCHw z;2P?&Zz1)P{16~{IoUNcVQ!+rvT)mkApJxZ;lIo2H(Or;>i2Jn#xP&uec+y2WE`YxynP7q|D*Vure* z!Ij0SIF|eKaZs~4<9q*1@7<`q8?4P;bWF!fVl8098yx`c*laSC zDnsMyfxjM+bau<>Th^swrG9)ytHG2^?reqtM2@D{$L)nXks6 z)8rN)S@=6_n{@2&naZ6VdoTt!FT&4L)?7sGzstYsBC{4Y^OKy&3?(ph&9I87$%^jg zfMwVDZ=aF7=U#z)44q&WGT+QoHIE5B=h0{CgbfO+smiIYs{v*Sl=H=rnyxEJE_FS% zHfn2w&A|^rw+5M-t9(a7o2j2ZPV0e4^CL+W`l)JQp=4X}O$;)twMl-pi=qN#G(uQkMo zun+yX>ja>!^~RjhnEo@HHpRE{y?m2#r?+)bt?O#v4Y1C9#|c8zYV5JoA$>bSb}`k8 zB1$L*{?c~?uS=eMz!tNc^a(yucA_J^?OCvPOIM)8YmlA*eLo*}ACeglrDiS`61(B1 zJ{BGIidzO0wG{qYb=vSNYg?vmOnPwp7r!g~N*LCVUgT?F3tpX=Cg#@5Msw|AiKNNU zKz5jM(q6Pan&*N%gjOIFZRh_5=bkg*SSpm2r=a%$k`0D`T$FpIG^0#*y8{vAoHO^h zEXWfyPVAFnNTf}__k>_s`UUcGloHrB-pfv(08zQjZkFIM`V`PEw~u7leTS$YNs>}9 z>r5~114h&hDmH>LQ}q8K$ae@1b>I@UMboN+GJ<3r6YdKg%si)WOkZ0v1#PQ8GOu)X zPe~8_Nd>NGgu*19g}_;ee(apIS(;`R6i`F9hS520q!Q`-=?g3$mqWifZtAS^ddzGN z_Gbf421Vm_)R&g6H_P9i|6c2D0tt8}O<_|{tFCqzo>V`+zP@=NU=C>qPj^^c*B$%A zG)cjlhr)43*Wnsiv-Qh|RDqC-MUZj5(Z}j%Evr%G(vE|2R5c6uU z|7hN4hvO{5aY^qs<_-m|7F#u0#ihq);v^75tibvcZfPD^|nW zgaCJYzm}?bvyyoZf?)j!MDEq->8S;hW;hQ}jNN>+ZOrDye$?UxLSj5ry5dmpq<~gf z6LIn=GWXC#wVtt^WybkSIbe3tnvHf)|9cjSAysrMD+b?4Exgk9zQ%%KmUKR>r^Bzo zYxs6Qvn4-t#|ph=!tIOqnCU^E(SgUQFP!U{5r> z)(Z!wv8Fu|89TdSd=4;w)y?0-G2YSF7avazq}#&C`hwm|RTMX4bncgw%T~`6`>xv@ z`GPDp2eeiCJ#c9p!v7k)VNsDNO6?hS?+*uAk3gV6yl6AQWRM0tLZ2iA^-R!Rx2OE- z;lcO^ns1|PjtW3H0_BQr9KEUSRX2gU-QVAbx`Q2L&$QMpDsA(-elVD!0l~hvr4*XS z`b9#}SSs{}75oUq7?KBF6{7TbpDrcuSB~(bsB_1>@8sZF$Z+o z1-Y~xI;xq;Jw76bu1jB)PvhnQ$!Q$J;5M}V5YNmox;c2r7r}Xma@ozU?GZ)ahqqJ< zDb=;aY7s32Z{>i?FU{iySMY!V4zyhTD(ZX`e#%!S`!3oX+5wJf6_#%~tI;Ek zgZI@1+v`Unvb2+2H6>XEGIK9;#Mkwz8mX*TGGhfx8KGU3Ek2Klpql`@fB9Z8gJjI0 zAysff8g4EJ&jZruKa)E?d?nVqUA`Uem0n+EoRq)3un`gCbFVV>R}#X5gD!I9M~L?0 z7-U&($J_ukUuRhtFud{4V#HQRW zjA2Hxtgih>l2%pDQyy?_)7blPGXKAv<`Bs*MhNC*reL~Y`k#qRh`U)#i)6twM?Be=YMX67o z9T4`nx&$bq6LM{FpNmm6MAvy4`54o%ey5}r_S)no;b|pI+fKk+e+YbyhN~@g^*)7CKDNqWY>=;7+b0Qv<@9P607Eh*d zFSqk{^h;DX=nWiG)JkwulObZPP{IaCBD#u=SwiGNT+f(6pe?=~D=Q;s3^f!|2bPu> zE$7CU(p)r7Gc~Z44j8cC?5#nM`~igAEpS$P^#M@dE%0WR+5gG!il@|T-=`XWe_|xtp z85@zH*}$lIK|jcgEE1Wid=$EjjEr=<7L$?hRK=0AOQ=9@WcM%#J_v^B8W7Y0_Jc_# zTS^T2!wk4fcAY2IBaAtkD!9re_JuS!Z%-%}ACXn}qVXx@T*hGu1$M}M(yP$i(!6|k<4v$V_D{2CX zu|bgEW~C7pt0+4pUKAZ`t-_~t?j)>+XI+PB{b&R3Nn3>O3i-y(z*LLtG&@GZ34d?4s!%#4YR3+neDI7?Rp{HIoAdxy} zOq!VRhwx@S3}(DD5C%2(rdsi^O0G{#sxO}b=D<{-Z_+OaR-^G38%&_lyIcOY&r_B! zV8%I?rcKYu7pmk-+iPoA@C)qvYnQ-{I0Sk>aC4!X?ZQp=uNHa0wB2IizKmg({ zJ`s9Ta@lFBcJ|9W0hq;{H{a)r*{U70Sbu#AUwrkGuT)285>GINuNsH3vQ=Fed!e6K=Hq5#SitbZ5C0elf`!qmF>_2M41& zeJ*iPxa9I-n1tw!Zyq0G>%R(t2?9|UyqJh~#6vEOyE7cAuGDKgD=Bc|2W**x z+uGNNBFqU&Sl;jxbjUKUP|xYo-7Nm+M9^jSr0GJ2t{EFrK7$ zj1T|9P_`N*hHp_g{KKc4Eo>kr-QYec6cm&JfMEwgu{0jcUm=ArOgUQ0b1SnA5e&|m z2G?@vfH4W6=j3arnXwg*7ddot&NU|h0F>1l!rRnTXYmEmDrtYM6#(`tlp2!B{a1}d zaV%!%!T?TW<>s`4vp$$TnIsWQOUB?6$p1C=NGBj5B>J}nF@qRP2kmlNQPc`1;!&=r z2lu;rt~nA23JR5G;IS*F&IaZy!tbJv<|LI_&47GwdVNDWYE7+#+CSy_vO4uHbhAGz zUHf}J^f~ppOV<&deF8Tbe%S1CWgK&sI#Up@>`%5avt^kWp6wy5g`~J-p2uJD+vqqAze1=|{vL+tne}fA!#ZBh1+2u1a(fspZKh3N3He~6pwv&)1S3T^!Ky0zRv|G1wHO{q0A}gM_`K|3 zA8@M5UnC0)w7m=bKLxV1if!SFKYaxHGZ}!_BFA7O$e&OH{TGW&+_P9ZXU@12TorwP zgx8Cz>4v<`h*G-L`Db6O^br7!E+hg7V0ytC!W%FGGxRnv9E4yLiM3|1-J5nToKFk| z)85x)oqm6-+qC)@sTr{Yp10+MS;?^TlSw&CVOcu7$xOkT^ueA$OX30$T4tvNHrj|{ zYzq4OJMU+5#A^KM3)uAG*Z?F#OgIeFXiT;56-G~-8VpWosR5K1J88WHz40JluDV$s z$JZh@J;Y4dC~E-ono}d1d&{j7xqgnKT`jr;c~EtGh|+e@7SJ|wp`tiTZ5iLBb*(ynGGk(qPZkg8IH0Qm445iK)}|0dU#NYYQdJPZWzbBZ z<4a#D2c8Zyvn%SeZWQpku@qzfF9&Jpp=^}nOep8fZjdjME|C)FOX#FFxuKGff8 z*}v@^@Q<7m6Ui;z3Js5fm(PVQr4?~-ih>y_Z$&&D+_Tb+N*^iZ-vd1C%9q+OZX+4T znY_PSQgT6zXR^SeBELVErL_IR6Nt70ZeyBh++>7Z0;i>x{Q?0Phgow$6S%lHQcqS_ zf!u=&7*PBJ-?F9~77PRTK^(-swLo_tKp@kr@gj5b!VzqkGmURMYiV7oY78fz-T&&j zps_9z!0%trHg{#r=_6`(ss9xdd55V$L6U0=4qlF;A)A%{y3uE&pfuCB?TMJ-kJF3G zHq0jSB$+K~~tf zh+3zcu(>0#&4C&v8IE&4F*0OhV2wm&oT<~;lhOS`qjl{Y$K?nq6mCXUxD@HJ!Ud&E zK+ScQ8jL-AC72^zswELpu`Z)MfpI-kzN##My@K0j`?_xT1>JjVJO$5tEf9_5U zM1zL1gTU=Tvsa*i!Wca48AUV)x<87&7F~wR6ftSZdQqq2^{AtEcjHDnC;vFbhg9S< zEtGC76>HQ#>N;^tTv2fv? zx__YRMSw5qsvZn`Ai=w*CJ1N8vdfyNHo0r{ZRO%7M{@}nge=R{s!(P-oWe{b$8scu z!vn{YdZPc!U7s3YKNA7-o2rPK${?ZB55MT8YUd*pl*(a8lp<+FEbbxMysjD`&LR_O zlk5??mB8qtr+&R4pK_Mqo#T}!+2n6|CIH8_XD(fb&-V@FIn>TCWXaN5-0~u!=TEjf z_#pZ6&_z1B-Lh?CZ{9`fT8Yvr8mRQnrJr#|B2K^RWs_YtHsaNl8kpF!si-=g$|La> z3=340?d!Ei-q*+5`F`yHF&|N`S=?zaK$Ze^cljp+$j+2KRZ+a7d*M*SlsVLQm;!As zn9wIafdM{&AkP%(j(5xjX%o27$Z(rVvG$<@_@E#h5Eyn)lrd{%4sw_hm!`y!nHqxs zM1jay(HeUQmRZ{ie+7l^(-^EP$P@I?Kq}!zL3TjcM=ZkkKtTO?7t1~t4i||X4Br-# zr{$?-U^1&e1JAQ|zGu5H(qZA+;pK)&24W=u_L)UNEM7&Rs?sDRq8KJs@Yq{NwMQqC z5ltV43P82BS`ONWyOJIRrVUZ*2=JiW;*&|tpXwdwA_rIt0ybbcBuD`=_P`n~u8Fsr zbxW0!a8pu{8371CT4=xT)8!};Py+#C50z_R3j>f&xQUAZq^7Mu@4EL^@p$2*0h$N_ ze9&$Z!ho>Zi5}O=gzd0Venkwx_$gCQiOvcV2(2@IDL0aNo`K^YybxDa6fR4kdt}

Kj;y0N&(0a#v+26sMZKIa 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%0A^|7F6muqrJg7FS3 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 N0{{R3000000086S215V< literal 0 HcmV?d00001 diff --git a/tests/data/dna/test.dna.bam.bai b/tests/data/dna/test.dna.bam.bai new file mode 100644 index 0000000000000000000000000000000000000000..a21f9c86e15c88ed3fd78a142d0739a67f89dc98 GIT binary patch literal 96 zcmZ>A^kigYU|?VZVoxCk1`wNpVH23rx+iuKB=5) diff --git a/tests/expected/dna/test.idxstats.txt b/tests/expected/dna/test.idxstats.txt new file mode 100644 index 00000000..f56aa9fe --- /dev/null +++ b/tests/expected/dna/test.idxstats.txt @@ -0,0 +1,2 @@ +chr22 40001 5642 0 +* 0 0 2 diff --git a/tests/expected/dna/test.mosdepth.global.dist.txt b/tests/expected/dna/test.mosdepth.global.dist.txt new file mode 100644 index 00000000..2299da21 --- /dev/null +++ b/tests/expected/dna/test.mosdepth.global.dist.txt @@ -0,0 +1,1094 @@ +chr22 866 0.00 +chr22 865 0.00 +chr22 863 0.00 +chr22 862 0.00 +chr22 860 0.00 +chr22 859 0.00 +chr22 858 0.00 +chr22 857 0.00 +chr22 854 0.00 +chr22 851 0.00 +chr22 848 0.00 +chr22 846 0.00 +chr22 842 0.00 +chr22 840 0.00 +chr22 837 0.00 +chr22 833 0.00 +chr22 832 0.00 +chr22 831 0.00 +chr22 830 0.00 +chr22 827 0.00 +chr22 825 0.00 +chr22 822 0.00 +chr22 817 0.00 +chr22 816 0.00 +chr22 814 0.00 +chr22 812 0.00 +chr22 811 0.00 +chr22 808 0.00 +chr22 802 0.00 +chr22 801 0.00 +chr22 799 0.00 +chr22 798 0.00 +chr22 795 0.00 +chr22 792 0.00 +chr22 790 0.00 +chr22 788 0.00 +chr22 786 0.00 +chr22 784 0.00 +chr22 779 0.00 +chr22 777 0.00 +chr22 775 0.00 +chr22 773 0.00 +chr22 768 0.00 +chr22 764 0.00 +chr22 761 0.00 +chr22 757 0.00 +chr22 754 0.00 +chr22 750 0.00 +chr22 747 0.00 +chr22 745 0.00 +chr22 743 0.00 +chr22 738 0.00 +chr22 734 0.00 +chr22 733 0.00 +chr22 726 0.00 +chr22 724 0.00 +chr22 720 0.00 +chr22 716 0.00 +chr22 711 0.00 +chr22 706 0.00 +chr22 702 0.00 +chr22 695 0.00 +chr22 693 0.00 +chr22 690 0.00 +chr22 689 0.00 +chr22 684 0.00 +chr22 683 0.00 +chr22 677 0.00 +chr22 675 0.00 +chr22 668 0.00 +chr22 667 0.00 +chr22 666 0.00 +chr22 662 0.00 +chr22 661 0.00 +chr22 659 0.00 +chr22 658 0.00 +chr22 657 0.00 +chr22 656 0.00 +chr22 654 0.00 +chr22 652 0.00 +chr22 651 0.00 +chr22 649 0.00 +chr22 647 0.00 +chr22 645 0.00 +chr22 644 0.00 +chr22 641 0.00 +chr22 640 0.00 +chr22 639 0.00 +chr22 638 0.00 +chr22 637 0.00 +chr22 636 0.00 +chr22 635 0.00 +chr22 634 0.00 +chr22 633 0.00 +chr22 631 0.00 +chr22 628 0.00 +chr22 627 0.00 +chr22 626 0.00 +chr22 624 0.00 +chr22 623 0.00 +chr22 622 0.00 +chr22 618 0.00 +chr22 617 0.00 +chr22 616 0.00 +chr22 614 0.00 +chr22 613 0.00 +chr22 612 0.00 +chr22 611 0.00 +chr22 609 0.00 +chr22 608 0.00 +chr22 605 0.00 +chr22 604 0.00 +chr22 603 0.00 +chr22 602 0.00 +chr22 601 0.00 +chr22 600 0.00 +chr22 598 0.00 +chr22 596 0.00 +chr22 595 0.00 +chr22 594 0.00 +chr22 592 0.00 +chr22 590 0.00 +chr22 589 0.00 +chr22 588 0.00 +chr22 587 0.00 +chr22 583 0.00 +chr22 582 0.00 +chr22 579 0.00 +chr22 577 0.00 +chr22 576 0.00 +chr22 575 0.00 +chr22 574 0.00 +chr22 571 0.00 +chr22 565 0.00 +chr22 562 0.00 +chr22 561 0.00 +chr22 557 0.00 +chr22 555 0.00 +chr22 554 0.00 +chr22 552 0.00 +chr22 550 0.00 +chr22 549 0.00 +chr22 547 0.00 +chr22 545 0.00 +chr22 540 0.00 +chr22 539 0.00 +chr22 536 0.00 +chr22 532 0.00 +chr22 531 0.00 +chr22 527 0.00 +chr22 526 0.01 +chr22 520 0.01 +chr22 518 0.01 +chr22 517 0.01 +chr22 516 0.01 +chr22 514 0.01 +chr22 512 0.01 +chr22 506 0.01 +chr22 505 0.01 +chr22 503 0.01 +chr22 500 0.01 +chr22 499 0.01 +chr22 496 0.01 +chr22 494 0.01 +chr22 491 0.01 +chr22 490 0.01 +chr22 489 0.01 +chr22 488 0.01 +chr22 485 0.01 +chr22 483 0.01 +chr22 482 0.01 +chr22 481 0.01 +chr22 477 0.01 +chr22 474 0.01 +chr22 472 0.01 +chr22 469 0.01 +chr22 468 0.01 +chr22 466 0.01 +chr22 461 0.01 +chr22 460 0.01 +chr22 457 0.01 +chr22 455 0.01 +chr22 453 0.01 +chr22 451 0.01 +chr22 448 0.01 +chr22 445 0.01 +chr22 444 0.01 +chr22 441 0.01 +chr22 439 0.01 +chr22 437 0.01 +chr22 435 0.01 +chr22 434 0.01 +chr22 431 0.01 +chr22 426 0.01 +chr22 425 0.01 +chr22 422 0.01 +chr22 419 0.01 +chr22 418 0.01 +chr22 414 0.01 +chr22 413 0.01 +chr22 410 0.01 +chr22 408 0.01 +chr22 406 0.01 +chr22 405 0.01 +chr22 401 0.01 +chr22 397 0.01 +chr22 395 0.01 +chr22 394 0.01 +chr22 392 0.01 +chr22 391 0.01 +chr22 387 0.01 +chr22 385 0.01 +chr22 384 0.01 +chr22 383 0.01 +chr22 381 0.01 +chr22 379 0.01 +chr22 376 0.01 +chr22 374 0.01 +chr22 373 0.01 +chr22 368 0.01 +chr22 366 0.01 +chr22 365 0.01 +chr22 361 0.01 +chr22 360 0.01 +chr22 359 0.01 +chr22 353 0.01 +chr22 352 0.01 +chr22 350 0.01 +chr22 345 0.01 +chr22 344 0.01 +chr22 343 0.01 +chr22 338 0.01 +chr22 337 0.01 +chr22 335 0.01 +chr22 334 0.01 +chr22 330 0.01 +chr22 328 0.01 +chr22 327 0.01 +chr22 324 0.01 +chr22 320 0.01 +chr22 318 0.01 +chr22 315 0.01 +chr22 311 0.01 +chr22 310 0.01 +chr22 307 0.01 +chr22 305 0.01 +chr22 300 0.01 +chr22 299 0.01 +chr22 298 0.01 +chr22 297 0.01 +chr22 296 0.01 +chr22 295 0.01 +chr22 294 0.01 +chr22 293 0.01 +chr22 292 0.01 +chr22 291 0.01 +chr22 290 0.01 +chr22 289 0.01 +chr22 288 0.01 +chr22 287 0.01 +chr22 286 0.01 +chr22 285 0.01 +chr22 284 0.01 +chr22 283 0.01 +chr22 282 0.01 +chr22 281 0.01 +chr22 280 0.01 +chr22 279 0.01 +chr22 278 0.01 +chr22 277 0.01 +chr22 276 0.01 +chr22 275 0.01 +chr22 274 0.01 +chr22 273 0.01 +chr22 272 0.01 +chr22 271 0.01 +chr22 270 0.01 +chr22 269 0.01 +chr22 268 0.01 +chr22 267 0.01 +chr22 266 0.01 +chr22 265 0.01 +chr22 264 0.01 +chr22 263 0.01 +chr22 262 0.01 +chr22 261 0.01 +chr22 260 0.01 +chr22 259 0.01 +chr22 258 0.01 +chr22 257 0.01 +chr22 256 0.01 +chr22 255 0.01 +chr22 254 0.01 +chr22 253 0.01 +chr22 252 0.01 +chr22 251 0.01 +chr22 250 0.01 +chr22 249 0.01 +chr22 248 0.01 +chr22 247 0.01 +chr22 246 0.01 +chr22 245 0.01 +chr22 244 0.01 +chr22 243 0.01 +chr22 242 0.01 +chr22 241 0.01 +chr22 240 0.01 +chr22 239 0.01 +chr22 238 0.01 +chr22 237 0.01 +chr22 236 0.01 +chr22 235 0.01 +chr22 234 0.01 +chr22 233 0.01 +chr22 232 0.01 +chr22 231 0.01 +chr22 230 0.01 +chr22 229 0.01 +chr22 228 0.01 +chr22 227 0.01 +chr22 226 0.01 +chr22 225 0.01 +chr22 224 0.01 +chr22 223 0.01 +chr22 222 0.01 +chr22 221 0.01 +chr22 220 0.01 +chr22 219 0.01 +chr22 218 0.01 +chr22 217 0.01 +chr22 216 0.01 +chr22 215 0.01 +chr22 214 0.01 +chr22 213 0.01 +chr22 212 0.01 +chr22 211 0.01 +chr22 210 0.01 +chr22 209 0.01 +chr22 208 0.01 +chr22 207 0.01 +chr22 206 0.01 +chr22 205 0.01 +chr22 204 0.01 +chr22 203 0.01 +chr22 202 0.01 +chr22 201 0.01 +chr22 200 0.01 +chr22 199 0.01 +chr22 198 0.01 +chr22 197 0.01 +chr22 196 0.01 +chr22 195 0.01 +chr22 194 0.01 +chr22 193 0.01 +chr22 192 0.01 +chr22 191 0.01 +chr22 190 0.01 +chr22 189 0.01 +chr22 188 0.01 +chr22 187 0.01 +chr22 186 0.01 +chr22 185 0.01 +chr22 184 0.01 +chr22 183 0.01 +chr22 182 0.01 +chr22 181 0.01 +chr22 180 0.01 +chr22 179 0.01 +chr22 178 0.01 +chr22 177 0.01 +chr22 176 0.01 +chr22 175 0.01 +chr22 174 0.01 +chr22 173 0.01 +chr22 172 0.01 +chr22 171 0.01 +chr22 170 0.01 +chr22 169 0.01 +chr22 168 0.01 +chr22 167 0.01 +chr22 166 0.01 +chr22 165 0.01 +chr22 164 0.01 +chr22 163 0.01 +chr22 162 0.01 +chr22 161 0.01 +chr22 160 0.01 +chr22 159 0.01 +chr22 158 0.01 +chr22 157 0.01 +chr22 156 0.01 +chr22 155 0.01 +chr22 154 0.01 +chr22 153 0.01 +chr22 152 0.01 +chr22 151 0.01 +chr22 150 0.01 +chr22 149 0.01 +chr22 148 0.01 +chr22 147 0.01 +chr22 146 0.01 +chr22 145 0.01 +chr22 144 0.01 +chr22 143 0.01 +chr22 142 0.01 +chr22 141 0.01 +chr22 140 0.01 +chr22 139 0.01 +chr22 138 0.01 +chr22 137 0.01 +chr22 136 0.01 +chr22 135 0.01 +chr22 134 0.01 +chr22 133 0.01 +chr22 132 0.01 +chr22 131 0.01 +chr22 130 0.01 +chr22 129 0.01 +chr22 128 0.01 +chr22 127 0.01 +chr22 126 0.01 +chr22 125 0.01 +chr22 124 0.01 +chr22 123 0.01 +chr22 122 0.01 +chr22 121 0.01 +chr22 120 0.01 +chr22 119 0.01 +chr22 118 0.01 +chr22 117 0.01 +chr22 116 0.01 +chr22 115 0.01 +chr22 114 0.01 +chr22 113 0.01 +chr22 112 0.01 +chr22 111 0.01 +chr22 110 0.01 +chr22 109 0.01 +chr22 108 0.01 +chr22 107 0.01 +chr22 106 0.01 +chr22 105 0.01 +chr22 104 0.01 +chr22 103 0.02 +chr22 102 0.02 +chr22 101 0.02 +chr22 100 0.02 +chr22 99 0.02 +chr22 98 0.02 +chr22 97 0.02 +chr22 96 0.02 +chr22 95 0.02 +chr22 94 0.02 +chr22 93 0.02 +chr22 92 0.02 +chr22 91 0.02 +chr22 90 0.02 +chr22 89 0.02 +chr22 88 0.02 +chr22 87 0.02 +chr22 86 0.02 +chr22 85 0.02 +chr22 84 0.02 +chr22 83 0.02 +chr22 82 0.02 +chr22 81 0.02 +chr22 80 0.02 +chr22 79 0.02 +chr22 78 0.02 +chr22 77 0.02 +chr22 76 0.02 +chr22 75 0.02 +chr22 74 0.02 +chr22 73 0.02 +chr22 72 0.02 +chr22 71 0.02 +chr22 70 0.02 +chr22 69 0.02 +chr22 68 0.02 +chr22 67 0.02 +chr22 66 0.02 +chr22 65 0.02 +chr22 64 0.02 +chr22 63 0.02 +chr22 62 0.02 +chr22 61 0.02 +chr22 60 0.02 +chr22 59 0.02 +chr22 58 0.02 +chr22 57 0.02 +chr22 56 0.02 +chr22 55 0.02 +chr22 54 0.02 +chr22 53 0.02 +chr22 52 0.02 +chr22 51 0.02 +chr22 50 0.02 +chr22 49 0.02 +chr22 48 0.02 +chr22 47 0.02 +chr22 46 0.02 +chr22 45 0.02 +chr22 44 0.02 +chr22 43 0.02 +chr22 42 0.02 +chr22 41 0.02 +chr22 40 0.02 +chr22 39 0.02 +chr22 38 0.02 +chr22 37 0.02 +chr22 36 0.02 +chr22 35 0.02 +chr22 34 0.02 +chr22 33 0.02 +chr22 32 0.02 +chr22 31 0.02 +chr22 30 0.02 +chr22 29 0.02 +chr22 28 0.02 +chr22 27 0.02 +chr22 26 0.02 +chr22 25 0.02 +chr22 24 0.02 +chr22 23 0.02 +chr22 22 0.02 +chr22 21 0.02 +chr22 20 0.02 +chr22 19 0.02 +chr22 18 0.02 +chr22 17 0.02 +chr22 16 0.02 +chr22 15 0.02 +chr22 14 0.02 +chr22 13 0.02 +chr22 12 0.02 +chr22 11 0.02 +chr22 10 0.02 +chr22 9 0.02 +chr22 8 0.02 +chr22 7 0.02 +chr22 6 0.02 +chr22 5 0.02 +chr22 4 0.03 +chr22 3 0.03 +chr22 2 0.03 +chr22 1 0.03 +chr22 0 1.00 +total 866 0.00 +total 865 0.00 +total 863 0.00 +total 862 0.00 +total 860 0.00 +total 859 0.00 +total 858 0.00 +total 857 0.00 +total 854 0.00 +total 851 0.00 +total 848 0.00 +total 846 0.00 +total 842 0.00 +total 840 0.00 +total 837 0.00 +total 833 0.00 +total 832 0.00 +total 831 0.00 +total 830 0.00 +total 827 0.00 +total 825 0.00 +total 822 0.00 +total 817 0.00 +total 816 0.00 +total 814 0.00 +total 812 0.00 +total 811 0.00 +total 808 0.00 +total 802 0.00 +total 801 0.00 +total 799 0.00 +total 798 0.00 +total 795 0.00 +total 792 0.00 +total 790 0.00 +total 788 0.00 +total 786 0.00 +total 784 0.00 +total 779 0.00 +total 777 0.00 +total 775 0.00 +total 773 0.00 +total 768 0.00 +total 764 0.00 +total 761 0.00 +total 757 0.00 +total 754 0.00 +total 750 0.00 +total 747 0.00 +total 745 0.00 +total 743 0.00 +total 738 0.00 +total 734 0.00 +total 733 0.00 +total 726 0.00 +total 724 0.00 +total 720 0.00 +total 716 0.00 +total 711 0.00 +total 706 0.00 +total 702 0.00 +total 695 0.00 +total 693 0.00 +total 690 0.00 +total 689 0.00 +total 684 0.00 +total 683 0.00 +total 677 0.00 +total 675 0.00 +total 668 0.00 +total 667 0.00 +total 666 0.00 +total 662 0.00 +total 661 0.00 +total 659 0.00 +total 658 0.00 +total 657 0.00 +total 656 0.00 +total 654 0.00 +total 652 0.00 +total 651 0.00 +total 649 0.00 +total 647 0.00 +total 645 0.00 +total 644 0.00 +total 641 0.00 +total 640 0.00 +total 639 0.00 +total 638 0.00 +total 637 0.00 +total 636 0.00 +total 635 0.00 +total 634 0.00 +total 633 0.00 +total 631 0.00 +total 628 0.00 +total 627 0.00 +total 626 0.00 +total 624 0.00 +total 623 0.00 +total 622 0.00 +total 618 0.00 +total 617 0.00 +total 616 0.00 +total 614 0.00 +total 613 0.00 +total 612 0.00 +total 611 0.00 +total 609 0.00 +total 608 0.00 +total 605 0.00 +total 604 0.00 +total 603 0.00 +total 602 0.00 +total 601 0.00 +total 600 0.00 +total 598 0.00 +total 596 0.00 +total 595 0.00 +total 594 0.00 +total 592 0.00 +total 590 0.00 +total 589 0.00 +total 588 0.00 +total 587 0.00 +total 583 0.00 +total 582 0.00 +total 579 0.00 +total 577 0.00 +total 576 0.00 +total 575 0.00 +total 574 0.00 +total 571 0.00 +total 565 0.00 +total 562 0.00 +total 561 0.00 +total 557 0.00 +total 555 0.00 +total 554 0.00 +total 552 0.00 +total 550 0.00 +total 549 0.00 +total 547 0.00 +total 545 0.00 +total 540 0.00 +total 539 0.00 +total 536 0.00 +total 532 0.00 +total 531 0.00 +total 527 0.00 +total 526 0.01 +total 520 0.01 +total 518 0.01 +total 517 0.01 +total 516 0.01 +total 514 0.01 +total 512 0.01 +total 506 0.01 +total 505 0.01 +total 503 0.01 +total 500 0.01 +total 499 0.01 +total 496 0.01 +total 494 0.01 +total 491 0.01 +total 490 0.01 +total 489 0.01 +total 488 0.01 +total 485 0.01 +total 483 0.01 +total 482 0.01 +total 481 0.01 +total 477 0.01 +total 474 0.01 +total 472 0.01 +total 469 0.01 +total 468 0.01 +total 466 0.01 +total 461 0.01 +total 460 0.01 +total 457 0.01 +total 455 0.01 +total 453 0.01 +total 451 0.01 +total 448 0.01 +total 445 0.01 +total 444 0.01 +total 441 0.01 +total 439 0.01 +total 437 0.01 +total 435 0.01 +total 434 0.01 +total 431 0.01 +total 426 0.01 +total 425 0.01 +total 422 0.01 +total 419 0.01 +total 418 0.01 +total 414 0.01 +total 413 0.01 +total 410 0.01 +total 408 0.01 +total 406 0.01 +total 405 0.01 +total 401 0.01 +total 397 0.01 +total 395 0.01 +total 394 0.01 +total 392 0.01 +total 391 0.01 +total 387 0.01 +total 385 0.01 +total 384 0.01 +total 383 0.01 +total 381 0.01 +total 379 0.01 +total 376 0.01 +total 374 0.01 +total 373 0.01 +total 368 0.01 +total 366 0.01 +total 365 0.01 +total 361 0.01 +total 360 0.01 +total 359 0.01 +total 353 0.01 +total 352 0.01 +total 350 0.01 +total 345 0.01 +total 344 0.01 +total 343 0.01 +total 338 0.01 +total 337 0.01 +total 335 0.01 +total 334 0.01 +total 330 0.01 +total 328 0.01 +total 327 0.01 +total 324 0.01 +total 320 0.01 +total 318 0.01 +total 315 0.01 +total 311 0.01 +total 310 0.01 +total 307 0.01 +total 305 0.01 +total 300 0.01 +total 299 0.01 +total 298 0.01 +total 297 0.01 +total 296 0.01 +total 295 0.01 +total 294 0.01 +total 293 0.01 +total 292 0.01 +total 291 0.01 +total 290 0.01 +total 289 0.01 +total 288 0.01 +total 287 0.01 +total 286 0.01 +total 285 0.01 +total 284 0.01 +total 283 0.01 +total 282 0.01 +total 281 0.01 +total 280 0.01 +total 279 0.01 +total 278 0.01 +total 277 0.01 +total 276 0.01 +total 275 0.01 +total 274 0.01 +total 273 0.01 +total 272 0.01 +total 271 0.01 +total 270 0.01 +total 269 0.01 +total 268 0.01 +total 267 0.01 +total 266 0.01 +total 265 0.01 +total 264 0.01 +total 263 0.01 +total 262 0.01 +total 261 0.01 +total 260 0.01 +total 259 0.01 +total 258 0.01 +total 257 0.01 +total 256 0.01 +total 255 0.01 +total 254 0.01 +total 253 0.01 +total 252 0.01 +total 251 0.01 +total 250 0.01 +total 249 0.01 +total 248 0.01 +total 247 0.01 +total 246 0.01 +total 245 0.01 +total 244 0.01 +total 243 0.01 +total 242 0.01 +total 241 0.01 +total 240 0.01 +total 239 0.01 +total 238 0.01 +total 237 0.01 +total 236 0.01 +total 235 0.01 +total 234 0.01 +total 233 0.01 +total 232 0.01 +total 231 0.01 +total 230 0.01 +total 229 0.01 +total 228 0.01 +total 227 0.01 +total 226 0.01 +total 225 0.01 +total 224 0.01 +total 223 0.01 +total 222 0.01 +total 221 0.01 +total 220 0.01 +total 219 0.01 +total 218 0.01 +total 217 0.01 +total 216 0.01 +total 215 0.01 +total 214 0.01 +total 213 0.01 +total 212 0.01 +total 211 0.01 +total 210 0.01 +total 209 0.01 +total 208 0.01 +total 207 0.01 +total 206 0.01 +total 205 0.01 +total 204 0.01 +total 203 0.01 +total 202 0.01 +total 201 0.01 +total 200 0.01 +total 199 0.01 +total 198 0.01 +total 197 0.01 +total 196 0.01 +total 195 0.01 +total 194 0.01 +total 193 0.01 +total 192 0.01 +total 191 0.01 +total 190 0.01 +total 189 0.01 +total 188 0.01 +total 187 0.01 +total 186 0.01 +total 185 0.01 +total 184 0.01 +total 183 0.01 +total 182 0.01 +total 181 0.01 +total 180 0.01 +total 179 0.01 +total 178 0.01 +total 177 0.01 +total 176 0.01 +total 175 0.01 +total 174 0.01 +total 173 0.01 +total 172 0.01 +total 171 0.01 +total 170 0.01 +total 169 0.01 +total 168 0.01 +total 167 0.01 +total 166 0.01 +total 165 0.01 +total 164 0.01 +total 163 0.01 +total 162 0.01 +total 161 0.01 +total 160 0.01 +total 159 0.01 +total 158 0.01 +total 157 0.01 +total 156 0.01 +total 155 0.01 +total 154 0.01 +total 153 0.01 +total 152 0.01 +total 151 0.01 +total 150 0.01 +total 149 0.01 +total 148 0.01 +total 147 0.01 +total 146 0.01 +total 145 0.01 +total 144 0.01 +total 143 0.01 +total 142 0.01 +total 141 0.01 +total 140 0.01 +total 139 0.01 +total 138 0.01 +total 137 0.01 +total 136 0.01 +total 135 0.01 +total 134 0.01 +total 133 0.01 +total 132 0.01 +total 131 0.01 +total 130 0.01 +total 129 0.01 +total 128 0.01 +total 127 0.01 +total 126 0.01 +total 125 0.01 +total 124 0.01 +total 123 0.01 +total 122 0.01 +total 121 0.01 +total 120 0.01 +total 119 0.01 +total 118 0.01 +total 117 0.01 +total 116 0.01 +total 115 0.01 +total 114 0.01 +total 113 0.01 +total 112 0.01 +total 111 0.01 +total 110 0.01 +total 109 0.01 +total 108 0.01 +total 107 0.01 +total 106 0.01 +total 105 0.01 +total 104 0.01 +total 103 0.02 +total 102 0.02 +total 101 0.02 +total 100 0.02 +total 99 0.02 +total 98 0.02 +total 97 0.02 +total 96 0.02 +total 95 0.02 +total 94 0.02 +total 93 0.02 +total 92 0.02 +total 91 0.02 +total 90 0.02 +total 89 0.02 +total 88 0.02 +total 87 0.02 +total 86 0.02 +total 85 0.02 +total 84 0.02 +total 83 0.02 +total 82 0.02 +total 81 0.02 +total 80 0.02 +total 79 0.02 +total 78 0.02 +total 77 0.02 +total 76 0.02 +total 75 0.02 +total 74 0.02 +total 73 0.02 +total 72 0.02 +total 71 0.02 +total 70 0.02 +total 69 0.02 +total 68 0.02 +total 67 0.02 +total 66 0.02 +total 65 0.02 +total 64 0.02 +total 63 0.02 +total 62 0.02 +total 61 0.02 +total 60 0.02 +total 59 0.02 +total 58 0.02 +total 57 0.02 +total 56 0.02 +total 55 0.02 +total 54 0.02 +total 53 0.02 +total 52 0.02 +total 51 0.02 +total 50 0.02 +total 49 0.02 +total 48 0.02 +total 47 0.02 +total 46 0.02 +total 45 0.02 +total 44 0.02 +total 43 0.02 +total 42 0.02 +total 41 0.02 +total 40 0.02 +total 39 0.02 +total 38 0.02 +total 37 0.02 +total 36 0.02 +total 35 0.02 +total 34 0.02 +total 33 0.02 +total 32 0.02 +total 31 0.02 +total 30 0.02 +total 29 0.02 +total 28 0.02 +total 27 0.02 +total 26 0.02 +total 25 0.02 +total 24 0.02 +total 23 0.02 +total 22 0.02 +total 21 0.02 +total 20 0.02 +total 19 0.02 +total 18 0.02 +total 17 0.02 +total 16 0.02 +total 15 0.02 +total 14 0.02 +total 13 0.02 +total 12 0.02 +total 11 0.02 +total 10 0.02 +total 9 0.02 +total 8 0.02 +total 7 0.02 +total 6 0.02 +total 5 0.02 +total 4 0.03 +total 3 0.03 +total 2 0.03 +total 1 0.03 +total 0 1.00 diff --git a/tests/expected/dna/test.mosdepth.region.dist.txt b/tests/expected/dna/test.mosdepth.region.dist.txt new file mode 100644 index 00000000..6e1d6dc7 --- /dev/null +++ b/tests/expected/dna/test.mosdepth.region.dist.txt @@ -0,0 +1,410 @@ +chr22 204 0.01 +chr22 203 0.01 +chr22 202 0.01 +chr22 201 0.01 +chr22 200 0.01 +chr22 199 0.01 +chr22 198 0.01 +chr22 197 0.01 +chr22 196 0.01 +chr22 195 0.01 +chr22 194 0.01 +chr22 193 0.01 +chr22 192 0.01 +chr22 191 0.01 +chr22 190 0.01 +chr22 189 0.01 +chr22 188 0.01 +chr22 187 0.01 +chr22 186 0.01 +chr22 185 0.01 +chr22 184 0.01 +chr22 183 0.01 +chr22 182 0.01 +chr22 181 0.01 +chr22 180 0.01 +chr22 179 0.01 +chr22 178 0.01 +chr22 177 0.01 +chr22 176 0.01 +chr22 175 0.01 +chr22 174 0.01 +chr22 173 0.01 +chr22 172 0.01 +chr22 171 0.01 +chr22 170 0.01 +chr22 169 0.01 +chr22 168 0.01 +chr22 167 0.01 +chr22 166 0.01 +chr22 165 0.01 +chr22 164 0.01 +chr22 163 0.01 +chr22 162 0.01 +chr22 161 0.01 +chr22 160 0.01 +chr22 159 0.01 +chr22 158 0.01 +chr22 157 0.01 +chr22 156 0.01 +chr22 155 0.01 +chr22 154 0.01 +chr22 153 0.01 +chr22 152 0.01 +chr22 151 0.02 +chr22 150 0.02 +chr22 149 0.02 +chr22 148 0.02 +chr22 147 0.02 +chr22 146 0.02 +chr22 145 0.02 +chr22 144 0.02 +chr22 143 0.02 +chr22 142 0.02 +chr22 141 0.02 +chr22 140 0.02 +chr22 139 0.02 +chr22 138 0.02 +chr22 137 0.02 +chr22 136 0.02 +chr22 135 0.02 +chr22 134 0.02 +chr22 133 0.02 +chr22 132 0.02 +chr22 131 0.02 +chr22 130 0.02 +chr22 129 0.02 +chr22 128 0.02 +chr22 127 0.02 +chr22 126 0.02 +chr22 125 0.02 +chr22 124 0.02 +chr22 123 0.02 +chr22 122 0.02 +chr22 121 0.02 +chr22 120 0.02 +chr22 119 0.02 +chr22 118 0.02 +chr22 117 0.02 +chr22 116 0.02 +chr22 115 0.02 +chr22 114 0.02 +chr22 113 0.02 +chr22 112 0.02 +chr22 111 0.02 +chr22 110 0.02 +chr22 109 0.02 +chr22 108 0.02 +chr22 107 0.02 +chr22 106 0.02 +chr22 105 0.02 +chr22 104 0.02 +chr22 103 0.02 +chr22 102 0.02 +chr22 101 0.02 +chr22 100 0.02 +chr22 99 0.02 +chr22 98 0.02 +chr22 97 0.02 +chr22 96 0.02 +chr22 95 0.02 +chr22 94 0.02 +chr22 93 0.02 +chr22 92 0.02 +chr22 91 0.02 +chr22 90 0.02 +chr22 89 0.02 +chr22 88 0.02 +chr22 87 0.02 +chr22 86 0.02 +chr22 85 0.02 +chr22 84 0.02 +chr22 83 0.02 +chr22 82 0.02 +chr22 81 0.02 +chr22 80 0.04 +chr22 79 0.04 +chr22 78 0.04 +chr22 77 0.04 +chr22 76 0.04 +chr22 75 0.04 +chr22 74 0.04 +chr22 73 0.04 +chr22 72 0.04 +chr22 71 0.04 +chr22 70 0.04 +chr22 69 0.04 +chr22 68 0.04 +chr22 67 0.04 +chr22 66 0.04 +chr22 65 0.04 +chr22 64 0.04 +chr22 63 0.04 +chr22 62 0.04 +chr22 61 0.04 +chr22 60 0.04 +chr22 59 0.04 +chr22 58 0.04 +chr22 57 0.04 +chr22 56 0.04 +chr22 55 0.04 +chr22 54 0.04 +chr22 53 0.04 +chr22 52 0.04 +chr22 51 0.04 +chr22 50 0.04 +chr22 49 0.05 +chr22 48 0.05 +chr22 47 0.05 +chr22 46 0.05 +chr22 45 0.05 +chr22 44 0.05 +chr22 43 0.05 +chr22 42 0.05 +chr22 41 0.05 +chr22 40 0.05 +chr22 39 0.05 +chr22 38 0.05 +chr22 37 0.05 +chr22 36 0.05 +chr22 35 0.05 +chr22 34 0.05 +chr22 33 0.05 +chr22 32 0.05 +chr22 31 0.05 +chr22 30 0.05 +chr22 29 0.05 +chr22 28 0.05 +chr22 27 0.05 +chr22 26 0.05 +chr22 25 0.05 +chr22 24 0.05 +chr22 23 0.05 +chr22 22 0.05 +chr22 21 0.05 +chr22 20 0.05 +chr22 19 0.05 +chr22 18 0.05 +chr22 17 0.05 +chr22 16 0.05 +chr22 15 0.05 +chr22 14 0.05 +chr22 13 0.05 +chr22 12 0.05 +chr22 11 0.05 +chr22 10 0.05 +chr22 9 0.05 +chr22 8 0.05 +chr22 7 0.06 +chr22 6 0.06 +chr22 5 0.07 +chr22 4 0.07 +chr22 3 0.07 +chr22 2 0.07 +chr22 1 0.07 +chr22 0 1.00 +total 204 0.01 +total 203 0.01 +total 202 0.01 +total 201 0.01 +total 200 0.01 +total 199 0.01 +total 198 0.01 +total 197 0.01 +total 196 0.01 +total 195 0.01 +total 194 0.01 +total 193 0.01 +total 192 0.01 +total 191 0.01 +total 190 0.01 +total 189 0.01 +total 188 0.01 +total 187 0.01 +total 186 0.01 +total 185 0.01 +total 184 0.01 +total 183 0.01 +total 182 0.01 +total 181 0.01 +total 180 0.01 +total 179 0.01 +total 178 0.01 +total 177 0.01 +total 176 0.01 +total 175 0.01 +total 174 0.01 +total 173 0.01 +total 172 0.01 +total 171 0.01 +total 170 0.01 +total 169 0.01 +total 168 0.01 +total 167 0.01 +total 166 0.01 +total 165 0.01 +total 164 0.01 +total 163 0.01 +total 162 0.01 +total 161 0.01 +total 160 0.01 +total 159 0.01 +total 158 0.01 +total 157 0.01 +total 156 0.01 +total 155 0.01 +total 154 0.01 +total 153 0.01 +total 152 0.01 +total 151 0.02 +total 150 0.02 +total 149 0.02 +total 148 0.02 +total 147 0.02 +total 146 0.02 +total 145 0.02 +total 144 0.02 +total 143 0.02 +total 142 0.02 +total 141 0.02 +total 140 0.02 +total 139 0.02 +total 138 0.02 +total 137 0.02 +total 136 0.02 +total 135 0.02 +total 134 0.02 +total 133 0.02 +total 132 0.02 +total 131 0.02 +total 130 0.02 +total 129 0.02 +total 128 0.02 +total 127 0.02 +total 126 0.02 +total 125 0.02 +total 124 0.02 +total 123 0.02 +total 122 0.02 +total 121 0.02 +total 120 0.02 +total 119 0.02 +total 118 0.02 +total 117 0.02 +total 116 0.02 +total 115 0.02 +total 114 0.02 +total 113 0.02 +total 112 0.02 +total 111 0.02 +total 110 0.02 +total 109 0.02 +total 108 0.02 +total 107 0.02 +total 106 0.02 +total 105 0.02 +total 104 0.02 +total 103 0.02 +total 102 0.02 +total 101 0.02 +total 100 0.02 +total 99 0.02 +total 98 0.02 +total 97 0.02 +total 96 0.02 +total 95 0.02 +total 94 0.02 +total 93 0.02 +total 92 0.02 +total 91 0.02 +total 90 0.02 +total 89 0.02 +total 88 0.02 +total 87 0.02 +total 86 0.02 +total 85 0.02 +total 84 0.02 +total 83 0.02 +total 82 0.02 +total 81 0.02 +total 80 0.04 +total 79 0.04 +total 78 0.04 +total 77 0.04 +total 76 0.04 +total 75 0.04 +total 74 0.04 +total 73 0.04 +total 72 0.04 +total 71 0.04 +total 70 0.04 +total 69 0.04 +total 68 0.04 +total 67 0.04 +total 66 0.04 +total 65 0.04 +total 64 0.04 +total 63 0.04 +total 62 0.04 +total 61 0.04 +total 60 0.04 +total 59 0.04 +total 58 0.04 +total 57 0.04 +total 56 0.04 +total 55 0.04 +total 54 0.04 +total 53 0.04 +total 52 0.04 +total 51 0.04 +total 50 0.04 +total 49 0.05 +total 48 0.05 +total 47 0.05 +total 46 0.05 +total 45 0.05 +total 44 0.05 +total 43 0.05 +total 42 0.05 +total 41 0.05 +total 40 0.05 +total 39 0.05 +total 38 0.05 +total 37 0.05 +total 36 0.05 +total 35 0.05 +total 34 0.05 +total 33 0.05 +total 32 0.05 +total 31 0.05 +total 30 0.05 +total 29 0.05 +total 28 0.05 +total 27 0.05 +total 26 0.05 +total 25 0.05 +total 24 0.05 +total 23 0.05 +total 22 0.05 +total 21 0.05 +total 20 0.05 +total 19 0.05 +total 18 0.05 +total 17 0.05 +total 16 0.05 +total 15 0.05 +total 14 0.05 +total 13 0.05 +total 12 0.05 +total 11 0.05 +total 10 0.05 +total 9 0.05 +total 8 0.05 +total 7 0.06 +total 6 0.06 +total 5 0.07 +total 4 0.07 +total 3 0.07 +total 2 0.07 +total 1 0.07 +total 0 1.00 diff --git a/tests/expected/dna/test.mosdepth.summary.txt b/tests/expected/dna/test.mosdepth.summary.txt new file mode 100644 index 00000000..ec15caf6 --- /dev/null +++ b/tests/expected/dna/test.mosdepth.summary.txt @@ -0,0 +1,5 @@ +chrom length bases mean min max +chr22 40001 247878 6.20 0 867 +chr22_region 40001 247878 6.20 0 867 +total 40001 247878 6.20 0 867 +total_region 40001 247878 6.20 0 867 diff --git a/tests/expected/dna/test.per-base.bed.gz b/tests/expected/dna/test.per-base.bed.gz new file mode 100644 index 0000000000000000000000000000000000000000..2bcaa4953f87fe278cf560f81734cad9fa1088fc GIT binary patch literal 4428 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 literal 0 HcmV?d00001 diff --git a/tests/expected/dna/test.per-base.bed.gz.csi b/tests/expected/dna/test.per-base.bed.gz.csi new file mode 100644 index 0000000000000000000000000000000000000000..360bf772d30260757e7053919047b16900ea6734 GIT binary patch literal 109 zcmb2|=3rp}f&Xj_PR>jW0Sv`_-%_3=CnO}WB&o6qB(Q9r*)T)0W2&S=18>J<jWrHrW;E%S~z@VH!zu=8o|-ez}RrLXq?frP12Vy8P9?Jh|z z`2YO3deY{%2Dfu&)YdYX?kYX}rl`X*tGKVy21so_FSv1wLU!p{!|6<$!V)c?{nATW zKUd!D-RHy;an{mh`fGk3NW1fT_l*AMMYW=zBWH^z**z8dtU7&${>~#cr&XUt>lym* zJYw@ysCaLAaMNrV;XT`W8UN`^9GLjxEQ9;vV>{fIm@~^vlzJ>R@gC2Isc-uWdjs;> zb}UogaZGu~Gvys@Dm&CvcDSkRNK@I-rm|z1%9Ynk@wxX7z`D+C8F3TM1xl@flnesVoykVEr(MRo=T Pd9+ZMW?%+K0*C+rv<{Yl literal 0 HcmV?d00001 diff --git a/tests/expected/dna/test.regions.bed.gz.csi b/tests/expected/dna/test.regions.bed.gz.csi new file mode 100644 index 0000000000000000000000000000000000000000..7fe77157f4c38961ee45ad1f01daa25f7bcf03bb GIT binary patch literal 107 zcmb2|=3rp}f&Xj_PR>jWehkHY-%_3=CnO}WB&o6qB(Q9r*)T)0W2&S=18>J<C!m=G6 literal 0 HcmV?d00001 diff --git a/tests/expected/dna/test.stats.txt b/tests/expected/dna/test.stats.txt new file mode 100644 index 00000000..9779c96d --- /dev/null +++ b/tests/expected/dna/test.stats.txt @@ -0,0 +1,1916 @@ +# This file was produced by samtools stats (1.24+htslib-1.24) and can be plotted using plot-bamstats +# This file contains statistics for all reads. +# The command line was: stats /Users/benjamin/RustQC-dna/tests/data/dna/test.dna.bam +# CHK, Checksum [2]Read Names [3]Sequences [4]Qualities +# CHK, CRC32 of reads which passed filtering followed by addition (32bit overflow) +CHK 82cbdacd 541c12e0 25a61aa9 +# Summary Numbers. Use `grep ^SN | cut -f 2-` to extract this part. +SN raw total sequences: 5642 # excluding supplementary and secondary reads +SN filtered sequences: 0 +SN sequences: 5642 +SN is sorted: 1 # sorted by coordinate +SN 1st fragments: 2821 +SN last fragments: 2821 +SN reads mapped: 5640 +SN reads mapped and paired: 5640 # paired-end technology bit set + both mates mapped +SN reads unmapped: 2 +SN reads properly paired: 5638 # proper-pair bit set +SN reads paired: 5642 # paired-end technology bit set +SN reads duplicated: 1656 # PCR or optical duplicate bit set +SN reads MQ0: 0 # mapped and MQ=0 +SN reads QC failed: 0 +SN non-primary alignments: 2 +SN supplementary alignments: 0 +SN total length: 672131 # ignores clipping +SN total first fragment length: 335944 # ignores clipping +SN total last fragment length: 336187 # ignores clipping +SN bases mapped: 671854 # ignores clipping +SN bases mapped (cigar): 670991 # more accurate +SN bases trimmed: 0 +SN bases duplicated: 201314 +SN mismatches: 1352 # from NM fields +SN error rate: 2.014930e-03 # mismatches / bases mapped (cigar) +SN average length: 119 +SN average first fragment length: 119 +SN average last fragment length: 119 +SN maximum length: 143 +SN maximum first fragment length: 143 +SN maximum last fragment length: 143 +SN average quality: 40.9 +SN insert size average: 124.8 +SN insert size standard deviation: 31.2 +SN inward oriented pairs: 2814 +SN outward oriented pairs: 6 +SN pairs with other orientation: 0 +SN pairs on different chromosomes: 0 +SN percentage of properly paired reads (%): 99.9 +# First Fragment Qualities. Use `grep ^FFQ | cut -f 2-` to extract this part. +# Columns correspond to qualities and rows to cycles. First column is the cycle number. +FFQ 1 0 0 2 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 15 0 0 0 0 0 0 0 0 0 0 34 0 0 881 0 0 0 0 0 1 0 0 1 54 1816 0 +FFQ 2 0 0 1 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 15 0 0 0 0 0 0 0 0 0 0 24 0 0 882 0 0 0 3 0 0 0 1 1 53 1816 0 +FFQ 3 0 0 0 0 1 0 0 0 0 0 0 0 0 24 0 0 0 0 0 0 15 0 0 0 0 0 0 0 0 0 0 35 0 0 872 0 0 0 2 0 0 0 0 0 62 1810 0 +FFQ 4 0 0 1 0 0 0 0 0 0 0 0 0 0 13 0 0 0 0 0 0 15 0 0 0 0 0 0 0 0 0 0 36 0 0 883 1 0 0 4 0 0 0 0 0 50 1818 0 +FFQ 5 0 0 0 0 0 0 0 0 0 0 0 0 0 11 0 0 0 0 0 0 18 0 0 0 0 0 0 0 0 0 0 34 0 0 885 2 0 0 0 0 0 0 0 0 42 1829 0 +FFQ 6 0 0 1 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 14 0 0 0 0 0 0 0 0 0 0 32 0 0 877 0 0 0 1 0 0 0 0 0 66 1805 0 +FFQ 7 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 14 0 0 0 0 0 1 0 0 0 0 30 0 0 876 1 0 0 1 0 0 0 0 0 46 1827 0 +FFQ 8 0 0 2 0 0 0 0 0 0 0 0 0 0 23 0 0 0 0 0 0 16 0 0 0 0 0 1 0 0 0 0 33 0 0 873 0 0 0 2 0 0 0 0 0 61 1810 0 +FFQ 9 0 0 1 0 0 0 0 0 0 0 0 0 0 24 0 0 0 0 1 0 8 0 0 0 0 0 1 0 0 0 0 37 0 0 876 0 0 0 4 0 0 0 0 1 53 1815 0 +FFQ 10 0 0 1 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 9 0 0 0 0 0 0 0 0 0 0 23 0 0 895 1 0 0 3 0 2 0 0 0 58 1809 0 +FFQ 11 0 0 0 0 0 0 0 0 0 0 0 0 0 8 0 0 0 0 0 0 22 0 0 0 0 0 0 0 0 0 0 23 0 0 895 0 0 0 0 0 0 0 0 1 63 1809 0 +FFQ 12 0 0 4 0 0 0 0 0 0 0 0 0 0 13 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 40 0 0 879 0 0 0 6 0 0 0 0 0 53 1809 0 +FFQ 13 0 0 2 0 0 0 0 0 0 0 0 0 0 21 0 0 0 1 0 0 13 0 0 0 0 0 3 0 0 0 0 38 0 0 872 0 0 0 3 0 0 0 0 1 49 1818 0 +FFQ 14 0 0 1 0 0 0 0 0 0 0 0 0 0 18 0 0 0 0 0 0 15 0 0 0 0 0 3 0 0 0 0 41 2 0 870 0 0 0 2 0 1 0 0 1 64 1803 0 +FFQ 15 0 0 4 0 1 0 0 0 0 0 0 0 0 21 0 1 0 0 0 0 14 0 0 0 0 0 0 0 0 0 0 34 0 0 878 0 0 0 1 0 0 0 0 0 63 1804 0 +FFQ 16 0 0 4 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 12 0 0 0 0 0 1 0 0 0 0 30 1 0 886 0 0 0 0 0 0 0 0 1 44 1822 0 +FFQ 17 0 0 3 0 0 0 0 0 0 0 0 0 0 18 0 0 0 0 0 0 11 0 0 0 0 0 1 0 0 0 0 35 0 0 883 0 0 0 2 0 1 0 0 0 61 1806 0 +FFQ 18 0 0 3 0 0 0 0 0 0 0 0 0 0 22 0 0 0 0 0 0 11 0 0 0 0 0 1 0 0 0 0 39 0 0 876 0 0 0 0 0 0 0 0 1 55 1813 0 +FFQ 19 0 0 2 0 1 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 14 0 0 0 0 0 0 0 0 0 0 35 0 0 873 0 0 0 2 0 0 0 0 0 69 1798 0 +FFQ 20 0 0 2 0 1 0 0 0 1 0 0 0 0 26 0 0 0 0 0 0 9 0 0 0 0 0 3 0 0 0 0 36 3 0 873 0 0 0 1 0 3 0 1 2 70 1790 0 +FFQ 21 0 0 0 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 5 0 0 0 0 0 2 0 0 0 0 46 0 0 866 0 0 0 1 0 0 0 0 0 64 1809 0 +FFQ 22 0 0 3 0 0 0 0 0 0 0 0 0 0 26 0 0 0 0 1 0 13 0 0 0 0 0 5 0 0 0 0 31 0 0 873 0 0 0 2 0 0 0 0 0 55 1812 0 +FFQ 23 0 0 3 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 10 0 0 0 0 0 4 0 0 0 0 41 0 0 863 0 0 0 4 0 2 0 1 1 52 1810 0 +FFQ 24 0 0 2 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 1 0 12 0 0 0 0 0 4 0 0 0 0 29 0 0 870 1 0 0 6 0 0 0 0 1 73 1788 0 +FFQ 25 0 0 5 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 11 0 0 0 0 0 0 0 0 0 0 34 0 0 877 0 0 0 1 0 0 0 2 0 53 1810 0 +FFQ 26 0 0 2 0 2 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 6 0 0 0 0 0 0 0 0 0 0 37 0 0 873 0 0 0 3 0 0 0 0 0 57 1809 0 +FFQ 27 0 0 3 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 7 0 0 0 0 0 4 0 0 0 0 29 0 0 870 0 0 0 6 0 0 0 0 0 40 1822 0 +FFQ 28 0 0 5 0 1 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 9 0 0 0 0 0 1 0 0 0 0 24 2 0 880 0 0 0 2 0 0 0 0 0 50 1811 0 +FFQ 29 0 0 1 0 1 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 3 0 0 0 0 1 4 0 0 0 0 52 0 0 857 3 0 0 7 0 0 0 0 1 68 1791 0 +FFQ 30 0 0 1 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 4 0 0 0 0 0 3 0 0 0 0 32 0 0 874 0 0 0 6 0 0 0 0 4 58 1804 0 +FFQ 31 0 0 5 0 2 0 0 0 0 0 0 0 0 24 0 0 0 0 0 0 11 0 0 0 0 0 1 0 0 0 0 38 0 0 875 0 0 0 3 0 0 0 0 0 54 1807 0 +FFQ 32 0 0 3 0 1 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 8 0 0 0 0 0 1 0 0 0 0 32 1 0 873 0 0 0 2 0 0 0 1 0 59 1804 0 +FFQ 33 0 0 7 0 2 0 0 0 0 0 0 0 0 35 0 0 0 0 1 0 6 0 0 0 0 0 5 0 0 0 0 26 0 0 877 0 0 0 3 0 0 0 0 0 53 1805 0 +FFQ 34 0 0 5 0 0 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 6 0 0 0 0 0 5 0 0 0 0 29 1 0 877 0 0 0 2 0 0 0 0 0 62 1801 0 +FFQ 35 0 0 4 0 0 0 0 0 0 0 0 0 0 31 0 0 0 0 0 0 8 0 0 0 0 0 5 0 0 0 0 29 0 0 877 0 0 0 1 0 0 0 0 0 55 1810 0 +FFQ 36 0 0 2 0 0 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 7 0 0 0 0 0 7 0 0 0 0 31 0 0 872 0 0 0 2 0 0 0 0 1 64 1802 0 +FFQ 37 0 0 3 0 0 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 8 0 0 0 0 0 4 0 0 0 0 34 0 0 867 1 0 0 0 0 0 0 2 0 65 1800 0 +FFQ 38 0 0 2 0 0 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 6 0 0 0 0 0 7 0 0 0 0 33 1 0 867 1 0 0 5 0 1 0 0 0 69 1792 0 +FFQ 39 0 0 3 0 2 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 2 0 0 0 0 0 5 0 0 0 0 40 1 0 870 0 0 0 2 0 0 0 0 0 63 1800 0 +FFQ 40 1 0 2 0 0 0 0 0 0 0 0 0 0 33 0 0 0 0 1 0 9 0 0 0 0 0 7 0 0 0 0 35 2 0 865 0 0 0 3 0 0 0 0 0 62 1800 0 +FFQ 41 0 0 3 0 0 0 0 0 0 0 0 0 0 24 0 0 0 0 0 0 8 0 0 0 0 0 9 0 0 0 0 33 0 0 875 0 0 0 2 0 0 1 1 1 58 1805 0 +FFQ 42 0 0 3 0 0 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 14 0 0 0 0 0 9 0 0 0 0 26 0 0 870 0 0 0 4 0 0 0 0 0 51 1810 0 +FFQ 43 0 0 2 0 0 0 0 0 0 0 0 0 0 42 0 0 0 0 1 0 6 0 0 0 0 0 4 0 0 0 0 30 0 0 864 0 0 0 2 0 0 0 2 0 60 1806 0 +FFQ 44 1 0 5 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 7 0 0 0 0 0 5 0 0 0 0 33 0 0 863 0 0 0 5 0 0 0 0 1 72 1788 0 +FFQ 45 1 0 3 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 7 0 0 0 0 0 5 0 0 0 0 39 0 0 867 1 0 0 3 0 0 0 1 1 70 1791 0 +FFQ 46 1 0 1 0 0 0 0 0 0 0 0 0 0 37 0 0 0 0 0 0 7 0 0 0 0 0 5 0 0 0 0 32 1 0 863 0 0 0 2 0 0 0 0 1 71 1797 0 +FFQ 47 2 0 4 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 4 0 0 0 0 0 1 0 0 0 0 34 0 0 866 0 0 0 7 0 1 0 0 2 76 1782 0 +FFQ 48 1 0 4 0 1 0 0 0 0 0 1 0 0 37 0 0 0 1 0 0 6 0 0 0 0 0 11 0 0 0 0 40 0 0 851 0 0 0 4 0 0 0 0 2 99 1760 0 +FFQ 49 1 0 1 0 1 0 0 0 0 0 0 0 0 43 0 0 0 0 0 0 6 0 0 0 0 0 7 0 0 0 0 45 0 0 844 0 0 0 2 0 0 0 1 2 94 1771 0 +FFQ 50 0 0 4 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 9 0 0 0 0 0 11 0 0 0 0 59 0 0 837 0 0 0 2 0 0 0 0 1 91 1771 0 +FFQ 51 1 0 3 0 1 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 12 0 0 0 0 0 8 0 0 0 0 59 0 0 835 0 0 0 4 0 0 0 1 2 99 1758 0 +FFQ 52 2 0 0 0 1 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 8 0 0 0 0 1 14 0 0 0 0 59 0 0 829 0 0 0 0 0 2 0 1 3 117 1742 0 +FFQ 53 0 0 1 0 0 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 7 0 0 0 0 0 10 0 0 0 0 57 0 0 838 0 0 0 1 0 1 0 0 1 100 1763 0 +FFQ 54 1 0 3 0 2 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 5 0 0 0 0 0 9 0 0 0 0 49 0 0 847 0 0 0 1 0 0 0 1 0 95 1766 0 +FFQ 55 0 0 6 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 13 0 0 0 0 0 16 0 0 0 0 51 1 0 830 1 0 0 4 0 1 0 1 1 107 1745 0 +FFQ 56 1 0 3 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 12 0 0 0 0 0 12 0 0 0 0 48 0 0 834 0 0 0 4 0 2 0 0 0 94 1763 0 +FFQ 57 0 0 4 0 0 0 0 0 0 0 0 0 0 37 0 0 0 0 1 0 9 0 0 0 0 0 9 0 0 0 0 57 0 0 833 1 0 0 2 0 3 0 1 0 108 1746 0 +FFQ 58 2 0 5 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 5 0 0 0 0 0 14 0 0 0 0 44 0 0 842 0 0 0 2 0 0 0 2 2 76 1779 0 +FFQ 59 1 0 3 0 0 1 0 0 0 0 0 0 0 42 0 0 0 0 0 0 9 0 0 0 0 0 8 0 0 0 0 54 1 0 831 0 0 0 6 0 0 0 2 1 105 1746 0 +FFQ 60 2 0 2 0 1 0 0 0 0 0 0 0 0 35 0 0 0 1 0 0 8 0 0 0 0 0 12 0 0 0 0 52 0 0 832 1 0 0 2 0 2 0 1 2 109 1746 0 +FFQ 61 1 0 3 0 0 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 11 0 0 0 0 0 18 0 0 0 0 69 1 0 811 0 1 0 3 0 0 0 0 6 99 1748 0 +FFQ 62 1 0 2 0 2 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 5 0 0 1 0 1 16 0 0 0 0 60 0 0 812 1 0 0 2 0 0 0 2 3 89 1759 0 +FFQ 63 1 0 6 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 8 0 0 0 0 0 19 0 0 0 0 60 1 0 821 0 0 0 3 0 0 0 2 3 93 1751 0 +FFQ 64 2 0 2 0 0 0 0 0 0 0 0 1 0 34 0 0 0 0 1 0 7 0 0 0 0 0 20 0 0 0 0 56 1 0 820 0 0 0 1 0 1 0 0 2 96 1753 0 +FFQ 65 1 0 5 0 0 0 0 0 0 0 0 0 0 42 0 0 0 1 0 0 8 0 0 0 0 0 14 0 0 0 0 51 1 0 821 0 0 0 4 0 0 0 0 4 93 1752 0 +FFQ 66 1 0 1 0 0 0 0 0 1 0 0 0 0 38 0 0 0 0 0 0 6 0 0 0 0 0 12 0 0 0 0 47 0 0 829 0 0 0 0 0 0 1 1 1 118 1736 0 +FFQ 67 0 0 3 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 5 0 0 0 0 0 16 0 0 0 0 51 0 0 831 0 0 0 1 0 0 0 0 0 109 1746 0 +FFQ 68 0 0 1 0 3 1 0 0 0 0 0 0 0 36 0 0 0 1 0 0 4 0 0 0 0 0 17 0 0 0 0 59 0 0 811 0 0 0 4 0 1 0 0 2 110 1734 0 +FFQ 69 0 0 2 0 1 0 0 0 0 0 0 0 0 38 0 0 0 0 1 0 7 0 0 0 0 0 16 0 0 0 0 62 0 0 805 0 0 0 1 0 4 0 0 3 111 1730 0 +FFQ 70 0 0 1 0 0 0 0 0 2 0 0 0 0 42 0 0 0 0 0 0 5 0 0 0 0 0 16 0 0 0 0 56 0 0 805 0 0 0 1 0 0 0 4 0 105 1739 0 +FFQ 71 0 0 1 0 0 0 0 0 2 0 0 0 0 38 0 0 0 0 1 0 8 0 0 0 0 0 18 0 0 0 0 53 0 0 805 0 0 0 5 0 0 0 1 6 99 1729 0 +FFQ 72 0 0 1 0 1 0 0 0 2 0 0 0 0 31 0 0 0 0 0 0 8 0 0 0 0 0 23 0 0 0 0 79 1 0 778 1 0 0 4 0 0 0 1 2 102 1721 0 +FFQ 73 0 0 1 0 1 0 0 0 1 0 0 0 0 43 0 0 0 0 0 0 5 0 0 0 0 0 10 0 0 0 0 58 0 0 797 0 0 0 2 0 1 0 0 5 111 1713 0 +FFQ 74 0 0 0 0 1 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 10 0 0 0 0 0 19 0 0 0 0 53 1 0 793 0 0 0 4 0 0 0 0 1 108 1717 0 +FFQ 75 0 0 3 0 0 0 0 0 0 0 0 0 0 41 0 0 0 1 0 0 8 0 0 0 0 0 22 0 0 0 0 63 1 0 776 0 0 0 1 0 1 0 0 1 115 1703 0 +FFQ 76 0 0 3 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 8 0 0 0 0 0 13 0 0 0 0 51 1 0 793 1 0 0 0 0 0 0 1 2 114 1698 0 +FFQ 77 0 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 2 0 0 0 0 0 16 0 0 0 0 55 1 0 786 1 0 0 0 0 0 0 0 1 120 1692 0 +FFQ 78 0 0 4 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 2 0 0 0 0 0 18 0 0 0 0 49 0 0 777 0 0 0 2 0 1 0 1 0 112 1688 0 +FFQ 79 0 0 2 0 0 0 0 0 0 0 0 0 0 48 0 0 0 1 0 0 7 0 0 0 0 0 12 0 0 0 0 57 0 0 753 0 0 0 0 0 0 0 1 1 117 1680 0 +FFQ 80 0 0 3 0 0 0 0 0 1 0 0 0 0 43 0 0 0 0 0 0 10 0 0 0 0 0 12 0 0 0 0 69 0 0 738 0 0 0 2 0 0 0 1 0 118 1669 0 +FFQ 81 0 0 2 0 0 0 0 0 1 0 0 0 0 37 0 0 0 0 1 0 6 0 0 0 0 0 19 0 0 0 0 55 0 0 745 0 0 0 0 0 1 0 3 3 127 1649 0 +FFQ 82 0 0 0 0 0 0 0 0 0 0 0 0 0 41 0 0 0 0 0 0 4 0 0 0 0 0 13 0 0 0 0 76 3 0 715 1 0 0 1 0 0 0 0 0 109 1662 0 +FFQ 83 0 0 2 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 5 0 0 0 0 0 15 0 0 0 0 57 0 0 717 0 0 0 0 0 0 0 0 1 92 1672 0 +FFQ 84 0 0 1 0 0 0 0 0 1 0 0 0 0 31 0 0 0 0 0 0 6 0 0 0 0 0 17 0 0 0 0 60 2 0 718 0 0 0 0 0 0 0 0 0 122 1630 0 +FFQ 85 0 0 4 0 0 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 9 0 0 0 0 0 16 0 0 0 0 58 2 0 710 0 0 0 0 0 0 0 0 4 130 1603 0 +FFQ 86 0 0 1 0 0 0 0 0 0 0 0 0 0 43 0 0 0 0 0 0 10 0 0 0 0 0 25 0 0 0 0 46 1 0 685 0 0 0 1 0 0 0 1 1 109 1625 0 +FFQ 87 0 0 1 0 1 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 3 0 0 0 0 0 19 0 0 0 0 53 2 0 692 0 0 0 0 0 0 0 0 2 117 1599 0 +FFQ 88 0 0 0 0 1 0 0 0 1 0 0 0 0 37 0 0 0 0 0 0 2 0 0 0 0 0 23 0 0 0 0 63 1 0 664 0 0 0 0 0 0 0 2 3 118 1579 0 +FFQ 89 0 0 1 0 0 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 7 0 0 0 0 0 17 0 0 0 0 59 1 0 666 0 0 0 2 0 0 0 0 3 131 1551 0 +FFQ 90 0 0 0 0 1 0 0 0 0 0 0 0 0 29 0 0 0 0 0 0 2 0 0 0 0 0 15 0 0 0 0 47 0 0 678 1 0 0 0 0 2 1 1 4 115 1548 0 +FFQ 91 0 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 2 0 0 0 0 0 17 0 0 0 0 66 1 0 635 1 0 0 1 0 0 0 2 4 119 1540 0 +FFQ 92 0 0 1 0 0 0 0 0 0 0 0 0 0 34 0 0 0 1 0 0 7 0 0 0 0 0 18 0 0 0 0 47 0 0 643 0 0 0 0 0 1 0 1 10 120 1522 0 +FFQ 93 0 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 5 1 0 0 0 0 26 0 0 0 0 63 1 0 610 1 0 0 2 0 0 0 1 8 103 1519 0 +FFQ 94 0 0 0 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 5 0 0 0 0 0 25 0 0 0 0 68 0 0 599 0 0 0 1 0 0 0 2 3 121 1494 0 +FFQ 95 0 0 1 0 0 0 0 0 0 0 0 0 0 29 0 0 0 1 0 0 3 0 0 0 0 0 17 0 0 0 0 78 0 0 593 0 0 0 0 0 0 0 1 1 124 1484 0 +FFQ 96 0 0 0 0 0 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 2 0 0 0 0 0 14 0 0 0 0 74 1 0 575 0 0 0 0 0 0 0 0 3 126 1459 0 +FFQ 97 0 0 0 0 0 0 0 0 1 0 0 0 0 30 0 0 0 1 0 0 6 0 0 0 0 0 21 0 0 0 0 76 0 0 545 0 0 0 0 0 0 0 1 5 137 1413 0 +FFQ 98 0 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 1 0 6 0 0 0 0 0 27 0 0 0 0 71 1 0 532 0 0 0 0 0 0 0 0 5 132 1397 0 +FFQ 99 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 5 0 0 0 0 0 32 0 0 0 0 85 1 0 506 1 0 0 1 0 0 0 0 4 133 1381 0 +FFQ 100 0 0 0 0 0 0 0 0 0 0 0 0 0 31 0 0 0 0 0 0 4 0 0 0 0 0 20 0 0 0 0 66 0 0 521 0 0 0 0 0 0 0 3 3 135 1367 0 +FFQ 101 0 0 0 0 0 0 0 0 0 0 0 0 0 26 0 0 0 0 0 0 5 0 0 0 0 0 21 0 0 0 0 83 0 0 489 0 0 0 0 0 0 0 0 2 135 1345 0 +FFQ 102 0 0 0 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 3 0 0 0 0 0 25 0 0 0 0 78 0 0 483 0 0 0 0 0 0 0 3 1 136 1325 0 +FFQ 103 0 0 0 0 0 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 3 0 0 0 0 0 34 0 0 0 0 80 1 0 460 0 0 0 0 0 0 0 1 4 138 1302 0 +FFQ 104 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 5 0 0 0 0 0 28 0 0 0 0 75 0 0 473 0 0 0 0 0 0 0 2 4 141 1285 0 +FFQ 105 0 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 8 0 0 0 0 0 22 0 0 0 0 80 0 0 441 0 0 0 0 0 0 0 3 6 125 1280 0 +FFQ 106 0 0 0 0 0 0 0 0 0 0 0 0 0 18 0 0 0 0 0 0 2 0 0 0 0 0 27 0 0 0 0 89 4 0 440 0 0 0 1 0 0 0 2 3 132 1256 0 +FFQ 107 0 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 7 0 0 0 0 0 24 0 0 0 0 81 0 0 423 0 0 0 1 0 0 0 4 4 147 1225 0 +FFQ 108 0 0 0 0 0 0 0 0 0 0 0 0 0 23 0 0 0 0 0 0 6 0 0 0 0 0 28 0 0 0 0 84 1 0 412 0 0 0 1 0 0 0 0 4 158 1193 0 +FFQ 109 0 0 0 0 0 0 0 0 0 0 0 0 0 33 0 0 0 1 0 0 6 0 0 0 0 0 22 0 0 0 0 99 1 0 381 0 0 0 1 0 0 0 3 7 134 1196 0 +FFQ 110 0 0 0 0 0 0 0 0 0 0 0 0 0 31 0 0 0 1 0 0 4 0 0 0 0 0 30 0 0 0 0 94 2 0 371 0 0 0 0 0 0 1 3 3 149 1171 0 +FFQ 111 0 0 0 0 0 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 3 0 0 0 0 0 44 0 0 0 0 86 2 0 353 0 0 0 1 0 0 0 2 2 132 1168 0 +FFQ 112 0 0 0 0 1 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 5 0 0 0 0 0 30 0 0 0 0 107 1 0 343 0 0 0 0 0 0 0 4 3 167 1109 0 +FFQ 113 0 0 0 0 0 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 4 0 0 0 0 0 34 0 1 0 0 103 1 0 332 0 0 0 0 0 0 0 3 3 164 1103 0 +FFQ 114 0 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 6 0 0 0 0 0 33 0 0 0 0 105 1 0 317 1 0 0 0 0 0 0 5 6 163 1072 0 +FFQ 115 0 0 0 0 1 0 0 0 0 0 0 0 0 26 0 0 0 0 0 0 5 0 0 0 0 0 41 0 0 0 0 117 0 0 293 0 0 0 1 0 0 0 3 5 151 1066 0 +FFQ 116 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 8 0 0 0 0 0 39 0 0 0 0 113 2 0 291 0 0 0 1 0 0 0 1 0 162 1026 0 +FFQ 117 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 11 0 0 0 0 0 25 0 0 0 0 116 0 0 279 0 0 0 0 0 0 0 0 4 156 1008 0 +FFQ 118 0 0 0 0 0 0 0 0 0 0 0 0 0 29 0 0 0 0 0 0 8 0 0 0 0 0 29 0 0 0 0 96 0 0 285 0 0 0 2 0 0 0 2 3 151 986 0 +FFQ 119 0 0 1 0 0 0 0 0 0 0 0 0 0 18 0 0 0 0 0 0 8 0 0 0 0 0 36 0 0 0 0 105 0 0 269 0 0 0 1 0 0 0 2 2 154 961 0 +FFQ 120 0 0 0 0 0 0 1 0 0 0 0 0 0 23 0 0 0 0 0 0 10 0 0 0 0 0 33 0 0 0 0 110 2 0 251 0 0 0 0 0 0 0 3 5 157 924 0 +FFQ 121 0 0 0 0 0 0 0 0 0 0 0 0 0 23 0 0 0 0 0 0 5 0 0 0 0 0 32 0 0 0 0 92 0 0 264 0 0 0 0 0 0 0 3 7 142 937 0 +FFQ 122 0 0 0 0 0 0 0 0 0 0 0 0 0 19 0 0 0 1 0 0 8 0 0 0 0 0 47 0 0 0 0 91 0 0 237 0 0 0 0 0 0 0 1 8 143 911 0 +FFQ 123 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 10 0 0 0 0 0 24 0 0 0 0 116 0 0 221 0 0 0 0 0 0 0 2 7 147 884 0 +FFQ 124 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 1 0 0 8 0 0 0 0 0 45 0 0 0 0 97 0 0 216 0 0 0 0 0 0 0 2 4 140 875 0 +FFQ 125 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 8 0 0 0 0 0 35 0 0 0 0 102 1 0 204 0 0 0 0 0 0 0 0 5 119 878 0 +FFQ 126 0 0 0 0 0 0 0 0 0 0 0 0 0 15 0 0 0 1 0 0 5 0 0 0 0 0 23 0 0 0 0 100 0 0 214 0 0 0 0 0 0 0 1 9 139 829 0 +FFQ 127 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 7 0 0 0 0 0 30 0 0 0 0 100 1 0 193 0 0 0 1 0 0 0 1 2 131 824 0 +FFQ 128 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 3 0 0 0 0 0 21 0 0 0 0 85 0 0 208 0 0 0 0 0 0 0 2 6 136 798 0 +FFQ 129 0 0 0 0 0 0 0 0 0 0 0 0 0 23 0 0 0 0 0 0 4 0 0 0 0 0 16 0 0 0 0 72 2 0 212 0 0 0 1 0 0 0 1 1 127 789 0 +FFQ 130 0 0 0 0 0 0 0 0 0 0 0 0 0 19 0 0 0 1 0 0 6 0 0 0 0 0 24 0 0 0 0 69 0 0 198 0 0 0 0 0 1 0 2 4 115 781 0 +FFQ 131 0 0 0 0 0 0 0 0 0 0 0 0 0 21 0 0 0 0 0 0 4 0 0 0 0 0 27 0 0 0 0 71 0 0 183 0 0 0 1 0 0 0 1 7 114 751 0 +FFQ 132 0 0 0 0 0 0 0 0 0 0 0 0 0 16 0 0 0 0 0 0 4 0 0 0 0 0 27 0 0 0 0 73 0 0 174 0 0 0 2 0 0 0 1 6 107 725 0 +FFQ 133 0 0 0 0 0 0 0 0 0 0 0 0 0 19 0 0 0 0 0 0 2 0 0 0 0 0 23 0 0 0 0 86 0 0 158 0 0 0 0 0 0 0 0 2 121 699 0 +FFQ 134 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 0 0 0 4 0 0 0 0 0 15 0 0 0 0 79 1 0 163 0 0 0 0 0 0 0 1 5 108 696 0 +FFQ 135 0 0 0 0 0 0 0 0 0 0 0 0 0 15 0 0 0 0 0 0 4 0 0 0 0 0 25 0 0 0 0 75 0 0 149 0 0 0 0 0 0 0 2 8 100 688 0 +FFQ 136 0 0 0 0 1 0 0 0 0 0 0 0 0 16 0 0 0 0 0 0 2 0 0 0 0 0 21 0 0 0 0 81 1 0 136 0 0 0 1 0 0 0 5 4 94 673 0 +FFQ 137 0 0 0 0 0 0 0 0 0 0 0 0 0 10 0 0 0 0 0 0 2 0 0 0 0 0 20 0 0 0 0 69 0 0 144 0 0 0 0 0 0 0 0 1 113 646 0 +FFQ 138 0 0 0 0 0 0 0 0 0 0 0 0 0 14 0 0 0 0 0 0 5 0 0 0 0 0 16 0 0 0 0 73 1 0 134 0 0 0 1 0 0 0 2 3 98 629 0 +FFQ 139 0 0 0 0 0 0 0 0 0 0 0 0 0 9 0 0 0 0 0 0 5 0 0 0 0 0 19 0 0 0 0 77 0 0 124 0 0 0 0 0 0 0 0 6 87 615 0 +FFQ 140 0 0 0 0 0 0 0 0 0 0 0 0 0 8 0 0 0 0 0 0 3 0 0 0 0 0 18 0 0 0 0 70 0 0 122 0 0 0 0 0 0 0 2 5 87 595 0 +FFQ 141 0 0 0 0 0 0 0 0 0 0 0 0 0 15 0 0 0 0 0 0 4 0 0 0 0 0 12 0 0 0 0 67 0 0 116 0 0 0 0 0 0 0 2 5 71 590 0 +FFQ 142 0 0 0 0 0 0 0 0 0 0 0 0 0 10 0 0 0 0 0 0 3 0 0 0 0 0 19 0 0 0 0 52 0 0 116 0 0 0 0 0 0 0 0 4 78 559 0 +FFQ 143 0 0 1 1 0 0 0 0 0 0 0 0 0 61 0 0 0 1 3 0 10 0 0 0 0 0 29 0 0 0 0 64 21 0 33 1 0 0 1 0 0 1 13 15 117 442 0 +# Last Fragment Qualities. Use `grep ^LFQ | cut -f 2-` to extract this part. +# Columns correspond to qualities and rows to cycles. First column is the cycle number. +LFQ 1 1 0 0 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 15 0 0 0 0 0 1 0 0 0 0 48 0 0 849 0 0 0 0 0 0 0 0 1 80 1792 0 +LFQ 2 2 0 1 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 10 0 0 0 0 0 0 0 0 0 0 38 0 0 869 0 0 0 0 0 0 0 0 3 86 1784 0 +LFQ 3 2 0 0 0 0 0 0 0 0 0 0 0 0 31 0 0 0 0 0 0 8 0 0 0 0 0 0 0 0 0 0 50 0 0 855 0 0 0 3 0 1 0 0 0 69 1802 0 +LFQ 4 3 0 0 0 0 0 0 0 0 0 0 0 0 32 0 0 0 0 0 0 12 0 0 0 0 0 2 0 0 0 0 38 0 0 861 0 0 0 0 0 0 0 0 2 60 1811 0 +LFQ 5 2 0 0 0 0 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 8 0 0 0 0 0 1 0 0 0 0 39 0 0 866 0 0 0 0 0 0 0 0 1 72 1799 0 +LFQ 6 3 0 0 0 0 0 0 0 0 0 0 0 0 29 0 0 0 0 0 0 15 0 0 0 0 0 1 0 0 0 0 42 0 0 858 0 0 0 1 0 0 0 0 0 68 1804 0 +LFQ 7 2 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 12 0 0 0 0 0 0 0 0 0 0 44 0 0 852 0 0 0 1 0 0 0 0 1 71 1799 0 +LFQ 8 3 0 1 0 0 0 0 0 0 0 0 0 0 31 0 0 0 0 0 0 16 0 0 0 0 0 2 0 0 0 0 39 0 0 857 0 0 0 0 0 0 0 0 1 72 1799 0 +LFQ 9 3 0 0 0 0 0 0 0 0 0 1 0 0 32 0 0 0 0 0 0 7 0 0 0 0 0 0 0 0 0 0 41 0 0 863 0 0 0 1 0 0 0 0 1 69 1803 0 +LFQ 10 3 0 0 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 13 0 0 0 0 0 0 0 0 0 0 57 0 0 844 0 0 0 0 0 0 0 0 3 70 1797 0 +LFQ 11 3 0 0 0 0 0 0 0 0 0 0 0 0 33 0 0 0 0 0 0 12 0 0 0 0 0 1 0 0 0 0 33 1 0 866 0 0 0 1 0 0 0 1 0 66 1804 0 +LFQ 12 3 0 0 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 12 0 0 0 0 0 3 0 0 0 0 31 0 0 865 0 0 0 0 0 0 0 0 0 62 1811 0 +LFQ 13 2 0 0 0 0 0 0 0 0 0 0 0 0 42 0 0 0 0 0 0 10 0 0 0 0 0 4 0 0 0 0 44 1 0 848 0 0 0 1 0 0 0 0 1 75 1793 0 +LFQ 14 3 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 5 0 0 0 0 0 4 0 0 0 0 47 0 0 850 0 0 0 0 0 0 0 0 3 75 1796 0 +LFQ 15 3 0 0 0 0 0 0 0 0 0 0 0 0 41 0 0 0 0 0 0 7 0 0 0 0 0 0 0 0 0 0 41 1 0 858 0 0 0 1 0 0 0 0 1 84 1784 0 +LFQ 16 3 0 1 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 4 0 0 0 0 0 13 0 0 0 0 39 1 0 850 0 0 0 0 0 0 0 0 0 70 1801 0 +LFQ 17 3 0 0 0 0 0 0 0 0 0 0 0 0 42 0 0 0 0 0 0 8 0 0 0 0 0 2 0 0 0 0 31 0 0 863 0 0 0 0 0 0 0 0 1 77 1794 0 +LFQ 18 2 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 9 0 0 0 0 0 3 0 0 0 0 35 0 0 854 0 0 0 0 0 0 0 1 2 67 1802 0 +LFQ 19 3 0 0 0 0 0 0 0 0 0 0 0 0 44 0 0 0 0 0 0 8 0 0 0 0 0 3 0 0 0 0 37 0 0 854 0 0 0 1 0 0 0 0 1 70 1800 0 +LFQ 20 3 0 0 0 0 0 0 0 0 0 0 0 0 50 0 0 0 0 0 0 6 0 0 0 0 0 3 0 0 0 0 36 0 0 851 0 0 0 2 0 0 0 0 3 69 1798 0 +LFQ 21 3 0 1 0 0 0 0 0 0 0 0 0 0 34 0 0 0 0 0 0 6 0 0 0 0 0 2 0 0 0 0 36 1 0 867 0 0 0 1 0 0 0 0 1 73 1796 0 +LFQ 22 3 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 6 0 0 0 0 0 1 0 0 0 0 43 2 0 866 0 0 0 0 0 0 0 0 3 70 1797 0 +LFQ 23 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 2 0 0 0 0 0 2 0 0 0 0 38 0 0 859 0 0 0 0 0 0 0 0 0 71 1800 0 +LFQ 24 3 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 4 0 0 0 0 0 2 0 0 0 0 35 0 0 859 0 0 0 0 0 0 0 0 1 72 1800 0 +LFQ 25 3 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 6 0 0 0 0 0 2 0 0 0 0 36 0 0 861 0 0 0 0 0 0 0 0 3 63 1807 0 +LFQ 26 3 0 0 0 0 0 0 0 0 0 0 0 0 43 0 0 0 0 0 0 4 0 0 0 0 0 4 0 0 0 0 41 1 0 853 0 0 0 0 0 0 0 0 2 73 1797 0 +LFQ 27 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 10 0 0 0 0 0 9 0 0 0 0 32 0 0 850 0 0 0 0 0 0 0 0 2 73 1796 0 +LFQ 28 3 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 1 0 6 0 0 0 0 0 7 0 0 0 0 39 2 0 848 0 0 0 0 0 0 0 0 0 73 1797 0 +LFQ 29 3 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 1 0 0 0 0 0 4 0 0 0 0 35 1 0 860 0 0 0 1 0 0 0 0 3 73 1795 0 +LFQ 30 3 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 2 0 0 0 0 0 8 0 0 0 0 34 0 0 863 0 0 0 0 0 0 0 0 2 69 1801 0 +LFQ 31 3 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 1 0 0 5 0 0 0 0 0 2 0 0 0 0 38 1 0 857 0 0 0 0 0 0 0 0 3 77 1789 0 +LFQ 32 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 5 0 0 0 0 0 3 0 0 0 0 37 2 0 855 0 0 0 0 0 0 0 0 2 82 1786 0 +LFQ 33 3 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 7 0 0 0 0 0 6 0 0 0 0 35 0 0 853 0 0 0 0 0 0 0 0 1 76 1795 0 +LFQ 34 3 0 0 0 0 0 0 0 0 0 0 0 0 43 0 0 0 0 0 0 11 0 0 0 0 0 10 0 0 0 0 38 2 0 844 0 0 0 1 0 0 0 0 0 85 1783 0 +LFQ 35 3 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 1 0 0 9 0 0 0 0 0 5 0 0 0 0 37 0 0 856 0 0 0 1 0 0 0 1 1 80 1787 0 +LFQ 36 3 0 1 0 0 0 0 0 0 0 0 0 0 41 0 0 0 0 0 0 8 0 0 0 0 0 6 0 0 0 0 33 2 0 856 0 0 0 1 0 0 0 0 1 72 1796 0 +LFQ 37 3 0 0 0 0 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 4 0 0 0 0 0 3 0 0 0 0 25 0 0 867 0 0 0 0 0 0 0 0 3 84 1784 0 +LFQ 38 3 0 0 0 0 0 0 0 0 0 0 0 0 54 0 0 0 0 0 0 8 0 0 0 0 0 8 0 0 0 0 27 0 0 849 0 0 0 0 0 0 0 0 1 85 1785 0 +LFQ 39 3 0 2 0 0 0 0 0 0 0 0 0 0 41 0 0 0 0 0 0 11 0 0 0 0 0 8 0 0 0 0 44 1 0 840 0 0 0 1 0 0 0 0 3 67 1799 0 +LFQ 40 3 0 0 0 0 0 0 0 0 0 0 0 0 51 0 0 0 0 0 0 5 0 0 0 0 0 10 0 0 0 0 41 2 0 838 0 0 0 1 0 0 0 1 1 93 1774 0 +LFQ 41 3 0 0 0 0 0 0 0 0 0 0 0 0 52 0 0 0 1 0 0 7 0 0 0 0 0 11 0 0 0 0 56 0 0 818 0 0 0 0 0 0 0 0 2 96 1774 0 +LFQ 42 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 5 0 0 0 0 0 10 0 0 0 0 35 1 0 848 0 0 0 1 0 0 0 2 3 102 1764 0 +LFQ 43 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 6 0 0 0 0 0 5 0 0 0 0 42 0 0 845 2 0 0 0 0 0 0 0 2 103 1766 0 +LFQ 44 3 0 0 0 1 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 10 0 0 0 0 0 11 0 0 0 0 51 0 0 834 0 0 0 0 0 0 0 0 3 97 1770 0 +LFQ 45 3 0 1 0 0 0 0 0 0 0 0 0 0 50 0 0 0 1 0 0 4 0 0 0 0 0 14 0 0 0 0 46 1 0 831 0 0 0 1 0 0 0 0 3 94 1771 0 +LFQ 46 3 0 0 0 0 0 0 0 0 0 0 0 0 59 0 0 0 0 0 0 10 0 0 0 0 0 9 0 0 0 0 33 0 0 835 0 0 0 1 0 0 0 0 4 104 1762 0 +LFQ 47 3 0 0 0 0 0 0 0 0 0 0 0 0 48 0 0 0 0 0 0 8 0 0 0 0 0 15 0 0 0 0 64 1 0 810 0 0 0 1 0 0 0 1 3 117 1749 0 +LFQ 48 3 0 0 0 0 0 0 0 0 0 0 0 0 43 0 0 0 0 0 0 7 0 0 0 0 0 14 0 0 0 0 62 3 0 820 0 0 0 0 0 0 0 0 3 103 1762 0 +LFQ 49 3 0 0 0 0 0 0 0 0 0 0 0 0 51 0 0 0 0 0 0 7 0 0 0 0 0 17 0 0 0 0 61 2 0 809 0 0 0 0 0 0 0 1 3 106 1760 0 +LFQ 50 3 0 0 0 1 0 0 0 0 0 0 0 0 48 0 0 0 0 0 0 4 0 0 0 0 0 18 0 0 0 0 56 3 0 818 0 0 0 1 0 0 0 2 3 132 1728 0 +LFQ 51 3 0 1 0 0 0 0 0 0 0 0 0 0 55 0 0 0 0 0 0 6 0 0 0 0 0 20 0 0 0 0 66 3 0 796 0 0 0 1 0 0 0 1 4 111 1750 0 +LFQ 52 3 0 0 0 0 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 5 0 0 0 0 0 36 0 0 0 0 56 0 0 799 0 0 0 0 0 0 0 0 3 139 1728 0 +LFQ 53 3 0 0 0 0 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 7 0 0 0 0 0 22 0 0 0 0 60 1 0 808 0 0 0 1 0 0 0 0 2 114 1749 0 +LFQ 54 3 0 0 0 0 0 0 0 0 0 0 0 0 51 0 0 0 0 0 0 5 0 0 0 0 0 21 0 0 0 0 62 0 0 803 0 0 0 1 0 0 0 2 1 109 1756 0 +LFQ 55 3 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 12 0 0 0 0 0 17 0 0 0 0 74 1 0 799 0 0 0 1 0 0 0 0 2 121 1743 0 +LFQ 56 3 0 0 0 0 0 0 0 0 0 0 0 0 49 0 0 0 0 0 0 9 0 0 0 0 0 16 0 0 0 0 60 1 0 808 0 0 0 0 0 0 0 0 2 134 1731 0 +LFQ 57 3 0 1 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 3 0 0 0 0 0 18 0 0 0 0 57 0 0 818 0 0 0 1 0 0 0 1 5 132 1729 0 +LFQ 58 3 0 0 0 0 0 0 0 0 0 0 0 0 51 0 0 0 0 0 0 2 0 0 0 0 0 11 0 0 0 0 52 1 0 826 0 0 0 2 0 0 0 0 6 107 1752 0 +LFQ 59 3 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 9 0 0 0 0 0 14 0 0 0 0 55 1 0 832 1 0 0 0 0 0 0 1 0 129 1737 0 +LFQ 60 3 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 8 0 0 0 0 0 14 0 0 0 0 70 0 0 813 0 0 0 0 0 0 0 0 6 118 1743 0 +LFQ 61 3 0 0 0 0 0 0 0 0 0 0 0 0 50 0 0 0 0 0 0 3 0 0 0 0 0 15 0 0 0 0 49 0 0 820 0 0 0 1 0 0 0 0 2 107 1759 0 +LFQ 62 3 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 1 0 0 9 0 0 0 0 0 21 0 0 0 0 67 1 0 808 0 0 0 0 0 0 0 1 2 121 1736 0 +LFQ 63 3 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 4 0 0 0 0 0 23 0 0 0 0 72 1 0 792 0 0 0 1 0 0 0 0 2 124 1736 0 +LFQ 64 3 0 0 0 0 0 0 0 0 0 0 0 0 58 0 0 0 0 0 0 6 0 0 0 0 0 19 0 0 0 0 78 2 0 772 0 0 0 1 0 0 0 0 2 111 1747 0 +LFQ 65 3 0 0 0 0 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 11 0 0 0 0 0 22 0 0 0 0 61 1 0 794 0 0 0 0 0 0 0 0 6 134 1720 0 +LFQ 66 2 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 8 0 0 0 0 0 25 0 0 0 0 53 1 0 801 0 0 0 1 0 0 0 1 1 135 1720 0 +LFQ 67 2 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 3 0 0 0 0 0 22 0 0 0 0 69 1 0 796 0 0 0 1 0 0 0 2 0 114 1743 0 +LFQ 68 2 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 6 0 0 0 0 0 28 0 0 0 0 60 0 0 792 0 0 0 1 0 0 0 0 3 128 1726 0 +LFQ 69 2 0 0 0 0 0 0 0 0 0 0 0 0 42 0 0 0 1 0 0 11 0 0 0 0 0 22 0 0 0 0 68 0 0 781 0 0 0 1 0 0 0 1 5 143 1705 0 +LFQ 70 3 0 0 0 0 0 0 0 0 0 0 0 0 47 0 0 0 0 0 0 7 0 0 0 0 0 31 0 0 0 0 57 2 0 778 0 0 0 0 0 0 0 1 4 127 1720 0 +LFQ 71 2 0 0 0 0 0 0 0 0 0 0 0 0 54 0 0 0 0 0 0 3 0 0 0 0 0 16 0 0 0 0 67 3 0 778 0 0 0 0 0 0 0 1 4 138 1701 0 +LFQ 72 2 0 1 0 0 0 0 0 0 0 0 0 0 49 0 0 0 0 0 0 6 0 0 0 0 0 18 0 0 0 0 64 2 0 777 0 0 0 1 0 0 0 0 5 139 1692 0 +LFQ 73 1 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 8 0 0 0 0 0 29 0 0 0 0 75 0 0 761 0 0 0 0 0 0 0 1 5 146 1685 0 +LFQ 74 2 0 1 0 1 0 0 0 0 0 0 0 0 54 0 0 0 0 0 0 9 0 0 0 0 0 13 0 0 0 0 84 0 0 745 0 0 0 0 0 0 0 2 0 145 1685 0 +LFQ 75 2 0 0 0 0 0 0 0 0 0 0 0 0 56 0 0 0 0 0 0 8 0 0 0 0 0 24 0 0 0 0 55 0 0 762 0 0 0 0 0 1 0 0 3 153 1673 0 +LFQ 76 2 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 15 0 0 0 0 0 18 0 0 0 0 80 2 0 749 0 0 0 0 0 0 0 1 6 146 1666 0 +LFQ 77 1 0 1 0 0 0 0 0 0 0 0 0 0 61 0 0 0 0 0 0 8 0 0 0 0 0 20 0 0 0 0 73 1 0 736 0 0 0 1 0 0 0 1 4 136 1671 0 +LFQ 78 1 0 0 0 0 0 0 0 0 0 0 0 0 56 0 0 0 0 0 0 16 0 0 0 0 0 21 0 0 0 0 68 1 0 724 0 0 0 1 0 0 0 0 4 148 1655 0 +LFQ 79 1 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 14 0 0 0 0 0 24 0 0 0 0 78 2 0 717 0 0 0 1 0 0 0 2 4 151 1641 0 +LFQ 80 2 0 0 0 0 0 0 0 0 0 0 0 0 51 0 0 0 0 0 0 7 0 0 0 0 0 37 0 0 0 0 76 1 0 700 0 0 0 0 0 0 0 1 6 146 1640 0 +LFQ 81 1 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 10 0 0 0 0 0 13 0 0 0 0 90 2 0 703 0 0 0 0 0 0 0 1 5 142 1638 0 +LFQ 82 2 0 0 0 1 0 0 0 0 0 0 0 0 54 0 0 0 0 0 0 6 0 0 0 0 1 25 0 0 0 0 74 0 0 688 0 0 0 4 0 0 0 0 5 154 1612 0 +LFQ 83 1 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 4 0 0 0 0 0 25 0 0 0 0 71 0 0 700 0 0 0 1 0 0 0 1 5 160 1601 0 +LFQ 84 1 0 0 0 0 0 0 0 0 0 0 0 0 54 0 0 0 0 0 0 10 0 0 0 0 0 20 0 0 0 0 70 1 0 675 0 0 0 0 0 0 0 2 9 135 1612 0 +LFQ 85 1 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 5 0 0 0 0 0 24 0 0 0 0 76 2 0 668 0 0 0 0 0 0 0 0 6 145 1592 0 +LFQ 86 0 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 9 0 0 0 0 0 29 0 0 0 0 84 3 0 642 0 0 0 0 0 0 0 3 6 139 1589 0 +LFQ 87 1 0 0 0 0 0 0 0 0 0 0 0 0 53 0 0 0 0 0 0 7 0 0 0 0 0 27 0 0 0 0 88 1 0 629 0 0 0 0 0 0 0 0 10 147 1562 0 +LFQ 88 1 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 1 0 0 7 0 0 0 0 0 25 0 0 0 0 83 2 0 637 0 0 0 0 0 0 0 3 7 122 1569 0 +LFQ 89 1 0 1 0 0 0 0 0 0 0 0 0 0 44 0 0 0 0 0 0 5 0 0 0 0 0 32 0 0 0 0 75 3 0 621 0 0 0 0 0 0 0 1 7 124 1552 0 +LFQ 90 1 0 0 0 0 0 0 0 0 0 0 0 0 55 0 0 0 0 0 0 2 0 0 0 0 0 20 0 0 0 0 57 0 0 637 0 0 0 0 0 0 0 2 3 137 1531 0 +LFQ 91 1 0 0 0 0 0 0 0 0 0 0 0 0 42 0 0 0 0 0 0 5 0 0 0 0 0 31 0 0 0 0 67 1 0 614 0 0 0 1 0 0 0 1 3 135 1528 0 +LFQ 92 1 0 1 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 3 0 0 0 0 0 24 0 0 0 0 67 3 0 616 0 0 0 1 0 0 0 2 2 149 1498 0 +LFQ 93 1 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 5 0 0 0 0 0 30 0 0 0 0 49 2 0 610 0 0 0 0 0 0 0 1 5 142 1486 0 +LFQ 94 0 0 1 0 0 0 0 0 0 0 0 0 0 48 0 0 0 0 0 0 4 0 0 0 0 0 20 0 0 0 0 73 2 0 588 0 0 0 1 0 0 0 1 8 149 1459 0 +LFQ 95 0 0 0 0 0 0 0 0 0 0 0 0 0 52 0 0 0 0 0 0 2 0 0 0 0 0 30 0 0 0 0 68 2 0 569 0 0 0 0 0 0 0 2 6 143 1460 0 +LFQ 96 1 0 0 0 0 0 0 0 0 0 0 0 0 45 0 0 0 0 0 0 9 0 0 0 0 0 32 0 0 0 0 74 0 0 544 0 0 0 0 0 0 0 1 5 149 1431 0 +LFQ 97 1 0 1 0 0 0 0 0 0 0 0 0 0 52 0 0 0 0 0 0 6 0 0 0 0 0 29 0 0 0 0 62 0 0 530 0 0 0 0 0 0 0 2 5 147 1402 0 +LFQ 98 1 0 0 0 0 0 0 0 0 0 0 0 0 50 0 0 0 0 0 0 10 0 0 0 0 0 27 0 0 0 0 61 2 0 520 0 0 0 2 0 0 0 4 4 148 1374 0 +LFQ 99 1 0 0 0 0 0 0 0 0 0 0 0 0 46 0 0 0 0 0 0 5 0 0 0 0 0 28 0 0 0 0 58 0 0 518 0 0 0 0 0 0 0 1 6 152 1360 0 +LFQ 100 0 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 5 0 0 0 0 0 32 0 0 0 0 66 1 0 502 0 0 0 0 0 0 0 4 7 138 1357 0 +LFQ 101 1 0 1 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 6 0 0 0 0 1 38 0 0 0 0 80 0 0 466 0 0 0 0 0 0 0 0 11 143 1325 0 +LFQ 102 1 0 0 0 0 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 3 0 0 0 0 0 36 0 0 0 0 73 0 0 466 0 0 0 0 0 0 0 1 6 150 1308 0 +LFQ 103 1 0 0 0 0 0 0 0 0 0 0 0 0 40 0 0 0 0 0 0 6 0 0 0 0 0 42 0 0 0 0 77 2 0 445 0 0 0 1 0 0 0 2 4 144 1292 0 +LFQ 104 0 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 1 0 0 6 0 0 0 0 0 46 0 0 0 0 70 1 0 444 0 0 0 0 0 0 0 1 9 149 1269 0 +LFQ 105 0 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 4 0 0 0 0 0 30 0 0 0 0 83 3 0 432 0 0 0 0 0 0 0 6 5 175 1225 0 +LFQ 106 0 0 0 0 1 0 0 0 0 0 0 0 0 39 0 0 0 0 0 0 4 0 0 0 0 0 37 0 0 0 0 81 3 0 417 0 0 0 0 0 0 0 2 4 186 1201 0 +LFQ 107 0 0 1 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 6 0 0 0 0 0 43 0 0 0 0 81 0 0 400 0 0 0 1 0 0 0 1 8 161 1207 0 +LFQ 108 0 0 2 0 0 0 0 0 0 0 0 0 0 33 0 0 0 1 0 0 3 0 0 0 0 0 47 0 0 0 0 108 2 0 365 0 0 0 0 0 0 0 2 3 161 1185 0 +LFQ 109 1 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 1 0 0 2 0 0 0 0 0 45 0 0 0 0 107 0 0 353 0 0 0 0 0 0 0 2 7 178 1155 0 +LFQ 110 1 0 0 0 1 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 10 0 0 0 0 0 44 0 0 0 0 90 2 0 352 0 0 0 0 0 0 0 4 10 166 1146 0 +LFQ 111 0 0 0 0 0 0 0 0 1 0 0 0 0 30 0 0 0 0 0 0 9 0 0 0 0 0 36 0 0 0 0 124 0 0 322 0 0 0 0 0 0 0 3 5 182 1116 0 +LFQ 112 1 0 0 0 0 0 0 0 0 0 0 0 0 31 0 0 0 0 0 0 10 0 0 0 0 0 33 0 0 0 0 118 2 0 322 0 0 0 0 0 0 0 4 7 182 1089 0 +LFQ 113 0 0 0 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 11 0 0 0 0 0 42 0 0 0 0 110 2 0 305 0 0 0 1 0 0 0 2 11 194 1064 0 +LFQ 114 0 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 1 0 0 7 0 0 0 0 0 44 0 0 0 0 120 1 0 285 0 0 0 1 0 0 0 6 9 190 1039 0 +LFQ 115 1 0 0 0 0 0 0 0 0 0 0 0 0 31 0 0 0 2 0 0 12 0 0 0 0 0 39 0 0 0 0 114 2 0 287 0 0 0 1 0 0 0 1 10 187 1024 0 +LFQ 116 1 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 5 0 0 0 0 0 44 0 0 0 0 117 1 0 266 0 0 0 0 0 0 0 4 7 183 997 0 +LFQ 117 0 0 0 0 0 0 0 1 0 0 0 0 0 33 0 0 0 2 0 0 9 0 0 0 0 0 43 0 0 0 0 102 1 0 273 0 0 0 0 0 0 0 3 4 164 992 0 +LFQ 118 0 0 0 0 0 0 0 0 0 0 0 0 0 34 0 0 0 1 0 0 6 0 0 0 0 0 46 0 0 0 0 98 0 0 266 0 0 0 0 0 0 0 2 5 180 956 0 +LFQ 119 0 0 1 0 0 0 0 0 0 0 0 0 0 35 0 0 0 0 0 0 8 0 0 0 0 0 37 0 0 0 0 110 1 0 249 0 0 0 1 0 0 0 2 9 161 946 0 +LFQ 120 0 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 1 0 0 11 0 0 0 0 0 36 0 0 0 0 97 2 0 256 0 0 0 1 0 0 0 4 8 163 914 0 +LFQ 121 0 0 0 0 0 0 0 0 0 0 0 0 0 33 0 0 0 1 0 0 13 0 0 0 0 0 33 0 0 0 0 103 0 0 237 0 0 0 0 0 0 0 3 6 162 917 0 +LFQ 122 0 0 0 0 0 0 0 0 0 0 0 0 0 38 0 0 0 0 0 0 6 0 0 0 0 0 31 0 0 0 0 105 0 0 225 0 0 0 0 0 0 0 0 6 147 911 0 +LFQ 123 0 0 0 0 0 0 0 0 0 0 0 0 0 30 0 0 0 0 0 0 13 0 0 0 0 0 31 0 0 0 0 107 0 0 218 0 0 0 0 0 0 0 2 6 153 879 0 +LFQ 124 0 0 0 0 0 0 0 0 0 0 0 0 0 28 0 0 0 0 0 0 9 0 0 0 0 0 33 0 0 0 0 91 1 0 228 0 0 0 2 0 0 0 2 8 158 851 0 +LFQ 125 0 0 0 0 0 0 0 0 0 0 0 0 0 29 0 0 0 1 0 0 8 0 0 0 0 0 29 0 0 0 0 113 0 0 193 0 0 0 0 0 0 0 3 8 155 836 0 +LFQ 126 0 0 0 0 0 0 0 0 0 0 0 0 0 36 0 0 0 0 0 0 11 0 0 0 0 0 27 0 0 0 0 84 0 0 202 0 0 0 1 0 0 1 3 8 146 820 0 +LFQ 127 1 0 0 0 1 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 12 0 0 0 0 0 35 0 0 0 0 96 1 0 186 0 0 0 0 0 0 0 2 4 138 812 0 +LFQ 128 1 0 0 0 0 0 0 0 0 0 0 0 0 28 0 0 0 1 0 0 7 0 0 0 0 0 26 0 0 0 0 80 0 0 200 0 0 0 0 0 0 0 2 13 141 785 0 +LFQ 129 0 0 0 0 0 0 0 1 0 0 1 0 0 34 0 0 0 1 0 0 6 0 0 0 0 0 27 0 0 0 0 75 3 0 192 0 0 0 0 0 0 0 1 7 138 767 0 +LFQ 130 0 0 1 0 0 0 0 0 0 0 0 0 0 27 0 0 0 0 0 0 4 0 0 0 0 0 28 0 0 0 0 79 2 0 184 0 0 0 0 0 0 0 4 11 133 752 0 +LFQ 131 0 0 0 0 0 0 0 0 0 0 0 0 0 23 0 0 0 1 0 0 6 0 0 0 0 0 21 0 0 0 0 82 1 0 182 0 0 0 1 0 0 0 3 7 113 746 0 +LFQ 132 0 0 0 0 0 0 0 0 0 0 0 0 0 19 0 0 0 0 0 0 9 0 0 0 0 0 18 0 0 0 0 71 0 0 186 0 0 0 0 0 0 0 0 5 134 699 0 +LFQ 133 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 3 0 0 0 0 0 25 0 0 0 0 83 0 0 160 0 0 0 1 0 0 0 3 5 112 699 0 +LFQ 134 0 0 0 0 0 0 0 0 0 0 0 0 0 26 0 0 0 1 0 0 5 0 0 0 0 0 26 0 0 0 0 70 1 0 160 0 0 0 0 0 0 0 0 9 115 684 0 +LFQ 135 0 0 0 0 0 0 0 0 0 0 0 0 0 21 0 0 0 0 0 0 6 0 0 0 0 0 26 0 0 0 0 70 0 0 152 2 0 0 0 0 0 0 0 5 115 675 0 +LFQ 136 0 0 0 0 0 0 0 0 0 0 0 0 0 26 0 0 0 1 0 0 7 0 0 0 0 0 15 0 0 0 0 71 0 0 144 0 0 0 1 0 0 0 0 3 111 662 0 +LFQ 137 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 1 0 0 6 0 0 0 0 0 21 0 0 0 0 62 0 0 138 0 0 0 0 0 0 0 4 5 103 646 0 +LFQ 138 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 4 0 0 0 0 0 21 0 0 0 0 65 1 0 142 1 0 0 0 0 0 0 2 5 111 613 0 +LFQ 139 0 0 0 0 0 0 0 0 0 0 0 0 0 15 0 0 0 0 0 0 10 0 0 0 0 0 15 0 0 0 0 66 0 0 134 0 0 0 1 0 0 0 3 4 109 591 0 +LFQ 140 0 0 0 0 0 0 0 0 0 0 0 0 0 25 0 0 0 0 0 0 5 0 0 0 0 0 12 0 0 0 0 59 0 0 127 0 0 0 1 0 0 0 2 5 97 583 0 +LFQ 141 0 0 0 0 0 0 0 0 0 0 0 0 0 18 0 0 0 0 0 0 5 0 0 0 0 0 16 0 0 0 0 64 0 0 119 0 0 0 0 0 0 0 2 6 84 574 0 +LFQ 142 0 0 0 0 0 0 0 0 0 0 0 0 0 20 0 0 0 1 0 0 3 0 0 0 0 0 20 0 0 0 0 47 0 0 117 0 0 0 1 0 0 0 1 2 88 547 0 +LFQ 143 0 0 5 1 0 0 0 0 0 0 0 1 0 71 0 1 0 5 4 0 12 0 0 0 0 3 34 0 0 0 0 52 18 1 36 2 1 0 5 0 0 1 13 14 124 416 0 +# GC Content of first fragments. Use `grep ^GCF | cut -f 2-` to extract this part. +GCF 7.29 0 +GCF 15.08 1 +GCF 17.09 0 +GCF 18.84 1 +GCF 19.35 2 +GCF 19.85 1 +GCF 20.35 0 +GCF 20.85 5 +GCF 21.36 4 +GCF 21.86 6 +GCF 22.36 20 +GCF 22.86 14 +GCF 23.37 23 +GCF 23.87 57 +GCF 24.37 86 +GCF 24.87 83 +GCF 25.38 107 +GCF 25.88 104 +GCF 26.38 78 +GCF 26.88 77 +GCF 27.39 125 +GCF 27.89 201 +GCF 28.39 258 +GCF 28.89 336 +GCF 29.40 353 +GCF 29.90 254 +GCF 30.40 213 +GCF 30.90 217 +GCF 31.41 195 +GCF 31.91 203 +GCF 32.41 177 +GCF 32.91 153 +GCF 33.42 160 +GCF 33.92 130 +GCF 34.42 104 +GCF 34.92 82 +GCF 35.43 96 +GCF 35.93 100 +GCF 36.43 106 +GCF 36.93 136 +GCF 37.44 138 +GCF 37.94 113 +GCF 38.44 73 +GCF 38.94 35 +GCF 39.45 16 +GCF 39.95 12 +GCF 40.45 8 +GCF 40.95 11 +GCF 41.46 13 +GCF 42.21 11 +GCF 42.96 13 +GCF 43.47 15 +GCF 43.97 12 +GCF 44.47 13 +GCF 44.97 12 +GCF 45.48 10 +GCF 45.98 12 +GCF 46.48 10 +GCF 46.98 5 +GCF 47.49 13 +GCF 47.99 14 +GCF 48.49 13 +GCF 49.25 4 +GCF 50.00 3 +GCF 50.50 2 +GCF 51.01 1 +# GC Content of last fragments. Use `grep ^GCL | cut -f 2-` to extract this part. +GCL 5.53 0 +GCL 11.31 1 +GCL 13.07 0 +GCL 14.82 1 +GCL 15.83 0 +GCL 17.09 2 +GCL 18.09 1 +GCL 18.84 0 +GCL 19.35 1 +GCL 19.85 0 +GCL 20.35 2 +GCL 20.85 6 +GCL 21.36 5 +GCL 21.86 6 +GCL 22.61 16 +GCL 23.37 24 +GCL 23.87 58 +GCL 24.37 94 +GCL 24.87 90 +GCL 25.38 110 +GCL 25.88 106 +GCL 26.38 85 +GCL 26.88 79 +GCL 27.39 120 +GCL 27.89 210 +GCL 28.39 262 +GCL 29.15 342 +GCL 29.90 254 +GCL 30.40 211 +GCL 30.90 227 +GCL 31.41 196 +GCL 31.91 203 +GCL 32.41 176 +GCL 32.91 155 +GCL 33.42 153 +GCL 33.92 122 +GCL 34.42 94 +GCL 34.92 75 +GCL 35.43 96 +GCL 35.93 100 +GCL 36.43 103 +GCL 37.19 141 +GCL 37.94 111 +GCL 38.44 65 +GCL 38.94 34 +GCL 39.45 19 +GCL 39.95 10 +GCL 40.45 5 +GCL 40.95 10 +GCL 41.46 16 +GCL 41.96 12 +GCL 42.46 10 +GCL 42.96 13 +GCL 43.47 15 +GCL 44.22 12 +GCL 45.23 10 +GCL 45.98 13 +GCL 46.48 10 +GCL 46.98 5 +GCL 47.49 12 +GCL 47.99 13 +GCL 48.49 12 +GCL 49.50 3 +GCL 50.50 2 +GCL 51.01 1 +# ACGT content per cycle. Use `grep ^GCC | cut -f 2-` to extract this part. The columns are: cycle; A,C,G,T base counts as a percentage of all A/C/G/T bases [%]; and N and O counts as a percentage of all A/C/G/T bases [%] +GCC 1 33.73 15.23 16.08 34.95 0.05 0.00 +GCC 2 33.26 15.50 17.56 33.68 0.07 0.00 +GCC 3 34.59 15.25 15.74 34.41 0.04 0.00 +GCC 4 34.25 15.59 14.81 35.35 0.07 0.00 +GCC 5 34.04 15.51 14.65 35.80 0.04 0.00 +GCC 6 34.55 15.18 14.90 35.37 0.07 0.00 +GCC 7 33.79 15.99 15.21 35.00 0.04 0.00 +GCC 8 33.09 15.24 16.00 35.66 0.11 0.00 +GCC 9 34.44 14.22 15.75 35.58 0.07 0.00 +GCC 10 34.60 13.75 16.53 35.12 0.07 0.00 +GCC 11 34.33 14.12 15.87 35.68 0.05 0.00 +GCC 12 34.09 13.63 16.18 36.10 0.12 0.00 +GCC 13 33.74 13.78 18.14 34.34 0.07 0.00 +GCC 14 33.91 13.41 17.33 35.35 0.07 0.00 +GCC 15 33.43 15.10 16.95 34.52 0.12 0.00 +GCC 16 35.00 14.64 16.58 33.78 0.14 0.00 +GCC 17 33.22 13.89 16.64 36.25 0.11 0.00 +GCC 18 33.53 14.44 17.40 34.63 0.09 0.00 +GCC 19 32.82 14.78 16.44 35.96 0.09 0.00 +GCC 20 32.91 14.01 15.13 37.95 0.09 0.00 +GCC 21 32.48 14.01 15.89 37.62 0.07 0.00 +GCC 22 33.20 13.15 17.02 36.64 0.11 0.00 +GCC 23 33.37 13.15 16.96 36.52 0.11 0.00 +GCC 24 33.42 14.32 16.55 35.71 0.09 0.00 +GCC 25 34.75 14.16 15.39 35.69 0.14 0.00 +GCC 26 34.77 14.74 15.86 34.63 0.09 0.00 +GCC 27 34.44 14.62 16.41 34.53 0.11 0.00 +GCC 28 33.81 14.80 16.60 34.79 0.14 0.00 +GCC 29 32.78 15.11 17.35 34.76 0.07 0.00 +GCC 30 33.59 14.01 16.28 36.11 0.07 0.00 +GCC 31 31.51 15.00 16.46 37.03 0.14 0.00 +GCC 32 32.85 15.33 15.53 36.29 0.11 0.00 +GCC 33 33.03 14.83 14.74 37.40 0.18 0.00 +GCC 34 31.55 14.77 15.43 38.25 0.14 0.00 +GCC 35 31.62 13.70 17.11 37.56 0.12 0.00 +GCC 36 33.35 13.99 16.95 35.71 0.11 0.00 +GCC 37 34.93 13.37 15.30 36.40 0.11 0.00 +GCC 38 32.95 15.35 16.29 35.40 0.09 0.00 +GCC 39 32.83 14.42 16.44 36.31 0.14 0.00 +GCC 40 32.53 14.47 17.47 35.53 0.11 0.00 +GCC 41 32.82 14.00 16.47 36.71 0.11 0.00 +GCC 42 32.08 15.16 14.97 37.80 0.11 0.00 +GCC 43 32.53 14.39 15.71 37.36 0.09 0.00 +GCC 44 33.32 13.78 16.18 36.71 0.16 0.00 +GCC 45 34.93 14.79 15.20 35.07 0.14 0.00 +GCC 46 33.30 14.49 15.91 36.30 0.09 0.00 +GCC 47 33.27 14.41 15.58 36.74 0.16 0.00 +GCC 48 33.61 13.94 16.09 36.36 0.14 0.00 +GCC 49 33.68 14.10 16.10 36.13 0.09 0.00 +GCC 50 34.40 14.52 16.02 35.06 0.12 0.00 +GCC 51 32.24 16.31 15.61 35.85 0.14 0.00 +GCC 52 30.68 14.79 17.46 37.07 0.09 0.00 +GCC 53 32.12 15.12 17.20 35.56 0.07 0.00 +GCC 54 32.50 15.04 16.66 35.81 0.12 0.00 +GCC 55 31.01 16.58 17.31 35.10 0.16 0.00 +GCC 56 31.83 15.29 16.65 36.23 0.12 0.00 +GCC 57 31.48 15.42 15.83 37.27 0.14 0.00 +GCC 58 33.02 14.52 15.02 37.44 0.18 0.00 +GCC 59 31.54 14.75 16.56 37.15 0.12 0.00 +GCC 60 30.03 14.95 16.88 38.14 0.12 0.00 +GCC 61 30.74 15.64 16.51 37.12 0.12 0.00 +GCC 62 31.90 16.15 15.32 36.63 0.11 0.00 +GCC 63 32.40 14.53 15.56 37.51 0.18 0.00 +GCC 64 30.36 15.07 16.43 38.15 0.13 0.00 +GCC 65 30.70 14.89 16.59 37.82 0.16 0.00 +GCC 66 30.54 16.30 15.44 37.71 0.07 0.00 +GCC 67 30.73 16.55 14.74 37.98 0.09 0.00 +GCC 68 30.99 14.80 16.96 37.26 0.05 0.00 +GCC 69 29.97 15.79 16.10 38.14 0.07 0.00 +GCC 70 29.88 15.26 16.44 38.42 0.07 0.00 +GCC 71 29.89 15.82 16.20 38.08 0.05 0.00 +GCC 72 31.27 16.51 15.56 36.66 0.07 0.00 +GCC 73 30.37 15.27 15.12 39.24 0.04 0.00 +GCC 74 30.08 14.71 15.41 39.80 0.05 0.00 +GCC 75 30.67 14.94 14.61 39.78 0.09 0.00 +GCC 76 29.79 14.84 15.43 39.93 0.09 0.00 +GCC 77 29.44 14.93 16.17 39.47 0.04 0.00 +GCC 78 29.81 15.62 16.34 38.22 0.09 0.00 +GCC 79 31.83 14.51 14.84 38.82 0.06 0.00 +GCC 80 29.94 15.88 14.58 39.60 0.09 0.00 +GCC 81 31.19 15.92 15.18 37.71 0.06 0.00 +GCC 82 31.66 16.19 15.34 36.81 0.04 0.00 +GCC 83 31.18 16.06 13.76 39.01 0.06 0.00 +GCC 84 30.96 16.06 12.89 40.10 0.04 0.00 +GCC 85 30.01 15.23 13.24 41.53 0.10 0.00 +GCC 86 30.81 15.86 14.66 38.68 0.02 0.00 +GCC 87 30.47 16.15 12.72 40.66 0.04 0.00 +GCC 88 31.11 16.50 12.35 40.04 0.02 0.00 +GCC 89 30.24 15.85 11.73 42.19 0.06 0.00 +GCC 90 31.22 14.87 12.36 41.55 0.02 0.00 +GCC 91 30.23 17.01 12.89 39.87 0.02 0.00 +GCC 92 31.36 14.43 13.43 40.78 0.06 0.00 +GCC 93 32.06 15.47 13.66 38.81 0.02 0.00 +GCC 94 32.56 14.86 14.96 37.62 0.02 0.00 +GCC 95 31.75 15.88 14.36 38.01 0.02 0.00 +GCC 96 32.25 16.62 12.71 38.43 0.02 0.00 +GCC 97 31.78 16.01 15.10 37.11 0.04 0.00 +GCC 98 31.47 15.10 15.78 37.65 0.02 0.00 +GCC 99 32.77 16.08 14.79 36.36 0.02 0.00 +GCC 100 31.18 16.81 13.53 38.48 0.00 0.00 +GCC 101 32.53 15.79 12.82 38.85 0.05 0.00 +GCC 102 34.85 14.89 13.38 36.89 0.02 0.00 +GCC 103 33.09 15.40 14.57 36.93 0.02 0.00 +GCC 104 34.25 13.60 14.78 37.37 0.00 0.00 +GCC 105 34.82 15.70 13.12 36.37 0.00 0.00 +GCC 106 36.36 14.74 13.60 35.30 0.00 0.00 +GCC 107 34.51 15.11 13.95 36.43 0.03 0.00 +GCC 108 32.09 16.88 14.37 36.65 0.05 0.00 +GCC 109 33.64 14.78 14.09 37.49 0.03 0.00 +GCC 110 35.15 14.14 15.88 34.83 0.03 0.00 +GCC 111 32.46 15.41 15.41 36.73 0.00 0.00 +GCC 112 30.38 16.47 14.49 38.66 0.03 0.00 +GCC 113 30.38 16.75 13.57 39.30 0.00 0.00 +GCC 114 31.95 16.24 15.52 36.29 0.00 0.00 +GCC 115 31.65 16.61 16.00 35.74 0.03 0.00 +GCC 116 32.63 15.92 13.73 37.72 0.03 0.00 +GCC 117 30.30 15.50 13.72 40.48 0.00 0.00 +GCC 118 31.59 15.86 13.78 38.78 0.00 0.00 +GCC 119 29.82 15.70 15.86 38.62 0.06 0.00 +GCC 120 32.51 15.42 14.86 37.21 0.00 0.00 +GCC 121 30.50 16.06 15.80 37.64 0.00 0.00 +GCC 122 31.72 16.32 15.67 36.29 0.00 0.00 +GCC 123 31.69 15.51 15.51 37.29 0.00 0.00 +GCC 124 31.50 15.71 16.74 36.04 0.00 0.00 +GCC 125 31.78 15.14 16.82 36.26 0.00 0.00 +GCC 126 33.98 16.26 14.92 34.84 0.00 0.00 +GCC 127 36.59 13.61 14.98 34.83 0.04 0.00 +GCC 128 31.58 16.32 16.04 36.07 0.04 0.00 +GCC 129 32.15 14.51 15.23 38.10 0.00 0.00 +GCC 130 32.32 14.77 14.65 38.26 0.04 0.00 +GCC 131 33.14 14.71 15.77 36.39 0.00 0.00 +GCC 132 31.94 14.32 16.61 37.13 0.00 0.00 +GCC 133 34.86 13.79 15.59 35.76 0.00 0.00 +GCC 134 32.53 15.71 18.55 33.21 0.00 0.00 +GCC 135 30.59 14.64 17.77 37.00 0.00 0.00 +GCC 136 31.60 13.15 18.79 36.46 0.00 0.00 +GCC 137 31.35 13.59 18.50 36.56 0.00 0.00 +GCC 138 33.20 12.92 20.58 33.30 0.00 0.00 +GCC 139 33.17 13.39 18.15 35.29 0.00 0.00 +GCC 140 33.84 12.65 17.74 35.76 0.00 0.00 +GCC 141 32.37 12.77 17.18 37.68 0.00 0.00 +GCC 142 33.29 12.32 17.83 36.55 0.00 0.00 +GCC 143 32.56 11.24 16.09 40.11 0.37 0.00 +# ACGT content per cycle, read oriented. Use `grep ^GCT | cut -f 2-` to extract this part. The columns are: cycle; A,C,G,T base counts as a percentage of all A/C/G/T bases [%] +GCT 1 36.58 12.84 18.48 32.10 +GCT 2 36.22 13.80 19.26 30.72 +GCT 3 37.11 13.23 17.77 31.90 +GCT 4 36.98 13.14 17.26 32.62 +GCT 5 38.07 12.38 17.78 31.77 +GCT 6 37.81 12.27 17.81 32.10 +GCT 7 36.21 12.91 18.30 32.59 +GCT 8 35.72 13.64 17.60 33.04 +GCT 9 36.34 12.08 17.90 33.68 +GCT 10 37.67 12.10 18.18 32.05 +GCT 11 36.48 12.75 17.24 33.53 +GCT 12 36.45 12.69 17.13 33.74 +GCT 13 35.19 14.35 17.58 32.88 +GCT 14 35.86 12.95 17.79 33.40 +GCT 15 36.82 14.00 18.05 31.13 +GCT 16 37.26 13.01 18.21 31.52 +GCT 17 37.51 13.08 17.46 31.96 +GCT 18 35.16 14.12 17.72 33.00 +GCT 19 35.16 14.78 16.44 33.62 +GCT 20 34.03 13.30 15.84 36.83 +GCT 21 35.12 12.75 17.15 34.98 +GCT 22 34.87 13.08 17.09 34.97 +GCT 23 34.23 13.27 16.84 35.66 +GCT 24 34.10 13.18 17.69 35.04 +GCT 25 34.81 13.03 16.52 35.64 +GCT 26 34.86 13.54 17.07 34.54 +GCT 27 34.40 13.54 17.49 34.56 +GCT 28 33.32 13.45 17.94 35.29 +GCT 29 34.23 14.47 17.99 33.31 +GCT 30 34.50 13.53 16.76 35.21 +GCT 31 35.20 13.78 17.68 33.34 +GCT 32 32.78 14.18 16.68 36.36 +GCT 33 34.98 13.09 16.48 35.45 +GCT 34 33.88 13.58 16.62 35.92 +GCT 35 33.39 14.11 16.71 35.79 +GCT 36 32.46 13.61 17.32 36.60 +GCT 37 34.27 12.07 16.60 37.06 +GCT 38 34.06 13.54 18.10 34.30 +GCT 39 33.98 13.46 17.40 35.16 +GCT 40 33.55 15.02 16.92 34.52 +GCT 41 35.20 14.57 15.90 34.33 +GCT 42 34.00 13.72 16.40 35.88 +GCT 43 34.03 14.00 16.10 35.87 +GCT 44 34.12 14.23 15.74 35.91 +GCT 45 35.14 14.83 15.17 34.86 +GCT 46 34.12 15.13 15.27 35.49 +GCT 47 34.39 14.60 15.38 35.62 +GCT 48 34.99 14.97 15.06 34.97 +GCT 49 35.63 14.56 15.64 34.17 +GCT 50 36.43 14.61 15.93 33.03 +GCT 51 34.32 15.65 16.27 33.77 +GCT 52 35.36 16.62 15.63 32.39 +GCT 53 35.01 15.21 17.11 32.68 +GCT 54 35.63 15.93 15.77 32.67 +GCT 55 33.93 16.85 17.04 32.18 +GCT 56 36.03 15.86 16.08 32.03 +GCT 57 35.72 15.46 15.79 33.03 +GCT 58 36.44 14.57 14.96 34.02 +GCT 59 34.96 16.46 14.85 33.73 +GCT 60 34.75 16.61 15.22 33.42 +GCT 61 34.73 16.81 15.33 33.13 +GCT 62 36.49 15.41 16.07 32.04 +GCT 63 35.69 14.65 15.44 34.22 +GCT 64 33.57 15.32 16.17 34.94 +GCT 65 35.42 15.12 16.36 33.09 +GCT 66 34.43 15.55 16.19 33.82 +GCT 67 36.56 16.42 14.86 32.15 +GCT 68 35.24 16.51 15.25 33.00 +GCT 69 35.89 16.19 15.70 32.22 +GCT 70 35.74 15.39 16.31 32.56 +GCT 71 35.12 15.48 16.55 32.86 +GCT 72 36.28 16.36 15.71 31.65 +GCT 73 36.49 15.27 15.12 33.12 +GCT 74 36.00 15.06 15.06 33.88 +GCT 75 35.02 14.25 15.31 35.42 +GCT 76 33.67 15.61 14.66 36.06 +GCT 77 33.99 15.78 15.32 34.91 +GCT 78 34.47 16.31 15.66 33.56 +GCT 79 35.53 15.03 14.32 35.12 +GCT 80 34.22 16.55 13.91 35.32 +GCT 81 34.06 16.94 14.16 34.84 +GCT 82 33.49 16.12 15.41 34.98 +GCT 83 33.37 16.39 13.43 36.82 +GCT 84 34.51 16.41 12.54 36.54 +GCT 85 35.36 15.54 12.92 36.18 +GCT 86 35.87 16.56 13.95 33.61 +GCT 87 36.14 16.15 12.72 34.99 +GCT 88 34.86 15.78 13.07 36.29 +GCT 89 36.49 15.48 12.09 35.94 +GCT 90 35.66 14.38 12.85 37.11 +GCT 91 34.51 16.17 13.74 35.58 +GCT 92 36.35 15.74 12.12 35.79 +GCT 93 37.04 15.83 13.30 33.82 +GCT 94 36.03 16.49 13.33 34.16 +GCT 95 35.13 15.86 14.38 34.62 +GCT 96 36.29 15.90 13.43 34.39 +GCT 97 34.38 16.04 15.07 34.51 +GCT 98 34.97 16.60 14.28 34.15 +GCT 99 35.63 17.13 13.73 33.51 +GCT 100 35.04 17.97 12.37 34.62 +GCT 101 36.07 16.41 12.21 35.31 +GCT 102 35.95 15.95 12.32 35.78 +GCT 103 35.69 16.42 13.55 34.33 +GCT 104 36.71 15.83 12.54 34.92 +GCT 105 35.89 16.97 11.85 35.29 +GCT 106 37.78 16.00 12.33 33.88 +GCT 107 34.71 16.75 12.31 36.23 +GCT 108 33.61 16.91 14.35 35.13 +GCT 109 33.72 15.23 13.64 37.41 +GCT 110 33.73 16.96 13.06 36.25 +GCT 111 33.42 18.23 12.59 35.77 +GCT 112 33.35 17.52 13.44 35.69 +GCT 113 33.19 16.78 13.54 36.49 +GCT 114 31.64 17.33 14.43 36.61 +GCT 115 31.76 18.46 14.16 35.62 +GCT 116 33.38 16.35 13.31 36.97 +GCT 117 31.41 16.15 13.07 39.37 +GCT 118 34.41 16.99 12.65 35.95 +GCT 119 32.94 19.20 12.36 35.51 +GCT 120 33.20 17.23 13.05 36.52 +GCT 121 32.36 18.62 13.24 35.78 +GCT 122 31.69 18.88 13.12 36.32 +GCT 123 30.82 18.61 12.42 38.16 +GCT 124 30.51 19.76 12.70 37.03 +GCT 125 32.33 18.89 13.07 35.71 +GCT 126 31.78 18.09 13.08 37.05 +GCT 127 35.79 16.65 11.93 35.63 +GCT 128 31.73 17.56 14.79 35.91 +GCT 129 32.59 17.07 12.67 37.66 +GCT 130 33.80 17.72 11.70 36.78 +GCT 131 34.32 17.75 12.72 35.21 +GCT 132 33.96 18.59 12.35 35.11 +GCT 133 34.23 15.86 13.52 36.39 +GCT 134 30.65 19.64 14.62 35.08 +GCT 135 32.13 18.62 13.80 35.45 +GCT 136 30.97 18.93 13.01 37.09 +GCT 137 30.41 17.96 14.14 37.50 +GCT 138 30.75 20.17 13.33 35.75 +GCT 139 32.65 18.89 12.65 35.82 +GCT 140 33.73 18.57 11.83 35.87 +GCT 141 32.09 16.33 13.62 37.97 +GCT 142 32.41 16.47 13.68 37.44 +GCT 143 32.43 16.71 10.63 40.23 +# ACGT content per cycle for first fragments. Use `grep ^FBC | cut -f 2-` to extract this part. The columns are: cycle; A,C,G,T base counts as a percentage of all A/C/G/T bases [%]; and N and O counts as a percentage of all A/C/G/T bases [%] +FBC 1 34.09 15.25 16.11 34.55 0.07 0.00 +FBC 2 33.51 15.96 16.74 33.79 0.04 0.00 +FBC 3 34.70 14.46 15.92 34.92 0.00 0.00 +FBC 4 34.08 15.92 14.79 35.21 0.04 0.00 +FBC 5 34.42 15.38 15.03 35.16 0.00 0.00 +FBC 6 34.72 14.72 15.14 35.43 0.04 0.00 +FBC 7 34.46 15.88 15.31 34.35 0.00 0.00 +FBC 8 33.88 15.04 15.75 35.33 0.07 0.00 +FBC 9 34.04 14.61 15.78 35.57 0.04 0.00 +FBC 10 34.82 13.16 16.95 35.07 0.04 0.00 +FBC 11 34.10 14.14 15.60 36.16 0.00 0.00 +FBC 12 34.68 13.35 16.19 35.78 0.14 0.00 +FBC 13 33.81 14.40 17.81 33.98 0.07 0.00 +FBC 14 34.11 13.40 17.48 35.00 0.04 0.00 +FBC 15 33.33 15.55 16.68 34.43 0.14 0.00 +FBC 16 34.22 14.52 17.32 33.94 0.14 0.00 +FBC 17 32.82 13.27 16.64 37.26 0.11 0.00 +FBC 18 33.71 15.08 17.28 33.92 0.11 0.00 +FBC 19 32.67 14.83 16.42 36.08 0.07 0.00 +FBC 20 32.74 14.44 15.04 37.78 0.07 0.00 +FBC 21 32.29 13.97 16.06 37.68 0.00 0.00 +FBC 22 32.82 13.38 17.28 36.52 0.11 0.00 +FBC 23 33.43 12.53 16.93 37.12 0.11 0.00 +FBC 24 33.63 14.51 16.28 35.58 0.07 0.00 +FBC 25 34.69 13.99 15.06 36.26 0.18 0.00 +FBC 26 35.15 15.40 15.29 34.16 0.07 0.00 +FBC 27 34.00 14.58 16.11 35.31 0.11 0.00 +FBC 28 33.45 14.60 17.37 34.59 0.18 0.00 +FBC 29 32.27 15.21 18.12 34.40 0.04 0.00 +FBC 30 34.18 14.08 15.89 35.85 0.04 0.00 +FBC 31 31.26 15.10 16.91 36.73 0.18 0.00 +FBC 32 33.51 15.26 15.16 36.07 0.11 0.00 +FBC 33 33.03 15.32 14.54 37.11 0.25 0.00 +FBC 34 30.80 14.42 15.63 39.15 0.18 0.00 +FBC 35 31.00 14.10 17.86 37.04 0.14 0.00 +FBC 36 33.46 13.80 16.75 35.98 0.07 0.00 +FBC 37 35.46 13.38 15.19 35.96 0.11 0.00 +FBC 38 33.43 14.98 16.11 35.49 0.07 0.00 +FBC 39 32.84 14.48 16.90 35.78 0.11 0.00 +FBC 40 33.19 14.16 17.96 34.68 0.11 0.00 +FBC 41 32.13 13.99 16.79 37.10 0.11 0.00 +FBC 42 32.03 15.48 14.42 38.07 0.11 0.00 +FBC 43 33.26 14.41 15.65 36.67 0.07 0.00 +FBC 44 33.84 13.58 15.85 36.72 0.21 0.00 +FBC 45 34.60 14.71 15.52 35.17 0.14 0.00 +FBC 46 32.88 14.74 15.98 36.40 0.07 0.00 +FBC 47 32.79 14.83 15.40 36.98 0.21 0.00 +FBC 48 33.99 14.54 15.71 35.76 0.18 0.00 +FBC 49 33.45 13.96 16.26 36.33 0.07 0.00 +FBC 50 33.69 14.62 16.08 35.61 0.14 0.00 +FBC 51 32.16 16.15 15.44 36.25 0.14 0.00 +FBC 52 30.94 14.69 16.75 37.62 0.07 0.00 +FBC 53 32.23 15.12 17.22 35.43 0.04 0.00 +FBC 54 32.55 15.38 16.52 35.54 0.14 0.00 +FBC 55 30.94 17.04 17.65 34.37 0.21 0.00 +FBC 56 31.92 15.18 17.10 35.80 0.14 0.00 +FBC 57 31.60 14.82 15.96 37.62 0.14 0.00 +FBC 58 33.31 14.59 14.87 37.23 0.25 0.00 +FBC 59 31.90 15.29 16.25 36.56 0.14 0.00 +FBC 60 29.60 15.34 17.19 37.87 0.14 0.00 +FBC 61 30.86 15.13 16.77 37.25 0.14 0.00 +FBC 62 31.18 16.18 15.79 36.86 0.11 0.00 +FBC 63 33.06 14.31 15.67 36.96 0.25 0.00 +FBC 64 30.43 15.11 16.65 37.81 0.14 0.00 +FBC 65 30.88 14.73 16.34 38.05 0.21 0.00 +FBC 66 30.22 16.45 15.38 37.96 0.07 0.00 +FBC 67 31.04 16.65 14.68 37.64 0.11 0.00 +FBC 68 31.15 14.73 17.21 36.90 0.04 0.00 +FBC 69 29.94 16.05 16.23 37.78 0.07 0.00 +FBC 70 29.91 15.10 16.43 38.56 0.04 0.00 +FBC 71 30.05 15.37 16.24 38.34 0.04 0.00 +FBC 72 30.68 16.45 16.19 36.67 0.04 0.00 +FBC 73 29.85 15.33 15.36 39.46 0.04 0.00 +FBC 74 30.33 15.04 15.36 39.27 0.00 0.00 +FBC 75 31.03 15.04 14.16 39.77 0.11 0.00 +FBC 76 30.10 14.81 15.21 39.88 0.11 0.00 +FBC 77 28.60 14.89 15.85 40.66 0.00 0.00 +FBC 78 29.78 15.54 16.28 38.40 0.15 0.00 +FBC 79 31.34 14.87 15.09 38.70 0.07 0.00 +FBC 80 29.67 15.62 14.91 39.80 0.11 0.00 +FBC 81 30.87 16.55 15.45 37.14 0.08 0.00 +FBC 82 31.50 16.42 15.39 36.69 0.00 0.00 +FBC 83 30.02 16.62 14.13 39.23 0.08 0.00 +FBC 84 30.85 15.31 13.18 40.66 0.04 0.00 +FBC 85 30.17 15.47 12.93 41.42 0.16 0.00 +FBC 86 31.37 15.78 14.76 38.08 0.04 0.00 +FBC 87 30.56 16.41 12.64 40.39 0.04 0.00 +FBC 88 30.67 16.76 13.03 39.53 0.00 0.00 +FBC 89 29.71 16.27 11.77 42.25 0.04 0.00 +FBC 90 31.34 14.16 12.48 42.02 0.00 0.00 +FBC 91 30.60 16.14 12.93 40.32 0.00 0.00 +FBC 92 30.78 14.52 13.14 41.56 0.04 0.00 +FBC 93 32.80 15.03 13.81 38.36 0.00 0.00 +FBC 94 32.91 14.97 14.75 37.37 0.00 0.00 +FBC 95 32.13 15.79 14.71 37.37 0.04 0.00 +FBC 96 31.62 16.77 13.01 38.60 0.00 0.00 +FBC 97 32.07 15.65 15.47 36.81 0.00 0.00 +FBC 98 32.11 14.85 15.80 37.24 0.00 0.00 +FBC 99 33.12 15.64 15.32 35.92 0.00 0.00 +FBC 100 30.84 16.23 13.95 38.98 0.00 0.00 +FBC 101 33.24 15.62 12.49 38.65 0.00 0.00 +FBC 102 34.92 14.41 13.59 37.08 0.00 0.00 +FBC 103 32.65 15.33 14.94 37.08 0.00 0.00 +FBC 104 34.58 13.08 14.46 37.88 0.00 0.00 +FBC 105 34.40 15.80 13.45 36.35 0.00 0.00 +FBC 106 35.66 15.10 13.22 36.02 0.00 0.00 +FBC 107 34.69 15.42 13.87 36.02 0.00 0.00 +FBC 108 31.83 17.43 14.35 36.39 0.00 0.00 +FBC 109 32.91 15.23 13.91 37.95 0.00 0.00 +FBC 110 35.22 13.71 15.48 35.59 0.00 0.00 +FBC 111 32.48 15.28 14.95 37.29 0.00 0.00 +FBC 112 30.55 16.69 14.75 38.01 0.00 0.00 +FBC 113 30.42 17.35 14.25 37.97 0.00 0.00 +FBC 114 32.26 16.85 15.53 35.37 0.00 0.00 +FBC 115 31.07 17.14 15.68 36.10 0.00 0.00 +FBC 116 32.41 16.39 13.31 37.89 0.00 0.00 +FBC 117 30.23 15.83 13.61 40.33 0.00 0.00 +FBC 118 30.92 15.96 13.64 39.47 0.00 0.00 +FBC 119 29.50 16.07 15.55 38.88 0.06 0.00 +FBC 120 33.18 14.88 15.08 36.87 0.00 0.00 +FBC 121 30.76 16.15 16.15 36.94 0.00 0.00 +FBC 122 31.86 16.71 15.69 35.74 0.00 0.00 +FBC 123 32.17 15.53 14.97 37.33 0.00 0.00 +FBC 124 31.68 15.84 16.48 36.01 0.00 0.00 +FBC 125 32.43 14.87 16.69 36.01 0.00 0.00 +FBC 126 35.10 15.72 14.45 34.73 0.00 0.00 +FBC 127 35.80 13.82 15.11 35.27 0.00 0.00 +FBC 128 32.06 16.34 15.79 35.81 0.00 0.00 +FBC 129 32.05 14.34 15.54 38.06 0.00 0.00 +FBC 130 31.15 14.75 14.75 39.34 0.00 0.00 +FBC 131 32.63 14.15 16.36 36.86 0.00 0.00 +FBC 132 31.81 14.54 16.48 37.18 0.00 0.00 +FBC 133 35.14 13.69 15.23 35.95 0.00 0.00 +FBC 134 31.78 16.30 18.50 33.42 0.00 0.00 +FBC 135 30.21 14.35 17.54 37.90 0.00 0.00 +FBC 136 31.59 12.75 18.94 36.71 0.00 0.00 +FBC 137 30.85 14.23 19.60 35.32 0.00 0.00 +FBC 138 33.71 13.11 20.80 32.38 0.00 0.00 +FBC 139 33.12 13.06 18.37 35.46 0.00 0.00 +FBC 140 33.74 12.75 17.47 36.04 0.00 0.00 +FBC 141 32.65 13.61 17.35 36.39 0.00 0.00 +FBC 142 31.63 12.84 19.02 36.50 0.00 0.00 +FBC 143 31.49 11.32 16.11 41.08 0.12 0.00 +# ACGT raw counters for first fragments. Use `grep ^FTC | cut -f 2-` to extract this part. The columns are: A,C,G,T,N base counters +FTC 108689 50470 52527 124018 240 +# ACGT content per cycle for last fragments. Use `grep ^LBC | cut -f 2-` to extract this part. The columns are: cycle; A,C,G,T base counts as a percentage of all A/C/G/T bases [%]; and N and O counts as a percentage of all A/C/G/T bases [%] +LBC 1 33.37 15.21 16.06 35.35 0.04 0.00 +LBC 2 33.00 15.05 18.38 33.57 0.11 0.00 +LBC 3 34.48 16.03 15.57 33.91 0.07 0.00 +LBC 4 34.42 15.26 14.83 35.49 0.11 0.00 +LBC 5 33.66 15.64 14.26 36.43 0.07 0.00 +LBC 6 34.39 15.65 14.66 35.31 0.11 0.00 +LBC 7 33.13 16.11 15.11 35.65 0.07 0.00 +LBC 8 32.30 15.44 16.26 36.00 0.14 0.00 +LBC 9 34.85 13.84 15.72 35.59 0.11 0.00 +LBC 10 34.39 14.34 16.11 35.17 0.11 0.00 +LBC 11 34.56 14.09 16.15 35.20 0.11 0.00 +LBC 12 33.50 13.91 16.18 36.41 0.11 0.00 +LBC 13 33.66 13.16 18.48 34.69 0.07 0.00 +LBC 14 33.71 13.41 17.18 35.70 0.11 0.00 +LBC 15 33.53 14.66 17.21 34.60 0.11 0.00 +LBC 16 35.78 14.77 15.83 33.62 0.14 0.00 +LBC 17 33.61 14.51 16.64 35.24 0.11 0.00 +LBC 18 33.35 13.80 17.52 35.33 0.07 0.00 +LBC 19 32.97 14.73 16.47 35.84 0.11 0.00 +LBC 20 33.07 13.59 15.22 38.11 0.11 0.00 +LBC 21 32.66 14.06 15.73 37.56 0.14 0.00 +LBC 22 33.57 12.92 16.75 36.76 0.11 0.00 +LBC 23 33.32 13.77 17.00 35.91 0.11 0.00 +LBC 24 33.22 14.12 16.82 35.84 0.11 0.00 +LBC 25 34.81 14.34 15.72 35.13 0.11 0.00 +LBC 26 34.39 14.09 16.43 35.10 0.11 0.00 +LBC 27 34.88 14.66 16.71 33.75 0.11 0.00 +LBC 28 34.17 15.01 15.83 34.99 0.11 0.00 +LBC 29 33.29 15.01 16.57 35.13 0.11 0.00 +LBC 30 33.00 13.95 16.68 36.37 0.11 0.00 +LBC 31 31.76 14.90 16.00 37.33 0.11 0.00 +LBC 32 32.19 15.40 15.90 36.52 0.11 0.00 +LBC 33 33.04 14.34 14.94 37.69 0.11 0.00 +LBC 34 32.30 15.12 15.23 37.34 0.11 0.00 +LBC 35 32.23 13.31 16.36 38.09 0.11 0.00 +LBC 36 33.24 14.17 17.15 35.44 0.14 0.00 +LBC 37 34.40 13.35 15.41 36.85 0.11 0.00 +LBC 38 32.48 15.73 16.47 35.32 0.11 0.00 +LBC 39 32.82 14.35 15.99 36.84 0.18 0.00 +LBC 40 31.88 14.77 16.97 36.39 0.11 0.00 +LBC 41 33.51 14.02 16.15 36.32 0.11 0.00 +LBC 42 32.13 14.84 15.51 37.52 0.11 0.00 +LBC 43 31.81 14.38 15.76 38.05 0.11 0.00 +LBC 44 32.80 13.99 16.51 36.71 0.11 0.00 +LBC 45 35.26 14.88 14.88 34.98 0.14 0.00 +LBC 46 33.72 14.24 15.83 36.21 0.11 0.00 +LBC 47 33.76 13.99 15.76 36.49 0.11 0.00 +LBC 48 33.23 13.35 16.47 36.95 0.11 0.00 +LBC 49 33.90 14.24 15.94 35.92 0.11 0.00 +LBC 50 35.11 14.43 15.96 34.51 0.11 0.00 +LBC 51 32.31 16.46 15.78 35.44 0.14 0.00 +LBC 52 30.43 14.90 18.17 36.51 0.11 0.00 +LBC 53 32.02 15.12 17.18 35.68 0.11 0.00 +LBC 54 32.44 14.69 16.79 36.07 0.11 0.00 +LBC 55 31.07 16.12 16.98 35.84 0.11 0.00 +LBC 56 31.74 15.41 16.19 36.65 0.11 0.00 +LBC 57 31.36 16.02 15.70 36.92 0.14 0.00 +LBC 58 32.74 14.45 15.16 37.65 0.11 0.00 +LBC 59 31.19 14.20 16.87 37.74 0.11 0.00 +LBC 60 30.46 14.57 16.57 38.40 0.11 0.00 +LBC 61 30.61 16.14 16.25 36.99 0.11 0.00 +LBC 62 32.62 16.13 14.85 36.40 0.11 0.00 +LBC 63 31.74 14.74 15.46 38.06 0.11 0.00 +LBC 64 30.29 15.02 16.20 38.48 0.11 0.00 +LBC 65 30.51 15.06 16.85 37.59 0.11 0.00 +LBC 66 30.87 16.15 15.51 37.46 0.07 0.00 +LBC 67 30.43 16.45 14.80 38.32 0.07 0.00 +LBC 68 30.82 14.87 16.70 37.61 0.07 0.00 +LBC 69 30.00 15.54 15.97 38.49 0.07 0.00 +LBC 70 29.85 15.43 16.44 38.28 0.11 0.00 +LBC 71 29.73 16.27 16.17 37.83 0.07 0.00 +LBC 72 31.86 16.56 14.93 36.65 0.11 0.00 +LBC 73 30.90 15.21 14.88 39.01 0.04 0.00 +LBC 74 29.84 14.39 15.45 40.32 0.11 0.00 +LBC 75 30.31 14.84 15.06 39.78 0.07 0.00 +LBC 76 29.49 14.87 15.64 39.99 0.07 0.00 +LBC 77 30.27 14.97 16.48 38.27 0.07 0.00 +LBC 78 29.84 15.70 16.41 38.05 0.04 0.00 +LBC 79 32.33 14.15 14.59 38.93 0.04 0.00 +LBC 80 30.21 16.14 14.26 39.40 0.08 0.00 +LBC 81 31.52 15.29 14.91 38.28 0.04 0.00 +LBC 82 31.82 15.97 15.28 36.93 0.08 0.00 +LBC 83 32.34 15.50 13.39 38.78 0.04 0.00 +LBC 84 31.07 16.81 12.60 39.53 0.04 0.00 +LBC 85 29.85 14.98 13.54 41.63 0.04 0.00 +LBC 86 30.25 15.93 14.55 39.27 0.00 0.00 +LBC 87 30.39 15.89 12.80 40.93 0.04 0.00 +LBC 88 31.56 16.24 11.67 40.54 0.04 0.00 +LBC 89 30.76 15.42 11.69 42.13 0.08 0.00 +LBC 90 31.10 15.59 12.23 41.08 0.04 0.00 +LBC 91 29.86 17.87 12.85 39.42 0.04 0.00 +LBC 92 31.93 14.35 13.72 40.00 0.08 0.00 +LBC 93 31.31 15.91 13.51 39.27 0.04 0.00 +LBC 94 32.21 14.75 15.17 37.87 0.04 0.00 +LBC 95 31.36 15.98 14.01 38.65 0.00 0.00 +LBC 96 32.88 16.46 12.40 38.25 0.04 0.00 +LBC 97 31.50 16.38 14.72 37.40 0.09 0.00 +LBC 98 30.84 15.35 15.76 38.06 0.05 0.00 +LBC 99 32.43 16.51 14.26 36.80 0.05 0.00 +LBC 100 31.52 17.39 13.11 37.98 0.00 0.00 +LBC 101 31.83 15.96 13.16 39.05 0.10 0.00 +LBC 102 34.77 15.37 13.16 36.70 0.05 0.00 +LBC 103 33.53 15.47 14.21 36.79 0.05 0.00 +LBC 104 33.92 14.11 15.09 36.87 0.00 0.00 +LBC 105 35.23 15.59 12.79 36.38 0.00 0.00 +LBC 106 37.06 14.38 13.97 34.58 0.00 0.00 +LBC 107 34.33 14.80 14.03 36.84 0.05 0.00 +LBC 108 32.36 16.34 14.40 36.91 0.10 0.00 +LBC 109 34.38 14.32 14.27 37.03 0.05 0.00 +LBC 110 35.09 14.56 16.28 34.07 0.05 0.00 +LBC 111 32.44 15.54 15.86 36.16 0.00 0.00 +LBC 112 30.20 16.24 14.24 39.32 0.06 0.00 +LBC 113 30.33 16.15 12.89 40.63 0.00 0.00 +LBC 114 31.65 15.62 15.51 37.22 0.00 0.00 +LBC 115 32.22 16.08 16.32 35.38 0.06 0.00 +LBC 116 32.85 15.46 14.14 37.55 0.06 0.00 +LBC 117 30.36 15.18 13.83 40.63 0.00 0.00 +LBC 118 32.25 15.75 13.93 38.08 0.00 0.00 +LBC 119 30.15 15.33 16.16 38.36 0.06 0.00 +LBC 120 31.85 15.96 14.64 37.56 0.00 0.00 +LBC 121 30.24 15.98 15.45 38.33 0.00 0.00 +LBC 122 31.59 15.93 15.66 36.83 0.00 0.00 +LBC 123 31.20 15.50 16.05 37.25 0.00 0.00 +LBC 124 31.33 15.59 17.01 36.07 0.00 0.00 +LBC 125 31.13 15.42 16.95 36.51 0.00 0.00 +LBC 126 32.86 16.80 15.38 34.95 0.00 0.00 +LBC 127 37.37 13.39 14.84 34.40 0.08 0.00 +LBC 128 31.10 16.29 16.29 36.32 0.08 0.00 +LBC 129 32.24 14.68 14.92 38.15 0.00 0.00 +LBC 130 33.50 14.79 14.54 37.17 0.08 0.00 +LBC 131 33.64 15.26 15.18 35.92 0.00 0.00 +LBC 132 32.08 14.11 16.74 37.07 0.00 0.00 +LBC 133 34.59 13.89 15.95 35.57 0.00 0.00 +LBC 134 33.27 15.13 18.60 33.00 0.00 0.00 +LBC 135 30.97 14.93 18.00 36.10 0.00 0.00 +LBC 136 31.60 13.54 18.64 36.22 0.00 0.00 +LBC 137 31.85 12.96 17.41 37.78 0.00 0.00 +LBC 138 32.69 12.73 20.37 34.22 0.00 0.00 +LBC 139 33.23 13.71 17.93 35.13 0.00 0.00 +LBC 140 33.95 12.55 18.01 35.48 0.00 0.00 +LBC 141 32.09 11.94 17.00 38.96 0.00 0.00 +LBC 142 34.95 11.81 16.65 36.60 0.00 0.00 +LBC 143 33.62 11.17 16.07 39.14 0.61 0.00 +# ACGT raw counters for last fragments. Use `grep ^LTC | cut -f 2-` to extract this part. The columns are: A,C,G,T,N base counters +LTC 108882 50371 52310 124355 269 +# Insert sizes. Use `grep ^IS | cut -f 2-` to extract this part. The columns are: insert size, pairs total, inward oriented pairs, outward oriented pairs, other pairs +IS 0 0 0 0 0 +IS 1 0 0 0 0 +IS 2 0 0 0 0 +IS 3 0 0 0 0 +IS 4 0 0 0 0 +IS 5 0 0 0 0 +IS 6 0 0 0 0 +IS 7 0 0 0 0 +IS 8 0 0 0 0 +IS 9 0 0 0 0 +IS 10 0 0 0 0 +IS 11 0 0 0 0 +IS 12 0 0 0 0 +IS 13 0 0 0 0 +IS 14 0 0 0 0 +IS 15 0 0 0 0 +IS 16 0 0 0 0 +IS 17 0 0 0 0 +IS 18 0 0 0 0 +IS 19 0 0 0 0 +IS 20 0 0 0 0 +IS 21 0 0 0 0 +IS 22 0 0 0 0 +IS 23 0 0 0 0 +IS 24 0 0 0 0 +IS 25 0 0 0 0 +IS 26 0 0 0 0 +IS 27 0 0 0 0 +IS 28 0 0 0 0 +IS 29 0 0 0 0 +IS 30 0 0 0 0 +IS 31 0 0 0 0 +IS 32 1 0 1 0 +IS 33 0 0 0 0 +IS 34 0 0 0 0 +IS 35 0 0 0 0 +IS 36 0 0 0 0 +IS 37 0 0 0 0 +IS 38 0 0 0 0 +IS 39 0 0 0 0 +IS 40 0 0 0 0 +IS 41 1 1 0 0 +IS 42 0 0 0 0 +IS 43 0 0 0 0 +IS 44 0 0 0 0 +IS 45 0 0 0 0 +IS 46 0 0 0 0 +IS 47 0 0 0 0 +IS 48 0 0 0 0 +IS 49 3 3 0 0 +IS 50 0 0 0 0 +IS 51 1 1 0 0 +IS 52 2 2 0 0 +IS 53 0 0 0 0 +IS 54 1 1 0 0 +IS 55 0 0 0 0 +IS 56 0 0 0 0 +IS 57 0 0 0 0 +IS 58 1 1 0 0 +IS 59 2 2 0 0 +IS 60 1 1 0 0 +IS 61 4 4 0 0 +IS 62 1 1 0 0 +IS 63 5 5 0 0 +IS 64 0 0 0 0 +IS 65 5 5 0 0 +IS 66 2 2 0 0 +IS 67 6 6 0 0 +IS 68 3 3 0 0 +IS 69 5 5 0 0 +IS 70 10 10 0 0 +IS 71 11 11 0 0 +IS 72 7 7 0 0 +IS 73 8 8 0 0 +IS 74 4 4 0 0 +IS 75 12 12 0 0 +IS 76 11 11 0 0 +IS 77 19 19 0 0 +IS 78 15 15 0 0 +IS 79 13 13 0 0 +IS 80 17 17 0 0 +IS 81 24 24 0 0 +IS 82 18 18 0 0 +IS 83 19 19 0 0 +IS 84 25 25 0 0 +IS 85 15 15 0 0 +IS 86 24 24 0 0 +IS 87 30 30 0 0 +IS 88 29 29 0 0 +IS 89 21 21 0 0 +IS 90 16 16 0 0 +IS 91 24 24 0 0 +IS 92 30 30 0 0 +IS 93 23 23 0 0 +IS 94 21 20 1 0 +IS 95 43 43 0 0 +IS 96 54 54 0 0 +IS 97 34 34 0 0 +IS 98 28 28 0 0 +IS 99 24 24 0 0 +IS 100 44 44 0 0 +IS 101 24 24 0 0 +IS 102 27 27 0 0 +IS 103 22 22 0 0 +IS 104 33 33 0 0 +IS 105 26 26 0 0 +IS 106 28 28 0 0 +IS 107 35 35 0 0 +IS 108 26 26 0 0 +IS 109 24 24 0 0 +IS 110 34 34 0 0 +IS 111 29 29 0 0 +IS 112 22 22 0 0 +IS 113 36 36 0 0 +IS 114 30 30 0 0 +IS 115 49 49 0 0 +IS 116 36 35 1 0 +IS 117 33 33 0 0 +IS 118 34 34 0 0 +IS 119 38 38 0 0 +IS 120 14 14 0 0 +IS 121 39 39 0 0 +IS 122 30 30 0 0 +IS 123 28 28 0 0 +IS 124 36 35 1 0 +IS 125 36 36 0 0 +IS 126 25 25 0 0 +IS 127 32 32 0 0 +IS 128 31 31 0 0 +IS 129 28 28 0 0 +IS 130 39 39 0 0 +IS 131 45 44 1 0 +IS 132 25 25 0 0 +IS 133 18 18 0 0 +IS 134 25 25 0 0 +IS 135 31 31 0 0 +IS 136 30 29 1 0 +IS 137 29 29 0 0 +IS 138 34 34 0 0 +IS 139 32 32 0 0 +IS 140 28 28 0 0 +IS 141 41 41 0 0 +IS 142 27 27 0 0 +IS 143 23 23 0 0 +IS 144 26 26 0 0 +IS 145 31 31 0 0 +IS 146 21 21 0 0 +IS 147 29 29 0 0 +IS 148 18 18 0 0 +IS 149 17 17 0 0 +IS 150 19 19 0 0 +IS 151 20 20 0 0 +IS 152 28 28 0 0 +IS 153 28 28 0 0 +IS 154 18 18 0 0 +IS 155 23 23 0 0 +IS 156 20 20 0 0 +IS 157 29 29 0 0 +IS 158 16 16 0 0 +IS 159 15 15 0 0 +IS 160 14 14 0 0 +IS 161 18 18 0 0 +IS 162 19 19 0 0 +IS 163 15 15 0 0 +IS 164 9 9 0 0 +IS 165 11 11 0 0 +IS 166 21 21 0 0 +IS 167 9 9 0 0 +IS 168 17 17 0 0 +IS 169 16 16 0 0 +IS 170 17 17 0 0 +IS 171 13 13 0 0 +IS 172 14 14 0 0 +IS 173 21 21 0 0 +IS 174 9 9 0 0 +IS 175 9 9 0 0 +IS 176 7 7 0 0 +IS 177 9 9 0 0 +IS 178 9 9 0 0 +IS 179 9 9 0 0 +IS 180 2 2 0 0 +IS 181 8 8 0 0 +IS 182 8 8 0 0 +IS 183 3 3 0 0 +IS 184 12 12 0 0 +IS 185 10 10 0 0 +IS 186 5 5 0 0 +IS 187 7 7 0 0 +IS 188 1 1 0 0 +IS 189 5 5 0 0 +IS 190 8 8 0 0 +IS 191 10 10 0 0 +IS 192 8 8 0 0 +IS 193 2 2 0 0 +IS 194 6 6 0 0 +IS 195 1 1 0 0 +IS 196 2 2 0 0 +IS 197 3 3 0 0 +IS 198 2 2 0 0 +IS 199 4 4 0 0 +IS 200 7 7 0 0 +IS 201 2 2 0 0 +IS 202 6 6 0 0 +IS 203 4 4 0 0 +IS 204 4 4 0 0 +IS 205 2 2 0 0 +IS 206 4 4 0 0 +IS 207 4 4 0 0 +# Read lengths. Use `grep ^RL | cut -f 2-` to extract this part. The columns are: read length, count +RL 30 1 +RL 33 1 +RL 41 1 +RL 45 1 +RL 49 6 +RL 51 2 +RL 52 4 +RL 54 2 +RL 58 2 +RL 59 4 +RL 60 2 +RL 61 8 +RL 62 2 +RL 63 10 +RL 65 10 +RL 66 4 +RL 67 12 +RL 68 7 +RL 69 10 +RL 70 20 +RL 71 22 +RL 72 14 +RL 73 16 +RL 74 8 +RL 75 24 +RL 76 22 +RL 77 38 +RL 78 30 +RL 79 26 +RL 80 34 +RL 81 48 +RL 82 36 +RL 83 38 +RL 84 50 +RL 85 30 +RL 86 48 +RL 87 60 +RL 88 58 +RL 89 42 +RL 90 32 +RL 91 45 +RL 92 60 +RL 93 46 +RL 94 40 +RL 95 85 +RL 96 108 +RL 97 68 +RL 98 56 +RL 99 48 +RL 100 88 +RL 101 48 +RL 102 54 +RL 103 44 +RL 104 66 +RL 105 52 +RL 106 56 +RL 107 71 +RL 108 52 +RL 109 48 +RL 110 68 +RL 111 58 +RL 112 44 +RL 113 72 +RL 114 60 +RL 115 97 +RL 116 72 +RL 117 66 +RL 118 68 +RL 119 75 +RL 120 29 +RL 121 78 +RL 122 60 +RL 123 56 +RL 124 72 +RL 125 72 +RL 126 50 +RL 127 62 +RL 128 62 +RL 129 56 +RL 130 79 +RL 131 90 +RL 132 50 +RL 133 37 +RL 134 51 +RL 135 62 +RL 136 60 +RL 137 58 +RL 138 68 +RL 139 64 +RL 140 56 +RL 141 82 +RL 142 54 +RL 143 1634 +# Read lengths - first fragments. Use `grep ^FRL | cut -f 2-` to extract this part. The columns are: read length, count +FRL 30 1 +FRL 41 1 +FRL 45 1 +FRL 49 3 +FRL 51 1 +FRL 52 2 +FRL 54 1 +FRL 58 1 +FRL 59 2 +FRL 60 1 +FRL 61 4 +FRL 62 1 +FRL 63 5 +FRL 65 5 +FRL 66 2 +FRL 67 6 +FRL 68 3 +FRL 69 5 +FRL 70 10 +FRL 71 11 +FRL 72 7 +FRL 73 8 +FRL 74 4 +FRL 75 12 +FRL 76 11 +FRL 77 19 +FRL 78 15 +FRL 79 13 +FRL 80 17 +FRL 81 24 +FRL 82 18 +FRL 83 19 +FRL 84 25 +FRL 85 15 +FRL 86 24 +FRL 87 30 +FRL 88 29 +FRL 89 21 +FRL 90 16 +FRL 91 23 +FRL 92 30 +FRL 93 23 +FRL 94 20 +FRL 95 42 +FRL 96 54 +FRL 97 34 +FRL 98 28 +FRL 99 24 +FRL 100 44 +FRL 101 24 +FRL 102 27 +FRL 103 22 +FRL 104 33 +FRL 105 26 +FRL 106 28 +FRL 107 36 +FRL 108 26 +FRL 109 24 +FRL 110 34 +FRL 111 29 +FRL 112 22 +FRL 113 36 +FRL 114 30 +FRL 115 49 +FRL 116 36 +FRL 117 33 +FRL 118 34 +FRL 119 38 +FRL 120 14 +FRL 121 39 +FRL 122 30 +FRL 123 28 +FRL 124 36 +FRL 125 36 +FRL 126 26 +FRL 127 31 +FRL 128 31 +FRL 129 28 +FRL 130 40 +FRL 131 45 +FRL 132 25 +FRL 133 18 +FRL 134 26 +FRL 135 31 +FRL 136 30 +FRL 137 29 +FRL 138 34 +FRL 139 32 +FRL 140 28 +FRL 141 41 +FRL 142 27 +FRL 143 814 +# Read lengths - last fragments. Use `grep ^LRL | cut -f 2-` to extract this part. The columns are: read length, count +LRL 33 1 +LRL 49 3 +LRL 51 1 +LRL 52 2 +LRL 54 1 +LRL 58 1 +LRL 59 2 +LRL 60 1 +LRL 61 4 +LRL 62 1 +LRL 63 5 +LRL 65 5 +LRL 66 2 +LRL 67 6 +LRL 68 4 +LRL 69 5 +LRL 70 10 +LRL 71 11 +LRL 72 7 +LRL 73 8 +LRL 74 4 +LRL 75 12 +LRL 76 11 +LRL 77 19 +LRL 78 15 +LRL 79 13 +LRL 80 17 +LRL 81 24 +LRL 82 18 +LRL 83 19 +LRL 84 25 +LRL 85 15 +LRL 86 24 +LRL 87 30 +LRL 88 29 +LRL 89 21 +LRL 90 16 +LRL 91 22 +LRL 92 30 +LRL 93 23 +LRL 94 20 +LRL 95 43 +LRL 96 54 +LRL 97 34 +LRL 98 28 +LRL 99 24 +LRL 100 44 +LRL 101 24 +LRL 102 27 +LRL 103 22 +LRL 104 33 +LRL 105 26 +LRL 106 28 +LRL 107 35 +LRL 108 26 +LRL 109 24 +LRL 110 34 +LRL 111 29 +LRL 112 22 +LRL 113 36 +LRL 114 30 +LRL 115 48 +LRL 116 36 +LRL 117 33 +LRL 118 34 +LRL 119 37 +LRL 120 15 +LRL 121 39 +LRL 122 30 +LRL 123 28 +LRL 124 36 +LRL 125 36 +LRL 126 24 +LRL 127 31 +LRL 128 31 +LRL 129 28 +LRL 130 39 +LRL 131 45 +LRL 132 25 +LRL 133 19 +LRL 134 25 +LRL 135 31 +LRL 136 30 +LRL 137 29 +LRL 138 34 +LRL 139 32 +LRL 140 28 +LRL 141 41 +LRL 142 27 +LRL 143 820 +# Mapping qualities for reads !(UNMAP|SECOND|SUPPL|QCFAIL|DUP). Use `grep ^MAPQ | cut -f 2-` to extract this part. The columns are: mapq, count +MAPQ 40 1 +MAPQ 42 1 +MAPQ 44 1 +MAPQ 54 1 +MAPQ 60 3980 +# Indel distribution. Use `grep ^ID | cut -f 2-` to extract this part. The columns are: length, number of insertions, number of deletions +ID 1 2 10 +# Indels per cycle. Use `grep ^IC | cut -f 2-` to extract this part. The columns are: cycle, number of insertions (fwd), .. (rev) , number of deletions (fwd), .. (rev) +IC 3 0 0 1 0 +IC 10 0 1 0 0 +IC 35 0 0 1 0 +IC 39 0 0 1 0 +IC 53 0 0 0 1 +IC 54 0 0 0 1 +IC 61 0 0 1 0 +IC 62 0 0 0 1 +IC 77 0 0 1 0 +IC 80 1 0 0 1 +IC 132 0 0 0 1 +# Coverage distribution. Use `grep ^COV | cut -f 2-` to extract this part. +COV [1-1] 1 40 +COV [2-2] 2 83 +COV [3-3] 3 32 +COV [4-4] 4 14 +COV [5-5] 5 10 +COV [6-6] 6 1 +COV [7-7] 7 12 +COV [8-8] 8 8 +COV [9-9] 9 9 +COV [10-10] 10 10 +COV [11-11] 11 1 +COV [12-12] 12 5 +COV [13-13] 13 1 +COV [14-14] 14 4 +COV [15-15] 15 1 +COV [16-16] 16 9 +COV [17-17] 17 1 +COV [18-18] 18 1 +COV [19-19] 19 2 +COV [20-20] 20 5 +COV [21-21] 21 13 +COV [22-22] 22 9 +COV [23-23] 23 2 +COV [24-24] 24 6 +COV [25-25] 25 1 +COV [26-26] 26 98 +COV [30-30] 30 2 +COV [32-32] 32 1 +COV [36-36] 36 1 +COV [37-37] 37 1 +COV [40-40] 40 2 +COV [41-41] 41 1 +COV [43-43] 43 2 +COV [45-45] 45 2 +COV [46-46] 46 1 +COV [48-48] 48 1 +COV [50-50] 50 5 +COV [52-52] 52 5 +COV [54-54] 54 3 +COV [55-55] 55 1 +COV [56-56] 56 2 +COV [57-57] 57 1 +COV [58-58] 58 2 +COV [59-59] 59 1 +COV [60-60] 60 1 +COV [63-63] 63 1 +COV [64-64] 64 1 +COV [66-66] 66 5 +COV [68-68] 68 1 +COV [70-70] 70 1 +COV [71-71] 71 1 +COV [72-72] 72 3 +COV [73-73] 73 1 +COV [74-74] 74 7 +COV [78-78] 78 6 +COV [80-80] 80 6 +COV [81-81] 81 1 +COV [82-82] 82 7 +COV [83-83] 83 1 +COV [84-84] 84 2 +COV [85-85] 85 1 +COV [86-86] 86 4 +COV [87-87] 87 1 +COV [88-88] 88 23 +COV [90-90] 90 7 +COV [92-92] 92 7 +COV [93-93] 93 1 +COV [94-94] 94 1 +COV [95-95] 95 1 +COV [98-98] 98 1 +COV [100-100] 100 1 +COV [101-101] 101 2 +COV [103-103] 103 1 +COV [104-104] 104 1 +COV [111-111] 111 1 +COV [114-114] 114 1 +COV [115-115] 115 2 +COV [116-116] 116 1 +COV [120-120] 120 1 +COV [121-121] 121 1 +COV [125-125] 125 1 +COV [126-126] 126 1 +COV [129-129] 129 2 +COV [130-130] 130 1 +COV [135-135] 135 1 +COV [136-136] 136 1 +COV [140-140] 140 1 +COV [143-143] 143 1 +COV [144-144] 144 1 +COV [145-145] 145 1 +COV [147-147] 147 1 +COV [148-148] 148 1 +COV [151-151] 151 1 +COV [154-154] 154 1 +COV [158-158] 158 2 +COV [159-159] 159 1 +COV [160-160] 160 1 +COV [164-164] 164 1 +COV [166-166] 166 1 +COV [170-170] 170 2 +COV [172-172] 172 1 +COV [173-173] 173 1 +COV [176-176] 176 1 +COV [178-178] 178 1 +COV [180-180] 180 2 +COV [184-184] 184 1 +COV [188-188] 188 1 +COV [190-190] 190 2 +COV [191-191] 191 1 +COV [192-192] 192 2 +COV [197-197] 197 1 +COV [200-200] 200 2 +COV [205-205] 205 1 +COV [208-208] 208 2 +COV [210-210] 210 1 +COV [212-212] 212 1 +COV [214-214] 214 1 +COV [215-215] 215 1 +COV [218-218] 218 1 +COV [224-224] 224 1 +COV [226-226] 226 1 +COV [229-229] 229 1 +COV [231-231] 231 2 +COV [232-232] 232 1 +COV [236-236] 236 1 +COV [240-240] 240 2 +COV [241-241] 241 1 +COV [242-242] 242 1 +COV [244-244] 244 3 +COV [245-245] 245 1 +COV [247-247] 247 1 +COV [250-250] 250 1 +COV [252-252] 252 1 +COV [254-254] 254 1 +COV [258-258] 258 2 +COV [259-259] 259 1 +COV [262-262] 262 1 +COV [263-263] 263 1 +COV [264-264] 264 1 +COV [265-265] 265 1 +COV [271-271] 271 1 +COV [274-274] 274 1 +COV [275-275] 275 1 +COV [278-278] 278 1 +COV [280-280] 280 1 +COV [281-281] 281 2 +COV [284-284] 284 1 +COV [286-286] 286 2 +COV [288-288] 288 2 +COV [289-289] 289 1 +COV [292-292] 292 1 +COV [293-293] 293 1 +COV [294-294] 294 1 +COV [296-296] 296 1 +COV [300-300] 300 1 +COV [302-302] 302 1 +COV [304-304] 304 2 +COV [306-306] 306 1 +COV [308-308] 308 1 +COV [310-310] 310 1 +COV [311-311] 311 1 +COV [314-314] 314 1 +COV [315-315] 315 1 +COV [317-317] 317 1 +COV [318-318] 318 2 +COV [320-320] 320 2 +COV [324-324] 324 1 +COV [325-325] 325 1 +COV [326-326] 326 3 +COV [329-329] 329 1 +COV [330-330] 330 1 +COV [331-331] 331 1 +COV [332-332] 332 1 +COV [333-333] 333 1 +COV [334-334] 334 2 +COV [338-338] 338 1 +COV [339-339] 339 1 +COV [340-340] 340 1 +COV [342-342] 342 1 +COV [343-343] 343 1 +COV [344-344] 344 3 +COV [345-345] 345 1 +COV [348-348] 348 3 +COV [349-349] 349 1 +COV [350-350] 350 1 +COV [352-352] 352 1 +COV [356-356] 356 3 +COV [357-357] 357 1 +COV [358-358] 358 4 +COV [360-360] 360 1 +COV [362-362] 362 9 +COV [364-364] 364 7 +COV [366-366] 366 3 +COV [367-367] 367 2 +COV [368-368] 368 28 +COV [374-374] 374 2 +COV [375-375] 375 2 +COV [387-387] 387 2 +COV [388-388] 388 1 +COV [389-389] 389 1 +COV [399-399] 399 1 +COV [401-401] 401 1 +COV [403-403] 403 1 +COV [406-406] 406 1 +COV [415-415] 415 1 +COV [419-419] 419 1 +COV [425-425] 425 1 +COV [426-426] 426 1 +COV [430-430] 430 1 +COV [432-432] 432 1 +COV [436-436] 436 1 +COV [445-445] 445 1 +COV [447-447] 447 1 +COV [454-454] 454 1 +COV [458-458] 458 2 +COV [459-459] 459 1 +COV [460-460] 460 1 +COV [463-463] 463 1 +COV [476-476] 476 1 +COV [477-477] 477 1 +COV [480-480] 480 1 +COV [481-481] 481 1 +COV [483-483] 483 2 +COV [489-489] 489 1 +COV [492-492] 492 1 +COV [500-500] 500 1 +COV [501-501] 501 1 +COV [504-504] 504 1 +COV [508-508] 508 1 +COV [511-511] 511 1 +COV [512-512] 512 1 +COV [515-515] 515 1 +COV [525-525] 525 3 +COV [529-529] 529 1 +COV [533-533] 533 1 +COV [539-539] 539 1 +COV [540-540] 540 1 +COV [541-541] 541 1 +COV [546-546] 546 1 +COV [549-549] 549 1 +COV [550-550] 550 1 +COV [553-553] 553 1 +COV [559-559] 559 1 +COV [563-563] 563 2 +COV [565-565] 565 1 +COV [569-569] 569 1 +COV [575-575] 575 1 +COV [577-577] 577 1 +COV [578-578] 578 1 +COV [579-579] 579 1 +COV [591-591] 591 2 +COV [592-592] 592 2 +COV [593-593] 593 2 +COV [601-601] 601 1 +COV [603-603] 603 1 +COV [605-605] 605 1 +COV [610-610] 610 1 +COV [611-611] 611 1 +COV [613-613] 613 2 +COV [617-617] 617 1 +COV [622-622] 622 1 +COV [625-625] 625 1 +COV [628-628] 628 1 +COV [637-637] 637 2 +COV [639-639] 639 1 +COV [640-640] 640 1 +COV [643-643] 643 1 +COV [652-652] 652 2 +COV [657-657] 657 1 +COV [661-661] 661 1 +COV [663-663] 663 2 +COV [665-665] 665 1 +COV [669-669] 669 1 +COV [671-671] 671 1 +COV [674-674] 674 1 +COV [675-675] 675 1 +COV [679-679] 679 1 +COV [685-685] 685 1 +COV [687-687] 687 1 +COV [689-689] 689 1 +COV [692-692] 692 1 +COV [694-694] 694 1 +COV [697-697] 697 2 +COV [698-698] 698 1 +COV [699-699] 699 1 +COV [705-705] 705 1 +COV [711-711] 711 1 +COV [714-714] 714 1 +COV [719-719] 719 2 +COV [724-724] 724 1 +COV [727-727] 727 1 +COV [728-728] 728 1 +COV [732-732] 732 1 +COV [733-733] 733 1 +COV [735-735] 735 1 +COV [738-738] 738 1 +COV [741-741] 741 1 +COV [746-746] 746 1 +COV [752-752] 752 1 +COV [755-755] 755 3 +COV [756-756] 756 1 +COV [757-757] 757 1 +COV [763-763] 763 1 +COV [765-765] 765 1 +COV [767-767] 767 1 +COV [769-769] 769 1 +COV [770-770] 770 1 +COV [771-771] 771 2 +COV [773-773] 773 2 +COV [774-774] 774 1 +COV [775-775] 775 1 +COV [779-779] 779 3 +COV [781-781] 781 1 +COV [782-782] 782 1 +COV [785-785] 785 2 +COV [788-788] 788 1 +COV [789-789] 789 2 +COV [792-792] 792 1 +COV [793-793] 793 5 +COV [794-794] 794 4 +COV [795-795] 795 7 +COV [796-796] 796 9 +COV [797-797] 797 8 +COV [799-799] 799 1 +COV [801-801] 801 1 +COV [806-806] 806 1 +COV [807-807] 807 1 +COV [817-817] 817 1 +COV [820-820] 820 1 +COV [824-824] 824 1 +COV [825-825] 825 1 +COV [847-847] 847 1 +COV [850-850] 850 1 +COV [851-851] 851 1 +COV [853-853] 853 1 +COV [868-868] 868 1 +COV [873-873] 873 1 +COV [874-874] 874 1 +COV [875-875] 875 1 +COV [892-892] 892 1 +COV [893-893] 893 1 +COV [902-902] 902 1 +COV [906-906] 906 1 +COV [908-908] 908 1 +COV [916-916] 916 1 +COV [925-925] 925 1 +COV [927-927] 927 1 +COV [935-935] 935 1 +COV [937-937] 937 1 +COV [944-944] 944 1 +COV [955-955] 955 1 +COV [965-965] 965 2 +COV [967-967] 967 1 +COV [986-986] 986 1 +COV [988-988] 988 1 +COV [999-999] 999 2 +COV [1000<] 1000 259 +# GC-depth. Use `grep ^GCD | cut -f 2-` to extract this part. The columns are: GC%, unique sequence percentiles, 10th, 25th, 50th, 75th and 90th depth percentile +GCD 0.0 100.000 0.000 0.000 0.000 0.000 0.000 diff --git a/tests/expected/dna/test.thresholds.bed.gz b/tests/expected/dna/test.thresholds.bed.gz new file mode 100644 index 0000000000000000000000000000000000000000..10d328e2765bb1e098bf0af0ee4d55ddfdaf8c87 GIT binary patch literal 578 zcmb2|=3rp}f&Xj_PR>jWs!Xkyz4PuE2(&ybeJ-_=VfE}QkJWCm^$D^&rZsdn$Ji%` zn@$r8TgaRLEV)?j*ZzHbY~SsFd;a(K4ZpWP67nJulBK-PYU-e_uG6_MC|XJ_R7dJr|+D9 zn{KuKc=d%@3(vJ#NoGl`lGfre>@`05`s2I|wmSRTvTLr^Ymen*Z{B*t=qAr3yHONwR74ZV`*6Ml8=?7TwqEhe!%RBxgy0Bqvw~ zIIuY~&SYUt5fPaX8Q{NAhN{&2y#P~5`@>s-EpM8YQshMxe=p#;$?p2)xZ939 z*X_@3^%mU3-!-8&04TjkUsTWagq?=dH%EjZk{+Pcum1A48o%mm9{pqXU;5PLD-Q#M PJX$J|W?%*<8V~^hZ*u`J literal 0 HcmV?d00001 diff --git a/tests/expected/dna/test.thresholds.bed.gz.csi b/tests/expected/dna/test.thresholds.bed.gz.csi new file mode 100644 index 0000000000000000000000000000000000000000..efa853278132c16ca0a2203af959f320b13f4596 GIT binary patch literal 108 zcmb2|=3rp}f&Xj_PR>jW{tU%@-%_3=CnO}WB&o6qB(Q9r*)T)0W2&S=18>J< Date: Fri, 28 Aug 2026 18:40:46 +0200 Subject: [PATCH 07/22] feat(cli): add the dna subcommand surface Shared options keep the same long name, short flag and RUSTQC_* environment variable as their rna counterparts. The deliberate differences: no --gtf, no --stranded, and --mapq defaults to 0 rather than 30 because that is mosdepth's default. run_dna is a stub for now; the pipeline lands in the following commits. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli.rs | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 21 ++- 2 files changed, 370 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 6e6459e8..2f8965a5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -32,6 +32,13 @@ pub enum Commands { /// analyses in one pass. Requires a GTF annotation and duplicate-marked /// (not removed) alignments. Rna(RnaArgs), + + /// DNA QC — single-pass analysis of BAM/SAM/CRAM files. + /// + /// Runs depth of coverage, samtools stats and library complexity + /// estimation in one pass. Needs no gene annotation. Pass `--targets` + /// to switch to targeted (exome or panel) mode. + Dna(DnaArgs), } /// Arguments for the `rna` subcommand. @@ -369,6 +376,278 @@ pub struct RnaArgs { pub preseq_seg_len: Option, } +/// Arguments for the `dna` subcommand. +/// +/// Shared options keep the same long name, short flag and `RUSTQC_*` +/// environment variable as their `rna` counterparts, so wrapper scripts and +/// muscle memory carry over between the two pipelines. The differences are +/// deliberate: there is no `--gtf` and no `--stranded`, and `--mapq` defaults +/// to 0 rather than 30 because that is mosdepth's default. +#[derive(Parser, Debug)] +#[command( + next_line_help = false, + term_width = 120, + help_template = "\ +{about-with-newline} +{usage-heading} {usage} + +{all-args}" +)] +pub struct DnaArgs { + // ── Input / Output ────────────────────────────────────────────────── + /// Duplicate-marked alignment file(s) + #[arg(value_name = "INPUT", num_args = 1.., required = true, help_heading = "Input / Output")] + pub input: Vec, + + /// Reference FASTA (required for CRAM and for GC bias) + #[arg( + short, + long, + value_name = "FASTA", + env = "RUSTQC_REFERENCE", + help_heading = "Input / Output" + )] + pub reference: Option, + + /// Target intervals BED; switches on targeted (exome or panel) mode + #[arg( + long, + value_name = "BED", + env = "RUSTQC_TARGETS", + help_heading = "Input / Output" + )] + pub targets: Option, + + /// Capture bait intervals BED [default: same as --targets] + #[arg( + long, + value_name = "BED", + env = "RUSTQC_BAITS", + requires = "targets", + help_heading = "Input / Output" + )] + pub baits: Option, + + /// Output directory [default: .] + #[arg( + short, + long, + default_value = ".", + hide_default_value = true, + env = "RUSTQC_OUTDIR", + help_heading = "Input / Output" + )] + pub outdir: String, + + /// Override sample name for output filenames (default: derived from BAM filename) + #[arg( + long, + value_name = "NAME", + env = "RUSTQC_SAMPLE_NAME", + help_heading = "Input / Output" + )] + pub sample_name: Option, + + /// Write outputs to a flat directory (no subdirs) + #[arg( + long, + default_value_t = false, + env = "RUSTQC_FLAT_OUTPUT", + help_heading = "Input / Output" + )] + pub flat_output: bool, + + /// YAML configuration file (see also: RUSTQC_CONFIG env var) + #[arg(short, long, value_name = "CONFIG", help_heading = "Input / Output")] + pub config: Option, + + /// JSON summary path (use "-" for stdout) + #[arg(short = 'j', long = "json-summary", value_name = "PATH", num_args = 0..=1, default_missing_value = "", env = "RUSTQC_JSON_SUMMARY", help_heading = "Input / Output")] + pub json_summary: Option, + + // ── Library ───────────────────────────────────────────────────────── + /// Paired-end reads + #[arg(short, long, env = "RUSTQC_PAIRED", help_heading = "Library")] + pub paired: bool, + + // ── General ───────────────────────────────────────────────────────── + /// Number of threads [default: 1] + #[arg( + short, + long, + default_value_t = 1, + hide_default_value = true, + env = "RUSTQC_THREADS", + help_heading = "General" + )] + pub threads: usize, + + /// MAPQ cutoff; reads below it are ignored [default: 0] + #[arg( + short = 'Q', + long = "mapq", + default_value_t = 0, + hide_default_value = true, + env = "RUSTQC_MAPQ", + help_heading = "General" + )] + pub mapq_cut: u8, + + /// Skip duplicate-marking check + #[arg( + long, + default_value_t = false, + env = "RUSTQC_SKIP_DUP_CHECK", + help_heading = "General" + )] + pub skip_dup_check: bool, + + /// Suppress output except warnings/errors + #[arg( + short = 'q', + long, + conflicts_with = "verbose", + env = "RUSTQC_QUIET", + help_heading = "General" + )] + pub quiet: bool, + + /// Show additional detail + #[arg( + short = 'v', + long, + conflicts_with = "quiet", + env = "RUSTQC_VERBOSE", + help_heading = "General" + )] + pub verbose: bool, + + // ── Tool parameters ───────────────────────────────────────────────── + /// Coverage thresholds to report [default: 1,5,10,15,20,30,50] + #[arg( + long = "depth-thresholds", + value_name = "N,...", + value_delimiter = ',', + default_values_t = vec![1u32, 5, 10, 15, 20, 30, 50], + hide_default_value = true, + env = "RUSTQC_DEPTH_THRESHOLDS", + help_heading = "Tool parameters" + )] + pub depth_thresholds: Vec, + + /// Fixed-width window size for per-window depth + #[arg( + long = "window-size", + value_name = "N", + env = "RUSTQC_WINDOW_SIZE", + help_heading = "Tool parameters" + )] + pub window_size: Option, + + /// Picard COVERAGE_CAP [default: 250] + #[arg( + long = "coverage-cap", + value_name = "N", + default_value_t = 250, + hide_default_value = true, + env = "RUSTQC_COVERAGE_CAP", + help_heading = "Tool parameters" + )] + pub coverage_cap: u32, + + /// Picard MINIMUM_BASE_QUALITY [default: 20] + #[arg( + long = "min-base-quality", + value_name = "N", + default_value_t = 20, + hide_default_value = true, + env = "RUSTQC_MIN_BASE_QUALITY", + help_heading = "Tool parameters" + )] + pub min_base_quality: u8, + + /// Skip the per-base depth output, by far the largest file + #[arg( + long, + default_value_t = false, + env = "RUSTQC_SKIP_PER_BASE", + help_heading = "Tool parameters" + )] + pub skip_per_base: bool, + + /// Skip GC bias metrics + #[arg( + long, + default_value_t = false, + env = "RUSTQC_SKIP_GC_BIAS", + help_heading = "Tool parameters" + )] + pub skip_gc_bias: bool, + + /// Cap on concurrently live per-contig depth arrays [default: derived from RAM] + #[arg( + long = "max-depth-workers", + value_name = "N", + env = "RUSTQC_MAX_DEPTH_WORKERS", + help_heading = "Tool parameters" + )] + pub max_depth_workers: Option, + + /// Skip preseq library complexity analysis + #[arg( + long, + default_value_t = false, + env = "RUSTQC_SKIP_PRESEQ", + help_heading = "Tool parameters" + )] + pub skip_preseq: bool, + + /// preseq: random seed for bootstrap CIs + #[arg( + long = "preseq-seed", + value_name = "N", + env = "RUSTQC_PRESEQ_SEED", + help_heading = "Tool parameters" + )] + pub preseq_seed: Option, + + /// preseq: max extrapolation depth + #[arg( + long = "preseq-max-extrap", + value_name = "N", + env = "RUSTQC_PRESEQ_MAX_EXTRAP", + help_heading = "Tool parameters" + )] + pub preseq_max_extrap: Option, + + /// preseq: step size between points + #[arg( + long = "preseq-step-size", + value_name = "N", + env = "RUSTQC_PRESEQ_STEP_SIZE", + help_heading = "Tool parameters" + )] + pub preseq_step_size: Option, + + /// preseq: bootstrap replicates for CIs + #[arg( + long = "preseq-n-bootstraps", + value_name = "N", + env = "RUSTQC_PRESEQ_N_BOOTSTRAPS", + help_heading = "Tool parameters" + )] + pub preseq_n_bootstraps: Option, + + /// preseq: max segment length for PE merging + #[arg( + long = "preseq-seg-len", + value_name = "N", + env = "RUSTQC_PRESEQ_SEG_LEN", + help_heading = "Tool parameters" + )] + pub preseq_seg_len: Option, +} + /// Parse command-line arguments and return the Cli struct. /// /// Sets a `long_version` that includes the git commit, build timestamp, @@ -413,7 +692,6 @@ mod tests { assert_eq!(args.min_intron, None); assert_eq!(args.inner_distance_step, None); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -435,7 +713,6 @@ mod tests { assert_eq!(args.input, vec!["a.bam", "b.bam", "c.bam"]); assert_eq!(args.gtf, "genes.gtf"); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -470,7 +747,6 @@ mod tests { assert_eq!(args.reference, Some("genome.fa".to_string())); assert_eq!(args.mapq_cut, 20); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -512,7 +788,6 @@ mod tests { assert_eq!(args.inner_distance_upper_bound, Some(500)); assert_eq!(args.inner_distance_step, Some(10)); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -542,7 +817,6 @@ mod tests { assert_eq!(args.preseq_n_bootstraps, Some(200)); assert_eq!(args.preseq_seg_len, Some(100_000_000)); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -568,7 +842,6 @@ mod tests { assert_eq!(args.tin_seed, Some(2)); assert_eq!(args.junction_saturation_seed, Some(3)); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } @@ -587,8 +860,80 @@ mod tests { Commands::Rna(args) => { assert!(args.skip_preseq); } - #[allow(unreachable_patterns)] _ => panic!("Expected Rna subcommand"), } } + + #[test] + fn test_dna_default_args() { + let cli = Cli::parse_from(["rustqc", "dna", "test.bam"]); + match cli.command { + Commands::Dna(args) => { + assert_eq!(args.input, vec!["test.bam"]); + assert_eq!(args.outdir, "."); + assert_eq!(args.threads, 1); + assert_eq!(args.mapq_cut, 0); + assert_eq!(args.coverage_cap, 250); + assert_eq!(args.min_base_quality, 20); + assert_eq!(args.depth_thresholds, vec![1, 5, 10, 15, 20, 30, 50]); + assert_eq!(args.window_size, None); + assert!(args.targets.is_none()); + assert!(args.baits.is_none()); + assert!(!args.skip_per_base); + assert!(!args.skip_gc_bias); + } + _ => panic!("Expected Dna subcommand"), + } + } + + #[test] + fn test_dna_no_gtf_required() { + assert!(Cli::try_parse_from(["rustqc", "dna", "test.bam"]).is_ok()); + } + + #[test] + fn test_dna_targeted_args() { + let cli = Cli::parse_from([ + "rustqc", + "dna", + "a.bam", + "b.bam", + "--targets", + "t.bed", + "--baits", + "b.bed", + "--depth-thresholds", + "1,10,100", + "--window-size", + "500", + "--reference", + "genome.fa", + "-Q", + "20", + "--threads", + "4", + ]); + match cli.command { + Commands::Dna(args) => { + assert_eq!(args.input, vec!["a.bam", "b.bam"]); + assert_eq!(args.targets, Some("t.bed".to_string())); + assert_eq!(args.baits, Some("b.bed".to_string())); + assert_eq!(args.depth_thresholds, vec![1, 10, 100]); + assert_eq!(args.window_size, Some(500)); + assert_eq!(args.reference, Some("genome.fa".to_string())); + assert_eq!(args.mapq_cut, 20); + assert_eq!(args.threads, 4); + } + _ => panic!("Expected Dna subcommand"), + } + } + + #[test] + fn test_dna_baits_without_targets_is_rejected() { + let result = Cli::try_parse_from(["rustqc", "dna", "test.bam", "--baits", "b.bed"]); + assert!( + result.is_err(), + "--baits without --targets must be rejected" + ); + } } diff --git a/src/main.rs b/src/main.rs index 978dd309..3934181c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,9 +73,13 @@ fn main() -> Result<()> { let cli = cli::parse_args(); // Determine verbosity from CLI flags - let verbosity = match &cli.command { - cli::Commands::Rna(args) if args.quiet => Verbosity::Quiet, - cli::Commands::Rna(args) if args.verbose => Verbosity::Verbose, + let (quiet, verbose) = match &cli.command { + cli::Commands::Rna(args) => (args.quiet, args.verbose), + cli::Commands::Dna(args) => (args.quiet, args.verbose), + }; + let verbosity = match (quiet, verbose) { + (true, _) => Verbosity::Quiet, + (_, true) => Verbosity::Verbose, _ => Verbosity::Normal, }; @@ -94,9 +98,20 @@ fn main() -> Result<()> { match cli.command { cli::Commands::Rna(args) => run_rna(args, &ui), + cli::Commands::Dna(args) => run_dna(args, &ui), } } +/// Run the DNA QC pipeline: depth of coverage, samtools-compatible outputs +/// and library complexity estimation in a single pass over each input. +/// +/// Not implemented yet; the pipeline lands over the following tasks in this +/// branch. The subcommand is wired up first so the CLI surface can be +/// reviewed and tested on its own. +fn run_dna(_args: cli::DnaArgs, _ui: &Ui) -> Result<()> { + anyhow::bail!("the dna subcommand is not implemented yet") +} + /// Reconstruct the command line for the featureCounts-compatible header comment. fn reconstruct_command_line(args: &cli::RnaArgs) -> String { let mut parts = vec![format!( From 9db53eadbcee306afd35c0ecfeadc6a5fde2ad7f Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:42:26 +0200 Subject: [PATCH 08/22] feat(config): add the dna configuration section Config gains a `dna` block alongside `rna`, with mosdepth and samtools sub-sections and a reuse of the existing PreseqConfig. Shared settings (chromosome_prefix, chromosome_mapping, sample_name, flat_output) are declared on DnaConfig itself, mirroring RnaConfig, so the two pipelines can be configured independently in one file. Co-Authored-By: Claude Opus 5 (1M context) --- src/config.rs | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/config.rs b/src/config.rs index 4952a1fd..28ae5517 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,10 @@ pub struct Config { /// RNA-Seq QC configuration (matches the `rna` subcommand). #[serde(default)] pub rna: RnaConfig, + + /// DNA QC configuration (matches the `dna` subcommand). + #[serde(default)] + pub dna: DnaConfig, } /// RNA-Seq QC configuration. @@ -908,6 +912,138 @@ impl RnaConfig { } } +// =================================================================== +// DNA QC configuration +// =================================================================== + +/// DNA QC configuration. +/// +/// Contains all settings for the `rustqc dna` subcommand. Tool-specific +/// settings are nested under their tool name (e.g. `mosdepth:`, `samtools:`, +/// `preseq:`). +/// +/// The shared settings are declared here rather than inherited from the root +/// [`Config`], mirroring [`RnaConfig`], so the two pipelines can be configured +/// independently in one file. +/// +/// Example: +/// ```yaml +/// dna: +/// flat_output: true +/// mosdepth: +/// window_size: 500 +/// thresholds: [1, 10, 30] +/// ``` +#[derive(Debug, Deserialize, Default)] +#[serde(default)] +pub struct DnaConfig { + /// Prefix to prepend to alignment file chromosome names before matching + /// interval-file names (for example a targets BED using `chr1` against an + /// alignment using `1`). + #[serde(default)] + pub chromosome_prefix: Option, + + /// Chromosome name mapping from interval-file names to alignment file names. + /// + /// Applied after `chromosome_prefix`, so explicit mappings override it. + #[serde(default)] + pub chromosome_mapping: HashMap, + + /// Override the sample name used in output filenames. + /// + /// The CLI `--sample-name` flag takes precedence over this setting. + #[serde(default)] + pub sample_name: Option, + + /// Write all output files to a flat directory (no subdirectories). + /// + /// By default (`false`), outputs are organised by tool: `mosdepth/`, + /// `samtools/`, `preseq/`. The CLI `--flat-output` flag enables flat + /// output regardless of this setting (either source being `true` produces + /// flat output). + #[serde(default)] + pub flat_output: bool, + + /// mosdepth-compatible depth of coverage configuration. + #[serde(default)] + pub mosdepth: MosdepthConfig, + + /// samtools-compatible output configuration (stats, flagstat, idxstats). + #[serde(default)] + pub samtools: SamtoolsConfig, + + /// preseq lc_extrap library complexity extrapolation configuration. + /// + /// Reuses the same type as the `rna` pipeline; the implementation is shared. + #[serde(default)] + pub preseq: PreseqConfig, +} + +/// Configuration for the mosdepth-compatible depth of coverage analysis. +/// +/// Example: +/// ```yaml +/// mosdepth: +/// enabled: true +/// window_size: 500 +/// thresholds: [1, 10, 30] +/// skip_per_base: false +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct MosdepthConfig { + /// Whether to compute depth of coverage. Defaults to true. + pub enabled: bool, + + /// Fixed-width window size for the per-window depth output. + /// + /// `None` (the default) means no `regions` output is written, matching + /// mosdepth run without `--by`. + pub window_size: Option, + + /// Coverage thresholds reported in the thresholds output and used for the + /// percent-of-bases-at-least-NX summary figures. + pub thresholds: Vec, + + /// Skip the per-base depth output, by far the largest file produced. + pub skip_per_base: bool, +} + +impl Default for MosdepthConfig { + fn default() -> Self { + Self { + enabled: true, + window_size: None, + thresholds: vec![1, 5, 10, 15, 20, 30, 50], + skip_per_base: false, + } + } +} + +/// Configuration for the samtools-compatible outputs of the DNA pipeline. +/// +/// A single toggle covers `stats`, `flagstat` and `idxstats` because all three +/// are produced from one accumulator in the same pass; disabling them +/// individually would save no work. +/// +/// Example: +/// ```yaml +/// samtools: +/// enabled: true +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct SamtoolsConfig { + /// Whether to write the samtools-compatible outputs. Defaults to true. + pub enabled: bool, +} + +impl Default for SamtoolsConfig { + fn default() -> Self { + Self { enabled: true } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1294,4 +1430,44 @@ preseq: std::env::set_var("RUSTQC_CONFIG", val); } } + + #[test] + fn test_dna_config_defaults() { + let config = Config::default(); + assert!(config.dna.mosdepth.enabled); + assert!(config.dna.samtools.enabled); + assert!(config.dna.preseq.enabled); + assert!(!config.dna.flat_output); + assert_eq!( + config.dna.mosdepth.thresholds, + vec![1, 5, 10, 15, 20, 30, 50] + ); + assert_eq!(config.dna.mosdepth.window_size, None); + } + + #[test] + fn test_dna_config_from_yaml() { + let yaml = "dna:\n flat_output: true\n mosdepth:\n window_size: 500\n thresholds: [1, 30]\n preseq:\n enabled: false\n"; + let config: Config = serde_yaml_ng::from_str(yaml).unwrap(); + assert!(config.dna.flat_output); + assert_eq!(config.dna.mosdepth.window_size, Some(500)); + assert_eq!(config.dna.mosdepth.thresholds, vec![1, 30]); + assert!(!config.dna.preseq.enabled); + // A dna-only config leaves the rna side untouched. + assert!(config.rna.preseq.enabled); + } + + #[test] + fn test_dna_config_deep_merge() { + let mut merged: Value = serde_yaml_ng::from_str( + "dna:\n mosdepth:\n window_size: 100\n thresholds: [1]\n", + ) + .unwrap(); + let overlay: Value = + serde_yaml_ng::from_str("dna:\n mosdepth:\n window_size: 500\n").unwrap(); + deep_merge(&mut merged, overlay); + let config: Config = serde_yaml_ng::from_value(merged).unwrap(); + assert_eq!(config.dna.mosdepth.window_size, Some(500)); + assert_eq!(config.dna.mosdepth.thresholds, vec![1]); + } } From 7143c6da6aeb1006fdb3681a414fc01d79d10019 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:44:37 +0200 Subject: [PATCH 09/22] feat(dna): add the per-contig depth accumulator DepthAccum records aligned blocks as increments in a delta array the length of the contig, then a prefix sum turns that into per-base depth in one linear pass. Filters and CIGAR handling reproduce mosdepth 0.3.14 outside fast mode: flags 1796 excluded, MAPQ floor applied, M/=/X cover the reference, D/N advance without covering, and I/S/H/P do not advance. Mate-overlap correction, the other half of mosdepth's default behaviour, lands in the next commit. Co-Authored-By: Claude Opus 5 (1M context) --- src/dna/depth.rs | 224 +++++++++++++++++++++++++++++++++++++++++++++++ src/dna/mod.rs | 7 ++ src/lib.rs | 1 + 3 files changed, 232 insertions(+) create mode 100644 src/dna/depth.rs create mode 100644 src/dna/mod.rs diff --git a/src/dna/depth.rs b/src/dna/depth.rs new file mode 100644 index 00000000..e7373826 --- /dev/null +++ b/src/dna/depth.rs @@ -0,0 +1,224 @@ +//! Per-contig depth of coverage accumulation. +//! +//! One [`DepthAccum`] covers one contig. Aligned blocks are recorded as +//! increments in a delta array the length of the contig, and a prefix sum at +//! the end turns that into per-base depth in a single linear pass. +//! +//! # Upstream semantics +//! +//! The filters and the CIGAR walk reproduce mosdepth 0.3.14 run without +//! `--fast-mode`, whose help text describes that flag as "dont look at +//! internal cigar operations or correct mate overlaps". Default mode +//! therefore does both, and so does this module: +//! +//! - records carrying any bit of [`MOSDEPTH_DEFAULT_EXCLUDE`] are skipped +//! (mosdepth's `-F` default of 1796); +//! - records with `MAPQ` below the cutoff are skipped (mosdepth's `-Q`, +//! default 0); +//! - `M`, `=` and `X` cover the reference, `D` and `N` advance without +//! covering, and `I`, `S`, `H` and `P` do not advance at all. + +use rust_htslib::bam; +use rust_htslib::bam::record::Cigar; + +use crate::common::bam_flags::*; + +/// Bit mask matching mosdepth's `-F` default: `UNMAP | SECONDARY | QCFAIL | DUP`. +pub const MOSDEPTH_DEFAULT_EXCLUDE: u16 = BAM_FUNMAP | BAM_FSECONDARY | BAM_FQCFAIL | BAM_FDUP; + +/// Accumulates per-base depth for a single contig. +#[derive(Debug)] +pub struct DepthAccum { + /// Delta array of length `contig_len + 1`; a `+1` at a block start and a + /// `-1` one past its end, summed into depth by [`DepthAccum::into_depths`]. + deltas: Vec, + /// Contig length in bases. + len: usize, + /// Records with `MAPQ` strictly below this value are ignored. + mapq_cut: u8, + /// Records carrying any of these flag bits are ignored. + exclude_flags: u16, +} + +impl DepthAccum { + /// Allocate for one contig of `length` bases. + pub fn new(length: u64, mapq_cut: u8, exclude_flags: u16) -> Self { + let len = length as usize; + Self { + deltas: vec![0i32; len + 1], + len, + mapq_cut, + exclude_flags, + } + } + + /// Add one record's aligned blocks. Records failing the filters are ignored. + pub fn process_read(&mut self, record: &bam::Record) { + if !self.passes_filters(record) { + return; + } + let mut pos = record.pos(); + for op in record.cigar().iter() { + match op { + // Reference-consuming and query-consuming: covers the reference. + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + let n = i64::from(*n); + self.add_block(pos, pos + n); + pos += n; + } + // Reference-consuming only: advances without covering. + Cigar::Del(n) | Cigar::RefSkip(n) => pos += i64::from(*n), + // Neither reference-consuming nor covering. + Cigar::Ins(_) | Cigar::SoftClip(_) | Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + } + + /// Consume the delta array and return per-base depth for the contig. + pub fn into_depths(self) -> Vec { + let mut depths = Vec::with_capacity(self.len); + let mut running = 0i32; + for delta in self.deltas.iter().take(self.len) { + running += delta; + // `running` cannot go negative: every `-1` is emitted only after + // its matching `+1`, and both are clamped to the same range. + depths.push(running.max(0) as u32); + } + depths + } + + /// Whether a record contributes to depth at all. + fn passes_filters(&self, record: &bam::Record) -> bool { + record.flags() & self.exclude_flags == 0 && record.mapq() >= self.mapq_cut + } + + /// Record a half-open aligned block `[start, end)`, clamped to the contig. + fn add_block(&mut self, start: i64, end: i64) { + let start = start.max(0) as usize; + let end = (end.max(0) as usize).min(self.len); + if start >= end { + return; + } + self.deltas[start] += 1; + self.deltas[end] -= 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_htslib::bam::record::{Cigar, CigarString, Record}; + + /// Build a minimal mapped record at `pos` with the given CIGAR, MAPQ and flags. + /// + /// `seq` and `qual` must both be as long as the query-consuming part of the + /// CIGAR, otherwise the record is malformed and every assertion made against + /// it is meaningless, so the helper asserts that itself. + fn rec(pos: i64, cigar: Vec, mapq: u8, flags: u16) -> Record { + let query_len: usize = cigar + .iter() + .map(|op| match op { + Cigar::Match(n) | Cigar::Ins(n) | Cigar::SoftClip(n) => *n as usize, + Cigar::Equal(n) | Cigar::Diff(n) => *n as usize, + _ => 0, + }) + .sum(); + let seq = vec![b'A'; query_len]; + let qual = vec![30u8; query_len]; + assert_eq!(seq.len(), qual.len(), "malformed test record"); + + let mut r = Record::new(); + r.set(b"q", Some(&CigarString(cigar)), &seq, &qual); + r.set_tid(0); + r.set_pos(pos); + r.set_mapq(mapq); + r.set_flags(flags); + r + } + + #[test] + fn match_block_covers_exactly_its_span() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(5, vec![Cigar::Match(4)], 60, 0)); + assert_eq!(&d.into_depths()[4..10], &[0, 1, 1, 1, 1, 0]); + } + + #[test] + fn deletion_and_skip_advance_without_covering() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec( + 0, + vec![Cigar::Match(2), Cigar::Del(3), Cigar::Match(2)], + 60, + 0, + )); + assert_eq!(&d.into_depths()[0..8], &[1, 1, 0, 0, 0, 1, 1, 0]); + } + + #[test] + fn ref_skip_advances_without_covering() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec( + 0, + vec![Cigar::Match(2), Cigar::RefSkip(3), Cigar::Match(2)], + 60, + 0, + )); + assert_eq!(&d.into_depths()[0..8], &[1, 1, 0, 0, 0, 1, 1, 0]); + } + + #[test] + fn insertion_and_soft_clip_do_not_advance_the_reference() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec( + 0, + vec![ + Cigar::SoftClip(3), + Cigar::Match(2), + Cigar::Ins(4), + Cigar::Match(2), + ], + 60, + 0, + )); + assert_eq!(&d.into_depths()[0..6], &[1, 1, 1, 1, 0, 0]); + } + + #[test] + fn duplicate_flagged_reads_are_excluded_by_default() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(0, vec![Cigar::Match(4)], 60, BAM_FDUP)); + assert_eq!(d.into_depths().iter().sum::(), 0); + } + + #[test] + fn secondary_qcfail_and_unmapped_reads_are_excluded_by_default() { + for flag in [BAM_FSECONDARY, BAM_FQCFAIL, BAM_FUNMAP] { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(0, vec![Cigar::Match(4)], 60, flag)); + assert_eq!( + d.into_depths().iter().sum::(), + 0, + "flag {flag:#x} should be excluded" + ); + } + } + + #[test] + fn reads_below_the_mapq_cutoff_are_excluded() { + let mut d = DepthAccum::new(20, 30, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(0, vec![Cigar::Match(4)], 29, 0)); + assert_eq!(d.into_depths().iter().sum::(), 0); + + let mut d = DepthAccum::new(20, 30, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(0, vec![Cigar::Match(4)], 30, 0)); + assert_eq!(d.into_depths().iter().sum::(), 4); + } + + #[test] + fn a_read_running_past_the_contig_end_is_clipped_not_panicking() { + let mut d = DepthAccum::new(6, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&rec(4, vec![Cigar::Match(10)], 60, 0)); + assert_eq!(d.into_depths(), vec![0, 0, 0, 0, 1, 1]); + } +} diff --git a/src/dna/mod.rs b/src/dna/mod.rs new file mode 100644 index 00000000..8d3ac015 --- /dev/null +++ b/src/dna/mod.rs @@ -0,0 +1,7 @@ +//! DNA quality control and analysis modules. +//! +//! Contains the depth of coverage engine and the mosdepth-compatible outputs +//! built on top of it. Read-level statistics, the samtools-compatible writers +//! and preseq are shared with the RNA pipeline and live in [`crate::common`]. + +pub mod depth; diff --git a/src/lib.rs b/src/lib.rs index e823028f..2ce25ddf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,6 +71,7 @@ use serde::Deserialize; pub mod common; pub mod config; pub mod cpu; +pub mod dna; pub mod gtf; pub mod io; pub mod rna; From e64cfd9bfd658706e92284a7dfafe6d02f192464 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 18:47:22 +0200 Subject: [PATCH 10/22] feat(dna): correct mate overlaps in the depth engine mosdepth counts a base once when both mates of a pair cover it, unless --fast-mode is given. On the test dataset this is the difference between 469875 and 247878 total covered bases, so it is the dominant behaviour rather than an edge case. Pending mates are held in a map keyed by read name, indexed by the position the outstanding mate was announced at so entries that can never be claimed are evicted as the coordinate-ordered scan moves past them. A test asserts the map empties. Includes an engine-level parity check against the committed mosdepth fixture: total covered bases and maximum depth both match exactly. Co-Authored-By: Claude Opus 5 (1M context) --- src/dna/depth.rs | 232 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 6 deletions(-) diff --git a/src/dna/depth.rs b/src/dna/depth.rs index e7373826..ce39f2c2 100644 --- a/src/dna/depth.rs +++ b/src/dna/depth.rs @@ -16,7 +16,14 @@ //! - records with `MAPQ` below the cutoff are skipped (mosdepth's `-Q`, //! default 0); //! - `M`, `=` and `X` cover the reference, `D` and `N` advance without -//! covering, and `I`, `S`, `H` and `P` do not advance at all. +//! covering, and `I`, `S`, `H` and `P` do not advance at all; +//! - a base covered by both mates of one pair counts once. +//! +//! That last rule is not a detail. On the project's test dataset, correcting +//! mate overlaps takes total covered bases from 469875 down to 247878, which +//! is exactly the gap between mosdepth's `--fast-mode` and its default. + +use std::collections::{BTreeMap, HashMap}; use rust_htslib::bam; use rust_htslib::bam::record::Cigar; @@ -38,6 +45,12 @@ pub struct DepthAccum { mapq_cut: u8, /// Records carrying any of these flag bits are ignored. exclude_flags: u16, + /// Aligned blocks already counted for a pair whose second mate is still + /// ahead, keyed by read name. + pending: HashMap, Vec<(usize, usize)>>, + /// Read names indexed by the position their outstanding mate is expected + /// at, so stale entries can be evicted without scanning `pending`. + pending_by_pos: BTreeMap>>, } impl DepthAccum { @@ -49,21 +62,93 @@ impl DepthAccum { len, mapq_cut, exclude_flags, + pending: HashMap::new(), + pending_by_pos: BTreeMap::new(), } } /// Add one record's aligned blocks. Records failing the filters are ignored. + /// + /// Records are expected in coordinate order, which is what the per-contig + /// worker feeds. That ordering is what makes the pending-mate bookkeeping + /// bounded: once the read position passes the position an outstanding mate + /// was announced at, that entry can never be claimed and is dropped. pub fn process_read(&mut self, record: &bam::Record) { if !self.passes_filters(record) { return; } + let pos = record.pos(); + self.evict_unclaimable(pos); + + let blocks = Self::aligned_blocks(record, self.len); + if blocks.is_empty() { + return; + } + + // A record can only overlap its own mate, and only on the same contig. + let paired_here = record.flags() & BAM_FPAIRED != 0 + && record.flags() & BAM_FMUNMAP == 0 + && record.mtid() == record.tid(); + + if paired_here { + if let Some(mate_blocks) = self.pending.remove(record.qname()) { + // Second mate of the pair: shared bases are already counted. + self.add_blocks_excluding(&blocks, &mate_blocks); + return; + } + if record.mpos() >= pos { + let qname = record.qname().to_vec(); + self.pending.insert(qname.clone(), blocks.clone()); + self.pending_by_pos + .entry(record.mpos()) + .or_default() + .push(qname); + } + } + + for &(start, end) in &blocks { + self.add_block_usize(start, end); + } + } + + /// Number of pairs still waiting for their second mate. Test-only: the + /// bookkeeping is an implementation detail, but an unbounded map would be + /// a memory leak on a real chromosome, so it is worth asserting on. + #[cfg(test)] + pub fn pending_mates_len(&self) -> usize { + self.pending.len() + } + + /// Drop pending entries whose outstanding mate lies behind `pos` and can + /// therefore never arrive (it was filtered out, or the file is truncated). + fn evict_unclaimable(&mut self, pos: i64) { + while let Some((&mate_pos, _)) = self.pending_by_pos.iter().next() { + if mate_pos >= pos { + break; + } + // Safe: the key came from `iter().next()` on this same map. + let qnames = self.pending_by_pos.remove(&mate_pos).unwrap_or_default(); + for qname in qnames { + self.pending.remove(&qname); + } + } + } + + /// The record's reference-covering blocks as half-open `[start, end)` + /// intervals, clamped to `len`. + fn aligned_blocks(record: &bam::Record, len: usize) -> Vec<(usize, usize)> { + let mut blocks = Vec::new(); let mut pos = record.pos(); for op in record.cigar().iter() { match op { // Reference-consuming and query-consuming: covers the reference. Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { let n = i64::from(*n); - self.add_block(pos, pos + n); + let start = pos.max(0) as usize; + let end = ((pos + n).max(0) as usize).min(len); + if start < end { + blocks.push((start, end)); + } pos += n; } // Reference-consuming only: advances without covering. @@ -72,6 +157,35 @@ impl DepthAccum { Cigar::Ins(_) | Cigar::SoftClip(_) | Cigar::HardClip(_) | Cigar::Pad(_) => {} } } + blocks + } + + /// Add `blocks`, skipping any part already covered by `exclude`. + /// + /// Both sides are in ascending order and non-overlapping within themselves, + /// because each comes from one record's CIGAR walk. + fn add_blocks_excluding(&mut self, blocks: &[(usize, usize)], exclude: &[(usize, usize)]) { + for &(start, end) in blocks { + let mut cursor = start; + for &(ex_start, ex_end) in exclude { + if ex_end <= cursor { + continue; + } + if ex_start >= end { + break; + } + if ex_start > cursor { + self.add_block_usize(cursor, ex_start.min(end)); + } + cursor = cursor.max(ex_end); + if cursor >= end { + break; + } + } + if cursor < end { + self.add_block_usize(cursor, end); + } + } } /// Consume the delta array and return per-base depth for the contig. @@ -92,10 +206,8 @@ impl DepthAccum { record.flags() & self.exclude_flags == 0 && record.mapq() >= self.mapq_cut } - /// Record a half-open aligned block `[start, end)`, clamped to the contig. - fn add_block(&mut self, start: i64, end: i64) { - let start = start.max(0) as usize; - let end = (end.max(0) as usize).min(self.len); + /// Record a half-open aligned block `[start, end)`, already clamped. + fn add_block_usize(&mut self, start: usize, end: usize) { if start >= end { return; } @@ -136,6 +248,20 @@ mod tests { r } + /// Build a paired record whose mate sits at `mate_pos` on the same contig. + fn pair_rec(qname: &[u8], pos: i64, mate_pos: i64, cigar: Vec, read2: bool) -> Record { + let mut r = rec( + pos, + cigar, + 60, + BAM_FPAIRED | BAM_FPROPER_PAIR | if read2 { BAM_FREAD2 } else { BAM_FREAD1 }, + ); + r.set_qname(qname); + r.set_mtid(0); + r.set_mpos(mate_pos); + r + } + #[test] fn match_block_covers_exactly_its_span() { let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); @@ -221,4 +347,98 @@ mod tests { d.process_read(&rec(4, vec![Cigar::Match(10)], 60, 0)); assert_eq!(d.into_depths(), vec![0, 0, 0, 0, 1, 1]); } + + #[test] + fn overlapping_mates_cover_a_base_once() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&pair_rec(b"pair1", 0, 0, vec![Cigar::Match(4)], false)); + d.process_read(&pair_rec(b"pair1", 0, 0, vec![Cigar::Match(4)], true)); + assert_eq!( + &d.into_depths()[0..5], + &[1, 1, 1, 1, 0], + "a base covered by both mates counts once" + ); + } + + #[test] + fn partially_overlapping_mates_count_the_shared_bases_once() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&pair_rec(b"pair1", 0, 2, vec![Cigar::Match(4)], false)); + d.process_read(&pair_rec(b"pair1", 2, 0, vec![Cigar::Match(4)], true)); + // Mate 1 covers 0..4, mate 2 covers 2..6; bases 2 and 3 are shared. + assert_eq!(&d.into_depths()[0..7], &[1, 1, 1, 1, 1, 1, 0]); + } + + #[test] + fn non_overlapping_mates_each_contribute() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&pair_rec(b"pair2", 0, 8, vec![Cigar::Match(4)], false)); + d.process_read(&pair_rec(b"pair2", 8, 0, vec![Cigar::Match(4)], true)); + assert_eq!( + &d.into_depths()[0..13], + &[1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0] + ); + } + + #[test] + fn reads_from_different_pairs_at_the_same_locus_both_count() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&pair_rec(b"pairA", 0, 0, vec![Cigar::Match(4)], false)); + d.process_read(&pair_rec(b"pairB", 0, 0, vec![Cigar::Match(4)], false)); + assert_eq!(d.into_depths()[0], 2); + } + + #[test] + fn the_pending_mate_map_is_emptied_once_both_mates_are_seen() { + let mut d = DepthAccum::new(20, 0, MOSDEPTH_DEFAULT_EXCLUDE); + d.process_read(&pair_rec(b"pair1", 0, 0, vec![Cigar::Match(4)], false)); + d.process_read(&pair_rec(b"pair1", 0, 0, vec![Cigar::Match(4)], true)); + assert_eq!(d.pending_mates_len(), 0, "the entry must be dropped"); + } + + #[test] + fn a_pending_mate_that_never_arrives_is_evicted() { + let mut d = DepthAccum::new(200, 0, MOSDEPTH_DEFAULT_EXCLUDE); + // Its mate is announced at 10 but never turns up (filtered, say). + d.process_read(&pair_rec(b"orphan", 0, 10, vec![Cigar::Match(4)], false)); + assert_eq!(d.pending_mates_len(), 1); + // Walking past position 10 makes the entry unclaimable. + d.process_read(&pair_rec(b"later", 50, 50, vec![Cigar::Match(4)], false)); + assert_eq!( + d.pending_mates_len(), + 1, + "only the unclaimable one is dropped" + ); + } + + /// Engine-level parity check against mosdepth 0.3.14 on the committed + /// fixture. `tests/expected/dna/test.mosdepth.summary.txt` records + /// `total 40001 247878 6.20 0 867` for this BAM, so the total covered + /// bases and the maximum depth are both pinned here. Getting this right + /// requires the flag filter, the CIGAR walk and the mate-overlap + /// correction to all be right at once. + #[test] + fn total_covered_bases_match_mosdepth_on_the_fixture() { + use rust_htslib::bam::Read; + + let bam_path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/dna/test.dna.bam"); + let mut bam = bam::Reader::from_path(bam_path).unwrap(); + let header = bam.header().to_owned(); + let contig_len = header.target_len(0).unwrap(); + + let mut accum = DepthAccum::new(contig_len, 0, MOSDEPTH_DEFAULT_EXCLUDE); + let mut record = Record::new(); + while let Some(result) = bam.read(&mut record) { + result.unwrap(); + accum.process_read(&record); + } + + let depths = accum.into_depths(); + let total: u64 = depths.iter().map(|d| u64::from(*d)).sum(); + let max = depths.iter().copied().max().unwrap(); + + assert_eq!(depths.len(), 40001, "contig length"); + assert_eq!(total, 247878, "total covered bases must match mosdepth"); + assert_eq!(max, 867, "maximum depth must match mosdepth"); + } } From 726bfbf552394ad640f39da531ee4ec7f758db10 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 19:01:06 +0200 Subject: [PATCH 11/22] feat(dna): add the mosdepth-compatible output writers Six writers plus the per-contig summarisation that feeds them: summary, global and region distributions, per-base runs, per-window means and per-window threshold counts. Compressed outputs are bgzf, matching mosdepth. The distribution emission rule was reverse-engineered from the fixtures and is the non-obvious part: every depth from 0 up to min(300, max) gets a row whether or not any base sits at it, above 300 only depths that occur and lie strictly below the maximum do. So the maximum gets a row when it falls inside the dense range and none when it does not. The global distribution tops out at 866 with a maximum of 867, while the region distribution does emit its maximum of 204. The region distribution is over windows and their rounded mean depth, not over bases. Parity tests drive the library directly and compare every mosdepth output against the committed fixtures: all eight match, including the 1094-line global distribution and the 721-interval per-base BED. Co-Authored-By: Claude Opus 5 (1M context) --- src/dna/mod.rs | 1 + src/dna/mosdepth/mod.rs | 427 ++++++++++++++++++++++++++++++++++ src/dna/mosdepth/output.rs | 289 +++++++++++++++++++++++ tests/dna_integration_test.rs | 185 +++++++++++++++ 4 files changed, 902 insertions(+) create mode 100644 src/dna/mosdepth/mod.rs create mode 100644 src/dna/mosdepth/output.rs create mode 100644 tests/dna_integration_test.rs diff --git a/src/dna/mod.rs b/src/dna/mod.rs index 8d3ac015..eafde9f9 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -5,3 +5,4 @@ //! and preseq are shared with the RNA pipeline and live in [`crate::common`]. pub mod depth; +pub mod mosdepth; diff --git a/src/dna/mosdepth/mod.rs b/src/dna/mosdepth/mod.rs new file mode 100644 index 00000000..b8fe1f8f --- /dev/null +++ b/src/dna/mosdepth/mod.rs @@ -0,0 +1,427 @@ +//! mosdepth-compatible depth of coverage results. +//! +//! [`ContigDepth::from_depths`] turns one contig's per-base depth vector into +//! everything the six mosdepth outputs need, in a single pass over the vector, +//! so the depth vector can be dropped as soon as the contig is done. +//! +//! # Output formats +//! +//! These were derived from mosdepth 0.3.14 output committed under +//! `tests/expected/dna/`, not from documentation, and every rule below was +//! checked against every row of those fixtures. +//! +//! `{prefix}.mosdepth.summary.txt` carries the header +//! `chrom length bases mean min max`, one row per contig, then one +//! `{contig}_region` row per contig when windows were requested, then `total` +//! and `total_region`. `mean` is `bases / length` to two decimals. +//! +//! `{prefix}.mosdepth.global.dist.txt` and `.region.dist.txt` carry +//! `chrom depth proportion` rows in descending depth order, where `proportion` +//! is the fraction at depth **at or above** `depth`, formatted to two +//! decimals, ending at depth 0 with `1.00`. Which depths get a row is the +//! non-obvious part: +//! +//! - depths 0 through [`DIST_DENSE_MAX`] always get a row, even when no base +//! sits at that exact depth; +//! - above that, only depths that actually occur; +//! - the maximum observed depth never gets a row. +//! +//! The global distribution is over bases and their exact depth; the region +//! distribution is over windows and their **rounded** mean depth. + +use std::collections::BTreeMap; + +pub mod output; + +/// Highest depth that always gets a distribution row, matching the size of +/// mosdepth's internal fixed depth array. +pub const DIST_DENSE_MAX: u32 = 300; + +/// A run of consecutive bases sharing one depth, as written to `per-base.bed.gz`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepthRun { + /// Zero-based, inclusive start. + pub start: u64, + /// Zero-based, exclusive end. + pub end: u64, + /// Depth shared by every base in the run. + pub depth: u32, +} + +/// A fixed-width window and its mean depth, as written to `regions.bed.gz`. +#[derive(Debug, Clone, PartialEq)] +pub struct WindowDepth { + /// Zero-based, inclusive start. + pub start: u64, + /// Zero-based, exclusive end. + pub end: u64, + /// Mean depth over the window. + pub mean: f64, +} + +/// One window's per-threshold counts, as written to `thresholds.bed.gz`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThresholdRow { + /// Zero-based, inclusive start. + pub start: u64, + /// Zero-based, exclusive end. + pub end: u64, + /// Bases at or above each requested threshold, in the requested order. + pub counts: Vec, +} + +/// Everything the mosdepth outputs need about one contig. +#[derive(Debug, Clone)] +pub struct ContigDepth { + /// Contig name as it appears in the alignment header. + pub name: String, + /// Contig length in bases. + pub length: u64, + /// Sum of per-base depth over the contig. + pub total_bases: u64, + /// Lowest per-base depth seen. + pub min: u32, + /// Highest per-base depth seen. + pub max: u32, + /// Base count per exact depth. + pub histogram: BTreeMap, + /// Collapsed runs of equal depth. + pub runs: Vec, + /// Per-window mean depth; empty when no window size was requested. + pub windows: Vec, + /// Per-window threshold counts; empty when no thresholds were requested. + pub thresholds: Vec, +} + +impl ContigDepth { + /// Summarise one contig's per-base depths in a single pass. + pub fn from_depths( + name: &str, + depths: &[u32], + window_size: Option, + thresholds: &[u32], + ) -> Self { + let length = depths.len() as u64; + let mut histogram: BTreeMap = BTreeMap::new(); + let mut runs: Vec = Vec::new(); + let mut total_bases = 0u64; + + for (i, &depth) in depths.iter().enumerate() { + total_bases += u64::from(depth); + *histogram.entry(depth).or_insert(0) += 1; + match runs.last_mut() { + Some(run) if run.depth == depth => run.end = i as u64 + 1, + _ => runs.push(DepthRun { + start: i as u64, + end: i as u64 + 1, + depth, + }), + } + } + + let min = depths.iter().copied().min().unwrap_or(0); + let max = depths.iter().copied().max().unwrap_or(0); + + let (windows, threshold_rows) = match window_size { + Some(size) if size > 0 => Self::windowed(depths, u64::from(size), thresholds), + _ => (Vec::new(), Vec::new()), + }; + + Self { + name: name.to_string(), + length, + total_bases, + min, + max, + histogram, + runs, + windows, + thresholds: threshold_rows, + } + } + + /// Split the contig into fixed-width windows, computing each window's mean + /// depth and its per-threshold base counts. + fn windowed( + depths: &[u32], + size: u64, + thresholds: &[u32], + ) -> (Vec, Vec) { + let mut windows = Vec::new(); + let mut rows = Vec::new(); + for (index, chunk) in depths.chunks(size as usize).enumerate() { + let start = index as u64 * size; + let end = start + chunk.len() as u64; + let sum: u64 = chunk.iter().map(|d| u64::from(*d)).sum(); + windows.push(WindowDepth { + start, + end, + mean: sum as f64 / chunk.len() as f64, + }); + if !thresholds.is_empty() { + let counts = thresholds + .iter() + .map(|t| chunk.iter().filter(|d| *d >= t).count() as u64) + .collect(); + rows.push(ThresholdRow { start, end, counts }); + } + } + (windows, rows) + } + + /// Mean depth over the contig. + pub fn mean(&self) -> f64 { + if self.length == 0 { + 0.0 + } else { + self.total_bases as f64 / self.length as f64 + } + } + + /// Histogram of window mean depths, rounded to the nearest integer, which + /// is what the region distribution is built from. + pub fn region_histogram(&self) -> BTreeMap { + let mut hist = BTreeMap::new(); + for window in &self.windows { + let key = window.mean.round().max(0.0) as u32; + *hist.entry(key).or_insert(0) += 1; + } + hist + } +} + +/// The mosdepth result for one alignment file. +#[derive(Debug, Clone)] +pub struct MosdepthResult { + /// Per-contig results, in alignment-header order. + pub contigs: Vec, + /// Window size, when per-window output was requested. + pub window_size: Option, + /// Requested coverage thresholds, in the order they are reported. + pub thresholds: Vec, +} + +impl MosdepthResult { + /// Total length across all contigs. + pub fn total_length(&self) -> u64 { + self.contigs.iter().map(|c| c.length).sum() + } + + /// Total covered bases across all contigs. + pub fn total_bases(&self) -> u64 { + self.contigs.iter().map(|c| c.total_bases).sum() + } + + /// Mean depth across all contigs. + pub fn mean(&self) -> f64 { + let length = self.total_length(); + if length == 0 { + 0.0 + } else { + self.total_bases() as f64 / length as f64 + } + } + + /// Lowest depth across all contigs. + pub fn min(&self) -> u32 { + self.contigs.iter().map(|c| c.min).min().unwrap_or(0) + } + + /// Highest depth across all contigs. + pub fn max(&self) -> u32 { + self.contigs.iter().map(|c| c.max).max().unwrap_or(0) + } +} + +/// Merge histograms element-wise. +pub fn merge_histograms<'a>( + parts: impl IntoIterator>, +) -> BTreeMap { + let mut merged = BTreeMap::new(); + for part in parts { + for (depth, count) in part { + *merged.entry(*depth).or_insert(0) += count; + } + } + merged +} + +/// The depths that get a distribution row, in descending order. +/// +/// The rule was derived from the committed fixtures and holds for both +/// distribution files: every depth from 0 up to `min(DIST_DENSE_MAX, max)` +/// gets a row whether or not anything sits at it, and above +/// [`DIST_DENSE_MAX`] only depths that actually occur and lie strictly below +/// the maximum do. +/// +/// The consequence worth stating plainly: the maximum observed depth gets a +/// row when it falls inside the dense range and no row when it does not. On +/// the project fixture the global distribution tops out at 866 with a maximum +/// of 867, while the region distribution does emit its maximum of 204. +pub fn dist_rows(histogram: &BTreeMap) -> Vec { + let observed_max = histogram + .iter() + .filter(|(_, count)| **count > 0) + .map(|(depth, _)| *depth) + .max() + .unwrap_or(0); + + let mut depths: Vec = histogram + .iter() + .filter(|(depth, count)| **count > 0 && **depth > DIST_DENSE_MAX && **depth < observed_max) + .map(|(depth, _)| *depth) + .collect(); + depths.extend(0..=DIST_DENSE_MAX.min(observed_max)); + depths.sort_unstable_by(|a, b| b.cmp(a)); + depths.dedup(); + depths +} + +/// Cumulative proportion at or above each depth in `rows`, given `histogram` +/// and a total to divide by. +pub fn dist_proportions(histogram: &BTreeMap, rows: &[u32], total: u64) -> Vec { + if total == 0 { + return vec![0.0; rows.len()]; + } + rows.iter() + .map(|threshold| { + let at_or_above: u64 = histogram + .iter() + .filter(|(depth, _)| *depth >= threshold) + .map(|(_, count)| count) + .sum(); + at_or_above as f64 / total as f64 + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_collapse_equal_neighbours() { + let c = ContigDepth::from_depths("chr1", &[0, 0, 0, 2, 2, 1], None, &[]); + assert_eq!( + c.runs, + vec![ + DepthRun { + start: 0, + end: 3, + depth: 0 + }, + DepthRun { + start: 3, + end: 5, + depth: 2 + }, + DepthRun { + start: 5, + end: 6, + depth: 1 + }, + ] + ); + } + + #[test] + fn summary_figures_are_computed_over_the_whole_contig() { + let c = ContigDepth::from_depths("chr1", &[0, 0, 3, 5], None, &[]); + assert_eq!(c.length, 4); + assert_eq!(c.total_bases, 8); + assert_eq!(c.min, 0); + assert_eq!(c.max, 5); + assert!((c.mean() - 2.0).abs() < 1e-12); + } + + #[test] + fn windows_cover_the_tail_even_when_shorter_than_the_window() { + let c = ContigDepth::from_depths("chr1", &[4, 4, 4, 4, 10], Some(4), &[]); + assert_eq!(c.windows.len(), 2); + assert_eq!( + c.windows[0], + WindowDepth { + start: 0, + end: 4, + mean: 4.0 + } + ); + assert_eq!( + c.windows[1], + WindowDepth { + start: 4, + end: 5, + mean: 10.0 + } + ); + } + + #[test] + fn threshold_counts_are_at_or_above_each_threshold() { + let c = ContigDepth::from_depths("chr1", &[0, 1, 5, 10], Some(4), &[1, 5, 20]); + assert_eq!(c.thresholds.len(), 1); + assert_eq!(c.thresholds[0].counts, vec![3, 2, 0]); + } + + #[test] + fn dist_rows_emit_a_maximum_that_falls_inside_the_dense_range() { + let mut hist = BTreeMap::new(); + hist.insert(0u32, 10u64); + hist.insert(204, 1); // the maximum, but below DIST_DENSE_MAX + let rows = dist_rows(&hist); + assert_eq!( + rows.first(), + Some(&204), + "a maximum inside the dense range is emitted" + ); + assert_eq!(rows.len(), 205, "0 through 204 inclusive"); + } + + #[test] + fn dist_rows_skip_a_maximum_above_the_dense_range() { + let mut hist = BTreeMap::new(); + hist.insert(0u32, 10u64); + hist.insert(5, 2); + hist.insert(400, 1); + hist.insert(500, 1); // the maximum, never emitted + let rows = dist_rows(&hist); + assert!(!rows.contains(&500), "the maximum depth gets no row"); + assert!( + rows.contains(&400), + "an observed depth above the dense range does" + ); + assert!( + rows.contains(&7), + "an unobserved depth inside the dense range does" + ); + assert!( + !rows.contains(&350), + "an unobserved depth above the dense range does not" + ); + assert_eq!(rows.first(), Some(&400), "descending order"); + assert_eq!(rows.last(), Some(&0), "down to zero"); + } + + #[test] + fn dist_proportions_are_cumulative_from_the_top() { + let mut hist = BTreeMap::new(); + hist.insert(0u32, 2u64); + hist.insert(1, 1); + hist.insert(3, 1); + let rows = vec![3u32, 2, 1, 0]; + let props = dist_proportions(&hist, &rows, 4); + assert!((props[0] - 0.25).abs() < 1e-12); + assert!((props[1] - 0.25).abs() < 1e-12); + assert!((props[2] - 0.50).abs() < 1e-12); + assert!((props[3] - 1.00).abs() < 1e-12); + } + + #[test] + fn region_histogram_rounds_window_means() { + let c = ContigDepth::from_depths("chr1", &[1, 2, 2, 3], Some(2), &[]); + // Windows: mean 1.5 rounds to 2, mean 2.5 rounds to 3 (away from zero). + let hist = c.region_histogram(); + assert_eq!(hist.get(&2), Some(&1)); + assert_eq!(hist.get(&3), Some(&1)); + } +} diff --git a/src/dna/mosdepth/output.rs b/src/dna/mosdepth/output.rs new file mode 100644 index 00000000..471b86fc --- /dev/null +++ b/src/dna/mosdepth/output.rs @@ -0,0 +1,289 @@ +//! Writers for the six mosdepth-compatible output files. +//! +//! Formats are documented in the parent module. Compressed outputs are written +//! as bgzf, which is what mosdepth writes and what both `tabix` and `gunzip` +//! read. Parity against the fixtures is therefore asserted on the decompressed +//! bytes: two bgzf writers at the same level need not emit identical +//! compressed bytes, so comparing the `.gz` byte for byte would be testing the +//! compressor rather than this code. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bgzf; + +use super::{dist_proportions, dist_rows, merge_histograms, MosdepthResult}; + +/// Write `{prefix}.mosdepth.summary.txt`. +pub fn write_summary(result: &MosdepthResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create summary file: {}", path.display()))?; + + writeln!(out, "chrom\tlength\tbases\tmean\tmin\tmax")?; + for contig in &result.contigs { + writeln!( + out, + "{}\t{}\t{}\t{:.2}\t{}\t{}", + contig.name, + contig.length, + contig.total_bases, + contig.mean(), + contig.min, + contig.max + )?; + if result.window_size.is_some() { + writeln!( + out, + "{}_region\t{}\t{}\t{:.2}\t{}\t{}", + contig.name, + contig.length, + contig.total_bases, + contig.mean(), + contig.min, + contig.max + )?; + } + } + writeln!( + out, + "total\t{}\t{}\t{:.2}\t{}\t{}", + result.total_length(), + result.total_bases(), + result.mean(), + result.min(), + result.max() + )?; + if result.window_size.is_some() { + writeln!( + out, + "total_region\t{}\t{}\t{:.2}\t{}\t{}", + result.total_length(), + result.total_bases(), + result.mean(), + result.min(), + result.max() + )?; + } + out.flush()?; + Ok(()) +} + +/// Write `{prefix}.mosdepth.global.dist.txt`, the distribution over bases. +pub fn write_global_dist(result: &MosdepthResult, path: &Path) -> Result<()> { + let per_contig: Vec<_> = result + .contigs + .iter() + .map(|c| (c.name.as_str(), c.histogram.clone(), c.length)) + .collect(); + write_dist(&per_contig, path) +} + +/// Write `{prefix}.mosdepth.region.dist.txt`, the distribution over windows +/// and their rounded mean depth. +pub fn write_region_dist(result: &MosdepthResult, path: &Path) -> Result<()> { + let per_contig: Vec<_> = result + .contigs + .iter() + .map(|c| { + let hist = c.region_histogram(); + let total = hist.values().sum::(); + (c.name.as_str(), hist, total) + }) + .collect(); + write_dist(&per_contig, path) +} + +/// Shared body of both distribution writers. +fn write_dist( + per_contig: &[(&str, std::collections::BTreeMap, u64)], + path: &Path, +) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create distribution file: {}", path.display()))?; + + for (name, histogram, total) in per_contig { + let rows = dist_rows(histogram); + for (depth, proportion) in rows.iter().zip(dist_proportions(histogram, &rows, *total)) { + writeln!(out, "{name}\t{depth}\t{proportion:.2}")?; + } + } + + let merged = merge_histograms(per_contig.iter().map(|(_, h, _)| h)); + let total: u64 = per_contig.iter().map(|(_, _, t)| t).sum(); + let rows = dist_rows(&merged); + for (depth, proportion) in rows.iter().zip(dist_proportions(&merged, &rows, total)) { + writeln!(out, "total\t{depth}\t{proportion:.2}")?; + } + + out.flush()?; + Ok(()) +} + +/// Write `{prefix}.per-base.bed.gz`, one line per run of equal depth. +pub fn write_per_base(result: &MosdepthResult, path: &Path) -> Result<()> { + let mut lines = Vec::new(); + for contig in &result.contigs { + for run in &contig.runs { + lines.push(format!( + "{}\t{}\t{}\t{}\n", + contig.name, run.start, run.end, run.depth + )); + } + } + write_bgzf(path, &lines.concat()) +} + +/// Write `{prefix}.regions.bed.gz`, one line per window with its mean depth. +pub fn write_regions(result: &MosdepthResult, path: &Path) -> Result<()> { + let mut lines = Vec::new(); + for contig in &result.contigs { + for window in &contig.windows { + lines.push(format!( + "{}\t{}\t{}\t{:.2}\n", + contig.name, window.start, window.end, window.mean + )); + } + } + write_bgzf(path, &lines.concat()) +} + +/// Write `{prefix}.thresholds.bed.gz`, one line per window with the number of +/// bases at or above each requested threshold. +pub fn write_thresholds(result: &MosdepthResult, path: &Path) -> Result<()> { + let mut body = String::from("#chrom\tstart\tend\tregion"); + for threshold in &result.thresholds { + body.push_str(&format!("\t{threshold}X")); + } + body.push('\n'); + + for contig in &result.contigs { + for row in &contig.thresholds { + body.push_str(&format!( + "{}\t{}\t{}\tunknown", + contig.name, row.start, row.end + )); + for count in &row.counts { + body.push_str(&format!("\t{count}")); + } + body.push('\n'); + } + } + write_bgzf(path, &body) +} + +/// Write `contents` to `path` as bgzf. +fn write_bgzf(path: &Path, contents: &str) -> Result<()> { + let mut writer = bgzf::Writer::from_path(path) + .with_context(|| format!("Failed to create bgzf file: {}", path.display()))?; + writer + .write_all(contents.as_bytes()) + .with_context(|| format!("Failed to write bgzf file: {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dna::mosdepth::ContigDepth; + use std::io::Read; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("rustqc-mosdepth-tests"); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + fn result_with_windows() -> MosdepthResult { + let depths = vec![0u32, 0, 2, 2, 4, 4]; + MosdepthResult { + contigs: vec![ContigDepth::from_depths("chr1", &depths, Some(3), &[1, 4])], + window_size: Some(3), + thresholds: vec![1, 4], + } + } + + fn read_bgzf(path: &std::path::Path) -> String { + let mut reader = bgzf::Reader::from_path(path).unwrap(); + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn summary_has_region_rows_only_when_windows_were_requested() { + let path = scratch("summary_windows.txt"); + write_summary(&result_with_windows(), &path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + text, + "chrom\tlength\tbases\tmean\tmin\tmax\n\ + chr1\t6\t12\t2.00\t0\t4\n\ + chr1_region\t6\t12\t2.00\t0\t4\n\ + total\t6\t12\t2.00\t0\t4\n\ + total_region\t6\t12\t2.00\t0\t4\n" + ); + + let depths = vec![0u32, 0, 2, 2, 4, 4]; + let no_windows = MosdepthResult { + contigs: vec![ContigDepth::from_depths("chr1", &depths, None, &[])], + window_size: None, + thresholds: vec![], + }; + let path = scratch("summary_nowindows.txt"); + write_summary(&no_windows, &path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("_region"), "no windows means no region rows"); + } + + #[test] + fn per_base_writes_one_line_per_run() { + let path = scratch("per-base.bed.gz"); + write_per_base(&result_with_windows(), &path).unwrap(); + assert_eq!( + read_bgzf(&path), + "chr1\t0\t2\t0\nchr1\t2\t4\t2\nchr1\t4\t6\t4\n" + ); + } + + #[test] + fn regions_carry_two_decimal_means() { + let path = scratch("regions.bed.gz"); + write_regions(&result_with_windows(), &path).unwrap(); + assert_eq!(read_bgzf(&path), "chr1\t0\t3\t0.67\nchr1\t3\t6\t3.33\n"); + } + + #[test] + fn thresholds_carry_a_header_and_one_column_per_threshold() { + let path = scratch("thresholds.bed.gz"); + write_thresholds(&result_with_windows(), &path).unwrap(); + assert_eq!( + read_bgzf(&path), + "#chrom\tstart\tend\tregion\t1X\t4X\n\ + chr1\t0\t3\tunknown\t1\t0\n\ + chr1\t3\t6\tunknown\t3\t2\n" + ); + } + + #[test] + fn global_dist_is_descending_and_ends_at_one() { + let path = scratch("global.dist.txt"); + write_global_dist(&result_with_windows(), &path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let chr1: Vec<&str> = text.lines().filter(|l| l.starts_with("chr1\t")).collect(); + assert_eq!( + *chr1.first().unwrap(), + "chr1\t4\t0.33", + "descending from the maximum" + ); + assert_eq!(*chr1.last().unwrap(), "chr1\t0\t1.00", "down to zero"); + assert_eq!( + chr1.len(), + 5, + "depths 4 down to 0, all inside the dense range" + ); + assert!(text.contains("total\t0\t1.00")); + } +} diff --git a/tests/dna_integration_test.rs b/tests/dna_integration_test.rs new file mode 100644 index 00000000..17fa995c --- /dev/null +++ b/tests/dna_integration_test.rs @@ -0,0 +1,185 @@ +//! Parity tests for the DNA pipeline against the committed reference outputs. +//! +//! The fixtures under `tests/expected/dna/` are the output of the upstream +//! tools themselves, at the versions pinned in `VERSIONS.txt`. A failure here +//! is a defect in RustQC, not a reason to regenerate the fixture. +//! +//! Compressed outputs are compared on their decompressed bytes. Two bgzf +//! writers at the same compression level need not emit identical compressed +//! bytes, so comparing the `.gz` files directly would test the compressor +//! rather than this code. + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use rust_htslib::bam::Read as BamRead; +use rust_htslib::{bam, bgzf}; + +use rustqc::dna::depth::{DepthAccum, MOSDEPTH_DEFAULT_EXCLUDE}; +use rustqc::dna::mosdepth::{output, ContigDepth, MosdepthResult}; + +/// Window size and thresholds the fixtures were generated with. +const WINDOW_SIZE: u32 = 500; +const THRESHOLDS: [u32; 7] = [1, 5, 10, 15, 20, 30, 50]; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/expected/dna") + .join(name) +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("rustqc-dna-parity"); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +/// Run the depth engine over the committed test BAM and summarise it exactly +/// as the fixtures were generated. +fn compute() -> MosdepthResult { + let bam_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/dna/test.dna.bam"); + let mut reader = bam::Reader::from_path(&bam_path).unwrap(); + let header = reader.header().to_owned(); + + let mut contigs = Vec::new(); + for tid in 0..header.target_count() { + let name = String::from_utf8(header.tid2name(tid).to_vec()).unwrap(); + let length = header.target_len(tid).unwrap(); + let mut accum = DepthAccum::new(length, 0, MOSDEPTH_DEFAULT_EXCLUDE); + + let mut record = bam::Record::new(); + let mut per_contig = bam::Reader::from_path(&bam_path).unwrap(); + while let Some(result) = per_contig.read(&mut record) { + result.unwrap(); + if record.tid() == tid as i32 { + accum.process_read(&record); + } + } + let depths = accum.into_depths(); + contigs.push(ContigDepth::from_depths( + &name, + &depths, + Some(WINDOW_SIZE), + &THRESHOLDS, + )); + } + + MosdepthResult { + contigs, + window_size: Some(WINDOW_SIZE), + thresholds: THRESHOLDS.to_vec(), + } +} + +fn read_bgzf(path: &Path) -> String { + let mut reader = bgzf::Reader::from_path(path).unwrap(); + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).unwrap(); + String::from_utf8(buf).unwrap() +} + +/// Compare line by line so a failure names the offending row. +fn assert_same_lines(actual: &str, expected: &str, what: &str) { + let a: Vec<&str> = actual.lines().collect(); + let e: Vec<&str> = expected.lines().collect(); + for (i, (got, want)) in a.iter().zip(e.iter()).enumerate() { + assert_eq!(got, want, "{what}: line {} differs", i + 1); + } + assert_eq!(a.len(), e.len(), "{what}: line count differs"); +} + +#[test] +fn fixture_tool_versions_are_the_pinned_ones() { + let versions = std::fs::read_to_string(fixture("VERSIONS.txt")).unwrap(); + assert!( + versions.contains("mosdepth\t0.3.14"), + "unexpected mosdepth fixture version: {versions}" + ); + assert!( + versions.contains("samtools\t1.24"), + "unexpected samtools fixture version: {versions}" + ); +} + +#[test] +fn summary_matches_mosdepth() { + let path = scratch("test.mosdepth.summary.txt"); + output::write_summary(&compute(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.mosdepth.summary.txt")).unwrap(), + "summary", + ); +} + +#[test] +fn global_dist_matches_mosdepth() { + let path = scratch("test.mosdepth.global.dist.txt"); + output::write_global_dist(&compute(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.mosdepth.global.dist.txt")).unwrap(), + "global dist", + ); +} + +#[test] +fn region_dist_matches_mosdepth() { + let path = scratch("test.mosdepth.region.dist.txt"); + output::write_region_dist(&compute(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.mosdepth.region.dist.txt")).unwrap(), + "region dist", + ); +} + +#[test] +fn per_base_matches_mosdepth() { + let path = scratch("test.per-base.bed.gz"); + output::write_per_base(&compute(), &path).unwrap(); + assert_same_lines( + &read_bgzf(&path), + &read_bgzf(&fixture("test.per-base.bed.gz")), + "per-base", + ); +} + +#[test] +fn regions_match_mosdepth() { + let path = scratch("test.regions.bed.gz"); + output::write_regions(&compute(), &path).unwrap(); + assert_same_lines( + &read_bgzf(&path), + &read_bgzf(&fixture("test.regions.bed.gz")), + "regions", + ); +} + +#[test] +fn thresholds_match_mosdepth() { + let path = scratch("test.thresholds.bed.gz"); + output::write_thresholds(&compute(), &path).unwrap(); + assert_same_lines( + &read_bgzf(&path), + &read_bgzf(&fixture("test.thresholds.bed.gz")), + "thresholds", + ); +} + +/// The depth histogram is the input to both distribution files, so pinning it +/// separately makes a distribution failure easy to attribute. +#[test] +fn depth_histogram_matches_the_per_base_fixture() { + let result = compute(); + let mut expected: BTreeMap = BTreeMap::new(); + for line in read_bgzf(&fixture("test.per-base.bed.gz")).lines() { + let fields: Vec<&str> = line.split('\t').collect(); + let start: u64 = fields[1].parse().unwrap(); + let end: u64 = fields[2].parse().unwrap(); + let depth: u32 = fields[3].parse().unwrap(); + *expected.entry(depth).or_insert(0) += end - start; + } + assert_eq!(result.contigs[0].histogram, expected); +} From 11190bb01eddd9172927eeacff0cdcc744cf69f5 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 19:10:49 +0200 Subject: [PATCH 12/22] feat(dna): wire up the run_dna pipeline One rayon worker per contig, each holding its own depth array, feeding a DepthAccum, a BamStatAccum and a PreseqAccum from the same record stream, so the alignment is read once. A separate pass over unmapped records feeds the counters flagstat and idxstats report. Workers run longest contig first and their number is bounded by --max-depth-workers, defaulting to a 4 GB budget divided by the largest contig, because each worker costs four bytes per base. Outputs land under mosdepth/, samtools/ and preseq/, or flat with --flat-output. Input without duplicate marks is rejected unless --skip-dup-check is passed. Also fixes the samtools stats header, which hardcoded "rustqc rna" and so labelled DNA output as RNA output. End-to-end parity tests run the binary and compare against the fixtures: all six mosdepth files match byte for byte, flagstat and idxstats match exactly, and all 1889 data lines of samtools stats match. The stats header differs by design, RustQC naming itself rather than reproducing samtools' version banner, so that comparison is on data lines. Co-Authored-By: Claude Opus 5 (1M context) --- src/common/samtools/stats.rs | 2 +- src/main.rs | 417 +++++++++++++++++++++++++++++++++- tests/dna_integration_test.rs | 118 ++++++++++ 3 files changed, 530 insertions(+), 7 deletions(-) diff --git a/src/common/samtools/stats.rs b/src/common/samtools/stats.rs index 948dcdef..32f42067 100644 --- a/src/common/samtools/stats.rs +++ b/src/common/samtools/stats.rs @@ -62,7 +62,7 @@ pub fn write_stats(result: &BamStatResult, output_path: &Path) -> Result<()> { writeln!(out, "# This file was produced by samtools stats and RustQC")?; writeln!( out, - "# The command line was: rustqc rna (samtools stats compatible output)" + "# The command line was: rustqc (samtools stats compatible output)" )?; // Derived values diff --git a/src/main.rs b/src/main.rs index 3934181c..16360d03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use indexmap::IndexMap; use log::debug; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use rustqc::io::{format_count, format_duration, format_pct}; @@ -26,6 +26,7 @@ use rustqc::{common, config, cpu, gtf, rna, summary}; use ui::{Ui, Verbosity}; +use rust_htslib::bam; use rust_htslib::bam::Read as BamRead; use rna::rseqc::accumulators::{RseqcAccumulators, RseqcAnnotations, RseqcConfig}; @@ -105,11 +106,415 @@ fn main() -> Result<()> { /// Run the DNA QC pipeline: depth of coverage, samtools-compatible outputs /// and library complexity estimation in a single pass over each input. /// -/// Not implemented yet; the pipeline lands over the following tasks in this -/// branch. The subcommand is wired up first so the CLI surface can be -/// reviewed and tested on its own. -fn run_dna(_args: cli::DnaArgs, _ui: &Ui) -> Result<()> { - anyhow::bail!("the dna subcommand is not implemented yet") +/// Contigs are processed in parallel, one worker per contig, each holding its +/// own depth array. Input files are processed one after another so that the +/// per-contig parallelism gets the whole thread budget. +fn run_dna(args: cli::DnaArgs, ui: &Ui) -> Result<()> { + let run_start = Instant::now(); + let timestamp_start = format_utc_now(); + + let (merged, config_paths) = config::load_merged_config(args.config.as_deref())?; + let mut config = merged.dna; + + // CLI flags override the configuration file. + if !args.depth_thresholds.is_empty() { + config.mosdepth.thresholds = args.depth_thresholds.clone(); + } + if let Some(window) = args.window_size { + config.mosdepth.window_size = Some(window); + } + if args.skip_per_base { + config.mosdepth.skip_per_base = true; + } + if args.skip_preseq { + config.preseq.enabled = false; + } + if let Some(seed) = args.preseq_seed { + config.preseq.seed = seed; + } + if let Some(val) = args.preseq_max_extrap { + config.preseq.max_extrap = val; + } + if let Some(val) = args.preseq_step_size { + config.preseq.step_size = val; + } + if let Some(val) = args.preseq_n_bootstraps { + config.preseq.n_bootstraps = val; + } + if let Some(val) = args.preseq_seg_len { + config.preseq.max_segment_length = val; + } + + let flat_output = args.flat_output || config.flat_output; + let outdir = Path::new(&args.outdir); + std::fs::create_dir_all(outdir) + .with_context(|| format!("Failed to create output directory: {}", outdir.display()))?; + + ui.header( + env!("CARGO_PKG_VERSION"), + env!("GIT_SHORT_HASH"), + env!("BUILD_TIMESTAMP"), + Some(&rustqc::cpu::cpu_info_line()), + ); + for (path, source) in &config_paths { + ui.config("Config", &format!("{} ({source})", path.display())); + } + ui.config("Output dir", &args.outdir); + ui.config("Threads", &args.threads.to_string()); + if let Some(ref targets) = args.targets { + ui.config("Targets", targets); + ui.warn("--targets is accepted but targeted metrics are not implemented yet"); + } + + let mut inputs = Vec::new(); + for bam_path in &args.input { + let bam_start = Instant::now(); + let name = Path::new(bam_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(bam_path.as_str()) + .to_string(); + + match process_single_dna_bam(bam_path, &args, &config, outdir, flat_output, ui) { + Ok(mut summary) => { + summary.runtime_seconds = bam_start.elapsed().as_secs_f64(); + ui.bam_result_ok(&name, bam_start.elapsed()); + inputs.push(summary); + } + Err(e) => { + ui.bam_result_err(&name, &format!("{e:#}")); + inputs.push(summary::InputSummary { + bam_file: bam_path.clone(), + status: "failed".to_string(), + error: Some(format!("{e:#}")), + runtime_seconds: bam_start.elapsed().as_secs_f64(), + counting: None, + dupradar: None, + outputs: Vec::new(), + }); + } + } + } + + if let Some(ref json_path) = args.json_summary { + let summary = summary::RunSummary { + version: env!("CARGO_PKG_VERSION").to_string(), + commit: env!("GIT_SHORT_HASH").to_string(), + binary_target: cpu::binary_target().to_string(), + cpu_features: cpu::detected_features() + .iter() + .map(|s| s.to_string()) + .collect(), + timestamp_start, + timestamp_end: format_utc_now(), + runtime_seconds: run_start.elapsed().as_secs_f64(), + inputs, + }; + let json = serde_json::to_string_pretty(&summary)?; + if json_path == "-" { + println!("{json}"); + } else { + let path = if json_path.is_empty() { + outdir.join("rustqc_summary.json") + } else { + PathBuf::from(json_path) + }; + std::fs::write(&path, json) + .with_context(|| format!("Failed to write JSON summary: {}", path.display()))?; + } + } + + ui.finish("DNA QC", run_start.elapsed()); + Ok(()) +} + +/// Process one alignment file through the DNA pipeline. +fn process_single_dna_bam( + bam_path: &str, + args: &cli::DnaArgs, + config: &config::DnaConfig, + outdir: &Path, + flat_output: bool, + ui: &Ui, +) -> Result { + use rustqc::common::bam_stat_accum::BamStatAccum; + use rustqc::common::preseq::PreseqAccum; + use rustqc::dna::depth::{DepthAccum, MOSDEPTH_DEFAULT_EXCLUDE}; + use rustqc::dna::mosdepth::{output as mos_out, ContigDepth, MosdepthResult}; + + let sample_name = args + .sample_name + .clone() + .or_else(|| config.sample_name.clone()) + .unwrap_or_else(|| { + Path::new(bam_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("sample") + .to_string() + }); + + let is_cram = bam_path.ends_with(".cram"); + ensure!( + !is_cram || args.reference.is_some(), + "CRAM input requires --reference" + ); + + // Read the header once to learn the contigs. + let header = { + let reader = bam::IndexedReader::from_path(bam_path) + .with_context(|| format!("Failed to open alignment file: {bam_path}"))?; + reader.header().to_owned() + }; + let mut contigs: Vec<(u32, String, u64)> = (0..header.target_count()) + .map(|tid| { + let name = String::from_utf8_lossy(header.tid2name(tid)).to_string(); + let len = header.target_len(tid).unwrap_or(0); + (tid, name, len) + }) + .collect(); + // Longest first, so the biggest depth arrays are allocated while the pool + // is emptiest. + contigs.sort_by_key(|contig| std::cmp::Reverse(contig.2)); + + let largest = contigs.first().map(|c| c.2).unwrap_or(0); + let workers = depth_worker_budget(args.threads, args.max_depth_workers, largest); + ui.config("Depth workers", &workers.to_string()); + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(workers) + .build() + .context("Failed to build rayon thread pool")?; + + let thresholds = config.mosdepth.thresholds.clone(); + let window_size = config.mosdepth.window_size; + let preseq_enabled = config.preseq.enabled; + let seg_len = config.preseq.max_segment_length; + let mapq_cut = args.mapq_cut; + + type ContigOutput = (ContigDepth, BamStatAccum, Option); + + let results: Vec> = pool.install(|| { + contigs + .par_iter() + .map(|(tid, name, len)| -> Result { + let mut reader = bam::IndexedReader::from_path(bam_path) + .with_context(|| format!("Failed to open alignment file: {bam_path}"))?; + if let Some(reference) = args.reference.as_deref() { + reader + .set_reference(reference) + .with_context(|| format!("Failed to set reference: {reference}"))?; + } + reader + .fetch(*tid) + .with_context(|| format!("Failed to fetch contig {name}"))?; + + let mut depth = DepthAccum::new(*len, mapq_cut, MOSDEPTH_DEFAULT_EXCLUDE); + let mut bam_stat = BamStatAccum::default(); + let mut preseq = preseq_enabled.then(|| PreseqAccum::new(seg_len)); + + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.context("Failed to read record")?; + depth.process_read(&record); + bam_stat.process_read(&record, mapq_cut); + if let Some(accum) = preseq.as_mut() { + accum.process_read(&record); + } + } + + let depths = depth.into_depths(); + let contig = ContigDepth::from_depths(name, &depths, window_size, &thresholds); + Ok((contig, bam_stat, preseq)) + }) + .collect() + }); + + let mut per_contig = Vec::new(); + let mut bam_stat_total = BamStatAccum::default(); + let mut preseq_total: Option = None; + for result in results { + let (contig, bam_stat, preseq) = result?; + per_contig.push(contig); + bam_stat_total.merge(bam_stat); + match (preseq_total.as_mut(), preseq) { + (Some(total), Some(part)) => total.merge(part), + (None, part) => preseq_total = part, + _ => {} + } + } + + // Unmapped records carry no contig, so they need their own pass; flagstat + // and idxstats both report them. + { + let mut reader = bam::IndexedReader::from_path(bam_path) + .with_context(|| format!("Failed to open alignment file: {bam_path}"))?; + if let Some(reference) = args.reference.as_deref() { + reader.set_reference(reference).ok(); + } + if reader.fetch(bam::FetchDefinition::Unmapped).is_ok() { + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.context("Failed to read unmapped record")?; + bam_stat_total.process_read(&record, mapq_cut); + } + } + } + + // Workers ran longest-contig-first; outputs go out in header order. + let order: Vec = (0..header.target_count()) + .map(|tid| String::from_utf8_lossy(header.tid2name(tid)).to_string()) + .collect(); + per_contig.sort_by_key(|contig| { + order + .iter() + .position(|name| name == &contig.name) + .unwrap_or(usize::MAX) + }); + + let result = MosdepthResult { + contigs: per_contig, + window_size, + thresholds: thresholds.clone(), + }; + + let bam_stat_result = bam_stat_total.into_result(); + ensure!( + args.skip_dup_check || bam_stat_result.duplicates > 0, + "No duplicate-flagged reads found in {bam_path}. RustQC expects \ + duplicate-marked (not removed) input. Pass --skip-dup-check to override." + ); + + let dir = |name: &str| -> PathBuf { + if flat_output { + outdir.to_path_buf() + } else { + outdir.join(name) + } + }; + let mut written: Vec = Vec::new(); + let mut record_output = |tool: &str, path: PathBuf| { + ui.output_item(tool, &path.display().to_string()); + written.push(summary::OutputFile { + tool: tool.to_string(), + path: path.display().to_string(), + }); + }; + + if config.mosdepth.enabled { + let mos_dir = dir("mosdepth"); + std::fs::create_dir_all(&mos_dir)?; + // Built with format! rather than with_extension: a sample name that + // contains a dot (test.dna, say) would otherwise lose its last segment. + let prefix = |suffix: &str| mos_dir.join(format!("{sample_name}.{suffix}")); + + let path = prefix("mosdepth.summary.txt"); + mos_out::write_summary(&result, &path)?; + record_output("mosdepth", path); + + let path = prefix("mosdepth.global.dist.txt"); + mos_out::write_global_dist(&result, &path)?; + record_output("mosdepth", path); + + if !config.mosdepth.skip_per_base { + let path = prefix("per-base.bed.gz"); + mos_out::write_per_base(&result, &path)?; + record_output("mosdepth", path); + } + + if window_size.is_some() { + let path = prefix("mosdepth.region.dist.txt"); + mos_out::write_region_dist(&result, &path)?; + record_output("mosdepth", path); + + let path = prefix("regions.bed.gz"); + mos_out::write_regions(&result, &path)?; + record_output("mosdepth", path); + + if !thresholds.is_empty() { + let path = prefix("thresholds.bed.gz"); + mos_out::write_thresholds(&result, &path)?; + record_output("mosdepth", path); + } + } + } + + if config.samtools.enabled { + let sam_dir = dir("samtools"); + std::fs::create_dir_all(&sam_dir)?; + + let path = sam_dir.join(format!("{sample_name}.stats.txt")); + common::samtools::stats::write_stats(&bam_stat_result, &path)?; + record_output("samtools stats", path); + + let path = sam_dir.join(format!("{sample_name}.flagstat.txt")); + common::samtools::flagstat::write_flagstat(&bam_stat_result, &path)?; + record_output("samtools flagstat", path); + + let refs: Vec<(String, u64)> = (0..header.target_count()) + .map(|tid| { + ( + String::from_utf8_lossy(header.tid2name(tid)).to_string(), + header.target_len(tid).unwrap_or(0), + ) + }) + .collect(); + let path = sam_dir.join(format!("{sample_name}.idxstats.txt")); + common::samtools::idxstats::write_idxstats(&bam_stat_result, &refs, &path)?; + record_output("samtools idxstats", path); + } + + if let Some(mut accum) = preseq_total { + let preseq_dir = dir("preseq"); + std::fs::create_dir_all(&preseq_dir)?; + accum.finalize(); + let total_reads = accum.total_fragments; + let n_distinct = accum.n_distinct(); + let histogram = accum.into_histogram(); + match common::preseq::estimate_complexity( + &histogram, + total_reads, + n_distinct, + &config.preseq, + ) { + Ok(preseq_result) => { + let path = preseq_dir.join(format!("{sample_name}.lc_extrap.txt")); + common::preseq::write_output( + &preseq_result, + &path, + config.preseq.confidence_level, + )?; + record_output("preseq", path); + } + Err(e) => ui.warn(&format!("preseq: {e:#}")), + } + } + + Ok(summary::InputSummary { + bam_file: bam_path.to_string(), + status: "success".to_string(), + error: None, + runtime_seconds: 0.0, + counting: None, + dupradar: None, + outputs: written, + }) +} + +/// How many contig depth arrays may be live at once. +/// +/// Each worker holds four bytes per base of its contig, so the largest contig +/// sets the per-worker cost: about 1 GB for GRCh38 chr1. There is no portable +/// way to ask the operating system how much memory is free, so the budget is a +/// fixed 4 GB unless the user overrides it with `--max-depth-workers`. +fn depth_worker_budget(threads: usize, override_value: Option, largest: u64) -> usize { + const BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024; + if let Some(value) = override_value { + return value.max(1); + } + let per_worker = largest.saturating_mul(4).max(1); + let affordable = (BUDGET_BYTES / per_worker).max(1) as usize; + threads.min(affordable).max(1) } /// Reconstruct the command line for the featureCounts-compatible header comment. diff --git a/tests/dna_integration_test.rs b/tests/dna_integration_test.rs index 17fa995c..b981f90d 100644 --- a/tests/dna_integration_test.rs +++ b/tests/dna_integration_test.rs @@ -183,3 +183,121 @@ fn depth_histogram_matches_the_per_base_fixture() { } assert_eq!(result.contigs[0].histogram, expected); } + +// =================================================================== +// End-to-end parity: the binary, not just the library +// =================================================================== + +/// Run `rustqc dna` once into a scratch directory shared by every end-to-end +/// test, with the same window size and thresholds the fixtures were made with. +fn run_binary() -> &'static Path { + static OUTDIR: std::sync::OnceLock = std::sync::OnceLock::new(); + OUTDIR.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let outdir = std::env::temp_dir().join("rustqc-dna-e2e"); + let _ = std::fs::remove_dir_all(&outdir); + std::fs::create_dir_all(&outdir).unwrap(); + + let status = std::process::Command::new(env!("CARGO_BIN_EXE_rustqc")) + .arg("dna") + .arg(root.join("tests/data/dna/test.dna.bam")) + .arg("--outdir") + .arg(&outdir) + .arg("--window-size") + .arg(WINDOW_SIZE.to_string()) + .arg("--quiet") + .status() + .expect("failed to run the rustqc binary"); + assert!(status.success(), "rustqc dna exited with {status}"); + outdir + }) +} + +/// The sample name is the BAM file stem, dots included. +const SAMPLE: &str = "test.dna"; + +fn produced(subdir: &str, name: &str) -> PathBuf { + run_binary().join(subdir).join(name) +} + +#[test] +fn binary_writes_every_mosdepth_output_byte_for_byte() { + for suffix in [ + "mosdepth.summary.txt", + "mosdepth.global.dist.txt", + "mosdepth.region.dist.txt", + ] { + let got = std::fs::read_to_string(produced("mosdepth", &format!("{SAMPLE}.{suffix}"))) + .unwrap_or_else(|e| panic!("reading {suffix}: {e}")); + let want = std::fs::read_to_string(fixture(&format!("test.{suffix}"))).unwrap(); + assert_same_lines(&got, &want, suffix); + } + for suffix in ["per-base.bed.gz", "regions.bed.gz", "thresholds.bed.gz"] { + let got = read_bgzf(&produced("mosdepth", &format!("{SAMPLE}.{suffix}"))); + let want = read_bgzf(&fixture(&format!("test.{suffix}"))); + assert_same_lines(&got, &want, suffix); + } +} + +#[test] +fn binary_writes_flagstat_and_idxstats_byte_for_byte() { + for suffix in ["flagstat", "idxstats"] { + let got = std::fs::read_to_string(produced("samtools", &format!("{SAMPLE}.{suffix}.txt"))) + .unwrap(); + let want = std::fs::read_to_string(fixture(&format!("test.{suffix}.txt"))).unwrap(); + assert_eq!(got, want, "{suffix} must match samtools exactly"); + } +} + +/// `samtools stats` output is compared on its data lines only. RustQC writes +/// its own `#` header, naming itself rather than reproducing samtools' command +/// line and version banner, which is deliberate and shared with the `rna` +/// pipeline. Everything below the header must match exactly. +#[test] +fn binary_writes_samtools_stats_data_lines_byte_for_byte() { + let got = + std::fs::read_to_string(produced("samtools", &format!("{SAMPLE}.stats.txt"))).unwrap(); + let want = std::fs::read_to_string(fixture("test.stats.txt")).unwrap(); + let strip = |s: &str| { + s.lines() + .filter(|l| !l.starts_with('#')) + .collect::>() + .join("\n") + }; + assert_same_lines(&strip(&got), &strip(&want), "samtools stats data lines"); +} + +#[test] +fn the_stats_header_does_not_claim_the_wrong_subcommand() { + let got = + std::fs::read_to_string(produced("samtools", &format!("{SAMPLE}.stats.txt"))).unwrap(); + assert!( + !got.contains("rustqc rna"), + "the dna pipeline must not label its output as rna output" + ); +} + +#[test] +fn binary_refuses_input_without_duplicate_marks() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let outdir = std::env::temp_dir().join("rustqc-dna-nodup"); + let _ = std::fs::remove_dir_all(&outdir); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_rustqc")) + .arg("dna") + .arg(root.join("tests/data/test_nodup.bam")) + .arg("--outdir") + .arg(&outdir) + .arg("--json-summary") + .arg("-") + .output() + .expect("failed to run the rustqc binary"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("duplicate-flagged") || combined.contains("failed"), + "expected a duplicate-marking complaint, got: {combined}" + ); +} From 419835949a58936e12e35d049665c09a35763623 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 19:16:32 +0200 Subject: [PATCH 13/22] feat(dna): add the DNA JSON summary block and citations InputSummary gains an optional dna block carrying genome length, covered bases, mean, median and maximum coverage, the percentage of the reference at or above each requested threshold, and the duplicate rate. An input carries either the RNA fields or this one, never both. Coverage thresholds are a list of objects rather than a map so the requested order survives serialisation; a map keyed by the threshold would sort "10" before "5". CITATIONS.md for a dna run cites mosdepth, samtools and preseq, and none of the RNA-only tools. The header is now shared between both writers. Co-Authored-By: Claude Opus 5 (1M context) --- src/citations.rs | 77 +++++++++++++++++++++++++++-------- src/main.rs | 72 ++++++++++++++++++++++++++++++++ src/summary.rs | 38 +++++++++++++++++ tests/dna_integration_test.rs | 58 ++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 16 deletions(-) diff --git a/src/citations.rs b/src/citations.rs index 316ed4d1..0c282d81 100644 --- a/src/citations.rs +++ b/src/citations.rs @@ -3,7 +3,7 @@ //! Writes a Markdown file alongside results documenting which upstream tools //! RustQC replicated in this run, their validated versions, and citation info. -use crate::config::RnaConfig; +use crate::config::{DnaConfig, RnaConfig}; use anyhow::{Context, Result}; use std::io::Write; use std::path::Path; @@ -48,6 +48,14 @@ const PRESEQ: Citation = Citation { doi: "10.1038/nmeth.2375", }; +const MOSDEPTH: Citation = Citation { + heading: "mosdepth (v0.3.14)", + description: "RustQC reimplements the depth of coverage analysis of mosdepth.", + reference: "Pedersen BS, Quinlan AR. Mosdepth: quick coverage calculation for genomes and exomes. *Bioinformatics*. 2018;34(5):867-868.", + url: "https://github.com/brentp/mosdepth", + doi: "10.1093/bioinformatics/btx699", +}; + const SAMTOOLS: Citation = Citation { heading: "Samtools (v1.22.1)", description: "RustQC produces Samtools-compatible flagstat, idxstats, and stats output.", @@ -79,21 +87,7 @@ pub fn write_citations(path: &Path, config: &RnaConfig, version: &str, commit: & .with_context(|| format!("Failed to create citations file: {}", path.display()))?; let mut w = std::io::BufWriter::new(file); - writeln!(w, "# RustQC Citations\n")?; - writeln!( - w, - "This file was generated by [RustQC](https://github.com/seqeralabs/RustQC) v{version} ({commit})." - )?; - writeln!( - w, - "It documents the upstream tools whose behaviour this run replicated." - )?; - writeln!( - w, - "Please cite both RustQC and the relevant upstream tools listed below.\n" - )?; - writeln!(w, "## RustQC (v{version})\n")?; - writeln!(w, "- Repository: \n")?; + write_header(&mut w, version, commit)?; if config.any_dupradar_output() { write_citation(&mut w, &DUPRADAR)?; @@ -118,6 +112,57 @@ pub fn write_citations(path: &Path, config: &RnaConfig, version: &str, commit: & Ok(()) } +/// Write `CITATIONS.md` for a `dna` run. +/// +/// Shares the header and the per-tool blocks with [`write_citations`]; only +/// the set of tools differs, because the two pipelines replicate different +/// upstream programs. +pub fn write_dna_citations( + path: &Path, + config: &DnaConfig, + version: &str, + commit: &str, +) -> Result<()> { + let file = std::fs::File::create(path) + .with_context(|| format!("Failed to create citations file: {}", path.display()))?; + let mut w = std::io::BufWriter::new(file); + + write_header(&mut w, version, commit)?; + + if config.mosdepth.enabled { + write_citation(&mut w, &MOSDEPTH)?; + } + if config.samtools.enabled { + write_citation(&mut w, &SAMTOOLS)?; + } + if config.preseq.enabled { + write_citation(&mut w, &PRESEQ)?; + } + + w.flush()?; + Ok(()) +} + +/// Shared preamble of both citation files. +fn write_header(w: &mut W, version: &str, commit: &str) -> Result<()> { + writeln!(w, "# RustQC Citations\n")?; + writeln!( + w, + "This file was generated by [RustQC](https://github.com/seqeralabs/RustQC) v{version} ({commit})." + )?; + writeln!( + w, + "It documents the upstream tools whose behaviour this run replicated." + )?; + writeln!( + w, + "Please cite both RustQC and the relevant upstream tools listed below.\n" + )?; + writeln!(w, "## RustQC (v{version})\n")?; + writeln!(w, "- Repository: \n")?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 16360d03..9ba81a33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -190,6 +190,7 @@ fn run_dna(args: cli::DnaArgs, ui: &Ui) -> Result<()> { runtime_seconds: bam_start.elapsed().as_secs_f64(), counting: None, dupradar: None, + dna: None, outputs: Vec::new(), }); } @@ -224,6 +225,15 @@ fn run_dna(args: cli::DnaArgs, ui: &Ui) -> Result<()> { } } + let citations_path = outdir.join("CITATIONS.md"); + citations::write_dna_citations( + &citations_path, + &config, + env!("CARGO_PKG_VERSION"), + env!("GIT_SHORT_HASH"), + )?; + ui.output_item("citations", &citations_path.display().to_string()); + ui.finish("DNA QC", run_start.elapsed()); Ok(()) } @@ -497,10 +507,70 @@ fn process_single_dna_bam( runtime_seconds: 0.0, counting: None, dupradar: None, + dna: Some(dna_summary(&result, &bam_stat_result, &thresholds)), outputs: written, }) } +/// Build the JSON summary block for a `dna` run. +fn dna_summary( + result: &rustqc::dna::mosdepth::MosdepthResult, + bam_stat: &rustqc::common::bam_stat::BamStatResult, + thresholds: &[u32], +) -> summary::DnaSummary { + let genome_length = result.total_length(); + let histogram = + rustqc::dna::mosdepth::merge_histograms(result.contigs.iter().map(|c| &c.histogram)); + + let coverage_thresholds = thresholds + .iter() + .map(|threshold| { + let at_or_above: u64 = histogram + .iter() + .filter(|(depth, _)| *depth >= threshold) + .map(|(_, count)| count) + .sum(); + summary::CoverageThreshold { + threshold: *threshold, + pct_bases: if genome_length == 0 { + 0.0 + } else { + at_or_above as f64 * 100.0 / genome_length as f64 + }, + } + }) + .collect(); + + // Median: walk the depth histogram until half the reference is behind us. + let mut seen = 0u64; + let mut median = 0u32; + for (depth, count) in &histogram { + seen += count; + if seen * 2 >= genome_length { + median = *depth; + break; + } + } + + let duplicate_pct = if bam_stat.total_records == 0 { + 0.0 + } else { + bam_stat.duplicates as f64 * 100.0 / bam_stat.total_records as f64 + }; + + summary::DnaSummary { + genome_length, + covered_bases: result.total_bases(), + mean_coverage: result.mean(), + median_coverage: median, + max_coverage: result.max(), + coverage_thresholds, + total_reads: bam_stat.total_records, + duplicates: bam_stat.duplicates, + duplicate_pct, + } +} + /// How many contig depth arrays may be live at once. /// /// Each worker holds four bytes per base of its contig, so the largest contig @@ -1038,6 +1108,7 @@ fn run_rna(args: cli::RnaArgs, ui: &Ui) -> Result<()> { runtime_seconds: 0.0, counting: None, dupradar: None, + dna: None, outputs: vec![], }); } @@ -1322,6 +1393,7 @@ impl BamResult { runtime_seconds: self.duration.as_secs_f64(), counting, dupradar, + dna: None, outputs: self .outputs .iter() diff --git a/src/summary.rs b/src/summary.rs index 2f1320a6..05f18254 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -44,6 +44,11 @@ pub struct InputSummary { /// dupRadar summary (if successful and enabled). #[serde(skip_serializing_if = "Option::is_none")] pub dupradar: Option, + /// DNA depth-of-coverage summary (if this was a `dna` run). + /// + /// An input carries either the RNA fields above or this one, never both. + #[serde(skip_serializing_if = "Option::is_none")] + pub dna: Option, /// List of output files written. pub outputs: Vec, } @@ -90,6 +95,39 @@ pub struct DupradarSummary { pub slope: Option, } +/// Depth of coverage summary for a single alignment file. +#[derive(Debug, Serialize)] +pub struct DnaSummary { + /// Total reference bases across all contigs. + pub genome_length: u64, + /// Sum of per-base depth, that is total bases covered. + pub covered_bases: u64, + /// Mean depth across the reference. + pub mean_coverage: f64, + /// Median per-base depth. + pub median_coverage: u32, + /// Highest per-base depth seen. + pub max_coverage: u32, + /// Percentage of reference bases at or above each requested threshold, + /// in the order the thresholds were requested. + pub coverage_thresholds: Vec, + /// Total records seen. + pub total_reads: u64, + /// Duplicate-flagged records. + pub duplicates: u64, + /// Duplicate rate as a percentage of total records. + pub duplicate_pct: f64, +} + +/// Percentage of the reference covered at or above one depth threshold. +#[derive(Debug, Serialize)] +pub struct CoverageThreshold { + /// The threshold itself, in reads (for example 10 for 10X). + pub threshold: u32, + /// Percentage of reference bases at or above it. + pub pct_bases: f64, +} + /// A single output file written during processing. #[derive(Debug, Serialize)] pub struct OutputFile { diff --git a/tests/dna_integration_test.rs b/tests/dna_integration_test.rs index b981f90d..274bd520 100644 --- a/tests/dna_integration_test.rs +++ b/tests/dna_integration_test.rs @@ -301,3 +301,61 @@ fn binary_refuses_input_without_duplicate_marks() { "expected a duplicate-marking complaint, got: {combined}" ); } + +/// The JSON summary is the machine-readable face of a run, so its DNA block is +/// pinned against the same figures the mosdepth fixtures carry. +#[test] +fn json_summary_carries_the_dna_block() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let outdir = std::env::temp_dir().join("rustqc-dna-json"); + let _ = std::fs::remove_dir_all(&outdir); + std::fs::create_dir_all(&outdir).unwrap(); + let json_path = outdir.join("summary.json"); + + let status = std::process::Command::new(env!("CARGO_BIN_EXE_rustqc")) + .arg("dna") + .arg(root.join("tests/data/dna/test.dna.bam")) + .arg("--outdir") + .arg(&outdir) + .arg("--window-size") + .arg(WINDOW_SIZE.to_string()) + .arg("--json-summary") + .arg(&json_path) + .arg("--quiet") + .status() + .expect("failed to run the rustqc binary"); + assert!(status.success()); + + let text = std::fs::read_to_string(&json_path).unwrap(); + // Checked as text rather than parsed: the point is that these exact + // figures reach the summary, and pulling in a JSON parser for one test + // would not make the assertion any stronger. + for needle in [ + "\"genome_length\": 40001", + "\"covered_bases\": 247878", + "\"max_coverage\": 867", + "\"total_reads\": 5644", + "\"duplicates\": 1656", + ] { + assert!( + text.contains(needle), + "summary is missing {needle}:\n{text}" + ); + } + assert!( + !text.contains("\"dupradar\""), + "a dna run must not emit the rna summary blocks" + ); +} + +/// The citations file names the tools this pipeline actually replicated. +#[test] +fn citations_name_the_dna_tools_only() { + let citations = std::fs::read_to_string(run_binary().join("CITATIONS.md")).unwrap(); + assert!(citations.contains("mosdepth"), "mosdepth must be cited"); + assert!(citations.contains("Samtools"), "samtools must be cited"); + assert!( + !citations.contains("dupRadar") && !citations.contains("RSeQC"), + "a dna run must not cite the rna-only tools" + ); +} From 10e711ad97daf77ba8302400a0774a566e25af3d Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 19:16:48 +0200 Subject: [PATCH 14/22] docs: describe the dna subcommand in AGENTS.md and the changelog Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 16 +++++++++++++++- CHANGELOG.md | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 07dfe143..d1c56db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,13 @@ src/ stats.rs — samtools stats full output (SN + all histogram sections) flagstat.rs — samtools flagstat-compatible output idxstats.rs — samtools idxstats-compatible output + dna/ + mod.rs — Re-exports the DNA submodules + depth.rs — Per-contig depth accumulator (delta array, CIGAR walk, + mate-overlap correction, prefix sum) + mosdepth/ + mod.rs — Per-contig summarisation feeding the mosdepth outputs + output.rs — The six mosdepth-compatible writers (bgzf for the BED outputs) rna/ mod.rs — Re-exports the RNA submodules (dupradar, featurecounts, rseqc, qualimap) and re-exports the shared ones from `common` for compatibility @@ -120,9 +127,16 @@ Inter-module access uses `crate::` paths (e.g., `use crate::common::bam_stat_acc Assay-agnostic analyses belong in `common`; put new code under `rna` only if it needs a gene annotation or a library strand protocol. -The CLI uses a single subcommand: +The CLI has two subcommands: - `rustqc rna ... --gtf [OPTIONS]` +- `rustqc dna ... [OPTIONS]` + +The `dna` subcommand needs no annotation. It runs depth of coverage +(mosdepth-compatible), the samtools-compatible outputs and preseq in one pass, +with one worker per contig. Shared flags keep their `rna` names, short forms +and `RUSTQC_*` environment variables, with one deliberate exception: +`-Q/--mapq` defaults to 0 for `dna`, matching mosdepth, rather than 30. A GTF gene annotation file (`--gtf`) is required. This runs all analyses: dupRadar duplicate rate analysis, featureCounts-compatible gene counting, diff --git a/CHANGELOG.md b/CHANGELOG.md index 469316e2..5352f57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- New `rustqc dna` subcommand for DNA (WGS) quality control: depth of coverage + with mosdepth-compatible outputs, samtools-compatible stats, flagstat and + idxstats, and preseq library complexity, all in a single pass over the + alignment with one worker per contig. Validated for exact parity against + mosdepth 0.3.14 and samtools 1.24. + ### Changed - Internal: assay-agnostic analyses (BAM flag helpers, read-level statistics, From 38abe0575cd817c26e46d1524289d99126fc4f59 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 20:03:09 +0200 Subject: [PATCH 15/22] feat(dna): write the .csi companion indexes, cite the samtools version used Closes two gaps left open in this PR. The bgzf BED outputs now get a .csi index built through htslib's tbx_index_build, as mosdepth writes and as tabix needs to seek into them. CSI rather than TBI because CSI carries no 512 Mb coordinate ceiling. Indexes are not compared byte for byte: an index is binary metadata over the compressed blocks, and two writers answering the same queries need not produce the same bytes. The test asserts instead that a region query returns the same rows through our index as through mosdepth's, going through the tabix binary because rust-htslib's tabix reader ends a fetched region with a TabixTruncatedRecord rather than stopping, and does so at different points for the two files. It skips where tabix is absent. CITATIONS.md for a dna run now cites samtools v1.24, the version its fixtures were generated with, instead of the v1.22.1 the rna pipeline was validated against. Each pipeline cites the version it was actually compared with rather than both claiming the newer one. Co-Authored-By: Claude Opus 5 (1M context) --- src/citations.rs | 15 ++++++++- src/dna/mosdepth/output.rs | 62 +++++++++++++++++++++++++++++++---- tests/dna_integration_test.rs | 54 +++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/citations.rs b/src/citations.rs index 0c282d81..6c968f2b 100644 --- a/src/citations.rs +++ b/src/citations.rs @@ -64,6 +64,19 @@ const SAMTOOLS: Citation = Citation { doi: "10.1093/gigascience/giab008", }; +/// Same tool as [`SAMTOOLS`], different validated version. +/// +/// The `rna` pipeline's outputs were checked against samtools 1.22.1 and the +/// `dna` pipeline's against 1.24, so each cites the version it was actually +/// compared with rather than both claiming the newer one. +const SAMTOOLS_DNA: Citation = Citation { + heading: "Samtools (v1.24)", + description: "RustQC produces Samtools-compatible flagstat, idxstats, and stats output.", + reference: "Danecek P, Bonfield JK, Liddle J, et al. Twelve years of Samtools and BCFtools. *GigaScience*. 2021;10(2):giab008.", + url: "http://www.htslib.org/", + doi: "10.1093/gigascience/giab008", +}; + const QUALIMAP: Citation = Citation { heading: "Qualimap (v2.3)", description: "RustQC produces gene body coverage output compatible with Qualimap rnaseq.", @@ -133,7 +146,7 @@ pub fn write_dna_citations( write_citation(&mut w, &MOSDEPTH)?; } if config.samtools.enabled { - write_citation(&mut w, &SAMTOOLS)?; + write_citation(&mut w, &SAMTOOLS_DNA)?; } if config.preseq.enabled { write_citation(&mut w, &PRESEQ)?; diff --git a/src/dna/mosdepth/output.rs b/src/dna/mosdepth/output.rs index 471b86fc..78e4ccb0 100644 --- a/src/dna/mosdepth/output.rs +++ b/src/dna/mosdepth/output.rs @@ -10,7 +10,7 @@ use std::io::Write; use std::path::Path; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use rust_htslib::bgzf; use super::{dist_proportions, dist_rows, merge_histograms, MosdepthResult}; @@ -174,13 +174,49 @@ pub fn write_thresholds(result: &MosdepthResult, path: &Path) -> Result<()> { write_bgzf(path, &body) } -/// Write `contents` to `path` as bgzf. +/// Write `contents` to `path` as bgzf, then build its `.csi` index. fn write_bgzf(path: &Path, contents: &str) -> Result<()> { - let mut writer = bgzf::Writer::from_path(path) - .with_context(|| format!("Failed to create bgzf file: {}", path.display()))?; - writer - .write_all(contents.as_bytes()) - .with_context(|| format!("Failed to write bgzf file: {}", path.display()))?; + { + let mut writer = bgzf::Writer::from_path(path) + .with_context(|| format!("Failed to create bgzf file: {}", path.display()))?; + writer + .write_all(contents.as_bytes()) + .with_context(|| format!("Failed to write bgzf file: {}", path.display()))?; + // The writer must be dropped, and the bgzf stream closed, before the + // indexer reads the file back. + } + build_csi_index(path) +} + +/// Build the `.csi` companion index for a bgzf-compressed BED file. +/// +/// mosdepth writes one alongside each of its BED outputs, and `tabix` needs it +/// to seek into them. CSI rather than TBI because CSI carries no 512 Mb +/// coordinate ceiling, which matters on large contigs. +fn build_csi_index(path: &Path) -> Result<()> { + use std::ffi::CString; + + let path_c = CString::new(path.as_os_str().as_encoded_bytes()).with_context(|| { + format!( + "Path is not representable as a C string: {}", + path.display() + ) + })?; + + // SAFETY: `path_c` is a valid NUL-terminated string that outlives the + // call, `tbx_conf_bed` is a static provided by htslib, and the file was + // closed above. A min_shift of 14 selects CSI, matching what mosdepth and + // `tabix --csi` produce. + let ret = unsafe { + rust_htslib::htslib::tbx_index_build( + path_c.as_ptr(), + 14, + &raw const rust_htslib::htslib::tbx_conf_bed, + ) + }; + if ret < 0 { + bail!("Failed to build the CSI index for {}", path.display()); + } Ok(()) } @@ -286,4 +322,16 @@ mod tests { ); assert!(text.contains("total\t0\t1.00")); } + + #[test] + fn compressed_outputs_get_a_loadable_csi_index() { + let path = scratch("indexed.per-base.bed.gz"); + let index = scratch("indexed.per-base.bed.gz.csi"); + let _ = std::fs::remove_file(&index); + write_per_base(&result_with_windows(), &path).unwrap(); + assert!(index.exists(), "the .csi companion index must be written"); + // htslib refuses to open a malformed index, so opening it is the check. + let tbx = rust_htslib::tbx::Reader::from_path(&path); + assert!(tbx.is_ok(), "htslib could not open the indexed file"); + } } diff --git a/tests/dna_integration_test.rs b/tests/dna_integration_test.rs index 274bd520..07f267ca 100644 --- a/tests/dna_integration_test.rs +++ b/tests/dna_integration_test.rs @@ -39,7 +39,7 @@ fn scratch(name: &str) -> PathBuf { /// as the fixtures were generated. fn compute() -> MosdepthResult { let bam_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/dna/test.dna.bam"); - let mut reader = bam::Reader::from_path(&bam_path).unwrap(); + let reader = bam::Reader::from_path(&bam_path).unwrap(); let header = reader.header().to_owned(); let mut contigs = Vec::new(); @@ -359,3 +359,55 @@ fn citations_name_the_dna_tools_only() { "a dna run must not cite the rna-only tools" ); } + +/// The `.csi` companion indexes are not compared byte for byte: an index is +/// binary metadata over the compressed blocks, and two writers answering the +/// same queries need not produce the same bytes. What matters is that a region +/// query returns the same rows through our index as through mosdepth's. +/// +/// The query goes through the `tabix` binary rather than rust-htslib's tabix +/// reader, which ends a fetched region by yielding a `TabixTruncatedRecord` +/// instead of stopping, and does so at different points for the two files. The +/// test is skipped where `tabix` is not installed, the same way the fixtures +/// themselves depend on the upstream tools being present. +#[test] +fn csi_indexes_answer_region_queries_like_mosdepths() { + if std::process::Command::new("tabix") + .arg("--version") + .output() + .is_err() + { + eprintln!("skipping: tabix is not installed"); + return; + } + + let query = |path: &Path| -> String { + let out = std::process::Command::new("tabix") + .arg(path) + .arg("chr22:2000-2500") + .output() + .unwrap_or_else(|e| panic!("querying {}: {e}", path.display())); + assert!( + out.status.success(), + "tabix failed on {}: {}", + path.display(), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() + }; + + for suffix in ["per-base.bed.gz", "regions.bed.gz", "thresholds.bed.gz"] { + let ours = produced("mosdepth", &format!("{SAMPLE}.{suffix}")); + let index = ours.with_file_name(format!("{SAMPLE}.{suffix}.csi")); + assert!( + index.exists(), + "{suffix} must have a .csi companion at {}", + index.display() + ); + + let mine = query(&ours); + let theirs = query(&fixture(&format!("test.{suffix}"))); + assert!(!mine.is_empty(), "{suffix}: the query returned nothing"); + assert_eq!(mine, theirs, "{suffix}: region query results differ"); + } +} From d4a0cc2dc8226269fd232c2d889b81f253119d01 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 21:36:34 +0200 Subject: [PATCH 16/22] feat(dna): reimplement Picard CollectInsertSizeMetrics Matches Picard 3.4.0 byte for byte on the project fixture, metrics row and all 170 histogram lines. Every rule was measured against Picard's own output rather than recalled. The inclusion filter is paired, not secondary, supplementary, duplicate or unmapped, mate mapped, and a positive TLEN so each pair counts once. Proper pair is deliberately not required: requiring it drops one pair and shortens the maximum from 300 to 239 on this data. Mean and standard deviation are over the histogram trimmed to DEVIATIONS median absolute deviations either side of the median, with the n-1 denominator; minimum and maximum are over the untrimmed set. WIDTH_OF_XX grows a window symmetrically around the median until it covers the percentile, reporting 2i+1; all eleven widths match. The fixture does not exercise trimming, since nothing on it lies beyond ten MADs of the median, so that path has its own unit test. Fixtures are generated with the JVM locale pinned to English: a French default writes "3,531312" where an English one writes "3.531312", which would make them depend on the machine that produced them. Picard's four-line preamble is stripped, holding only a command line and a timestamp. Co-Authored-By: Claude Opus 5 (1M context) --- src/dna/insert_size.rs | 489 ++++++++++++++++++ src/dna/mod.rs | 1 + tests/create_dna_test_data.sh | 37 +- tests/data/dna/test.dna.bam | Bin 193636 -> 193635 bytes tests/data/dna/test.dna.bam.bai | Bin 96 -> 96 bytes tests/dna_integration_test.rs | 58 +++ tests/expected/dna/VERSIONS.txt | 1 + .../expected/dna/test.insert_size_metrics.txt | 178 +++++++ tests/expected/dna/test.wgs_metrics.txt | 258 +++++++++ 9 files changed, 1020 insertions(+), 2 deletions(-) create mode 100644 src/dna/insert_size.rs create mode 100644 tests/expected/dna/test.insert_size_metrics.txt create mode 100644 tests/expected/dna/test.wgs_metrics.txt diff --git a/src/dna/insert_size.rs b/src/dna/insert_size.rs new file mode 100644 index 00000000..5ebd179e --- /dev/null +++ b/src/dna/insert_size.rs @@ -0,0 +1,489 @@ +//! Picard `CollectInsertSizeMetrics` reimplementation. +//! +//! # Upstream semantics +//! +//! Every rule below was measured against Picard 3.4.0 output on +//! `tests/data/dna/test.dna.bam`, not recalled from documentation. +//! +//! A record contributes when it is paired, is neither secondary, +//! supplementary, duplicate-flagged nor unmapped, has a mapped mate, and +//! carries a positive `TLEN`. Taking only the positive `TLEN` of the two is +//! what counts each pair once. Proper-pair is deliberately **not** required: +//! requiring it drops one pair and shortens the maximum from 300 to 239 on the +//! project fixture. +//! +//! Pairs are grouped by orientation (`FR`, `RF`, `TANDEM`), each group +//! reported on its own row with its own histogram, exactly as Picard does. +//! +//! `MEAN_INSERT_SIZE` and `STANDARD_DEVIATION` are computed over the +//! histogram trimmed to `DEVIATIONS` median absolute deviations either side of +//! the median, and the standard deviation uses the `n - 1` denominator. +//! `MIN_INSERT_SIZE` and `MAX_INSERT_SIZE` are over the untrimmed set. +//! +//! `WIDTH_OF_XX_PERCENT` is the width of the smallest window centred on the +//! median that covers at least `XX` percent of pairs: grow `i` from zero until +//! the bins from `median - i` to `median + i` cover the target, then report +//! `2i + 1`. + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bam; + +use crate::common::bam_flags::*; + +/// Percentiles Picard reports a width for, in output order. +pub const WIDTH_PERCENTILES: [u32; 11] = [10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99]; + +/// Picard's `DEVIATIONS` default: how many median absolute deviations either +/// side of the median survive trimming before the mean and standard deviation +/// are computed. +pub const DEFAULT_DEVIATIONS: f64 = 10.0; + +/// Relative orientation of the two mates of a pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PairOrientation { + /// Forward-reverse, the usual Illumina paired-end arrangement. + Fr, + /// Reverse-forward, seen in mate-pair and some capture libraries. + Rf, + /// Both mates on the same strand. + Tandem, +} + +impl PairOrientation { + /// The label Picard writes in the `PAIR_ORIENTATION` column. + pub fn label(&self) -> &'static str { + match self { + PairOrientation::Fr => "FR", + PairOrientation::Rf => "RF", + PairOrientation::Tandem => "TANDEM", + } + } + + /// The prefix Picard uses for this orientation's histogram column. + fn histogram_column(&self) -> &'static str { + match self { + PairOrientation::Fr => "fr", + PairOrientation::Rf => "rf", + PairOrientation::Tandem => "tandem", + } + } +} + +/// Accumulates insert sizes, one histogram per orientation. +#[derive(Debug, Default)] +pub struct InsertSizeAccum { + histograms: BTreeMap>, +} + +impl InsertSizeAccum { + /// A new, empty accumulator. + pub fn new() -> Self { + Self::default() + } + + /// Offer one record. Records that do not represent a countable pair are + /// ignored. + pub fn process_read(&mut self, record: &bam::Record) { + let flags = record.flags(); + if flags & BAM_FPAIRED == 0 { + return; + } + let excluded = BAM_FUNMAP | BAM_FMUNMAP | BAM_FSECONDARY | BAM_FSUPPLEMENTARY | BAM_FDUP; + if flags & excluded != 0 { + return; + } + // Only the mate carrying the positive TLEN counts, so each pair is + // counted once. + let insert_size = record.insert_size(); + if insert_size <= 0 { + return; + } + + let orientation = orientation_of(flags); + *self + .histograms + .entry(orientation) + .or_default() + .entry(insert_size as u64) + .or_insert(0) += 1; + } + + /// Fold another accumulator into this one. + pub fn merge(&mut self, other: InsertSizeAccum) { + for (orientation, histogram) in other.histograms { + let target = self.histograms.entry(orientation).or_default(); + for (size, count) in histogram { + *target.entry(size).or_insert(0) += count; + } + } + } + + /// Summarise each orientation, in Picard's output order: most pairs first. + pub fn into_result(self, deviations: f64) -> InsertSizeResult { + let mut rows: Vec = self + .histograms + .into_iter() + .map(|(orientation, histogram)| InsertSizeRow::new(orientation, histogram, deviations)) + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.read_pairs)); + InsertSizeResult { rows } + } +} + +/// Which orientation a record's flags describe. +fn orientation_of(flags: u16) -> PairOrientation { + let read_reverse = flags & BAM_FREVERSE != 0; + let mate_reverse = flags & BAM_FMREVERSE != 0; + if read_reverse == mate_reverse { + PairOrientation::Tandem + } else if read_reverse { + // This record carries the positive TLEN, so it is the leftmost mate. + // Leftmost on the reverse strand means reverse-forward. + PairOrientation::Rf + } else { + PairOrientation::Fr + } +} + +/// One orientation's metrics and histogram. +#[derive(Debug, Clone)] +pub struct InsertSizeRow { + /// The orientation this row describes. + pub orientation: PairOrientation, + /// Insert size histogram, size to pair count. + pub histogram: BTreeMap, + /// Number of pairs counted. + pub read_pairs: u64, + /// Median insert size. + pub median: u64, + /// Most frequent insert size; ties go to the smaller size. + pub mode: u64, + /// Median absolute deviation from the median. + pub median_absolute_deviation: u64, + /// Smallest insert size seen, before trimming. + pub min: u64, + /// Largest insert size seen, before trimming. + pub max: u64, + /// Mean over the trimmed histogram. + pub mean: f64, + /// Standard deviation over the trimmed histogram, `n - 1` denominator. + pub standard_deviation: f64, + /// Width of the smallest median-centred window covering each percentile, + /// in the order of [`WIDTH_PERCENTILES`]. + pub widths: Vec, +} + +impl InsertSizeRow { + fn new(orientation: PairOrientation, histogram: BTreeMap, deviations: f64) -> Self { + let read_pairs: u64 = histogram.values().sum(); + let median = quantile(&histogram, read_pairs / 2); + let mode = histogram + .iter() + .max_by_key(|(size, count)| (**count, std::cmp::Reverse(**size))) + .map(|(size, _)| *size) + .unwrap_or(0); + + // Median absolute deviation, itself a median over |size - median|. + let mut deviation_histogram: BTreeMap = BTreeMap::new(); + for (size, count) in &histogram { + let deviation = size.abs_diff(median); + *deviation_histogram.entry(deviation).or_insert(0) += count; + } + let median_absolute_deviation = quantile(&deviation_histogram, read_pairs / 2); + + let min = histogram.keys().copied().min().unwrap_or(0); + let max = histogram.keys().copied().max().unwrap_or(0); + + // Trim to `deviations` MADs either side before the mean and SD. + let span = deviations * median_absolute_deviation as f64; + let low = (median as f64 - span).max(0.0); + let high = median as f64 + span; + let trimmed: Vec<(u64, u64)> = histogram + .iter() + .filter(|(size, _)| **size as f64 >= low && **size as f64 <= high) + .map(|(size, count)| (*size, *count)) + .collect(); + + let n: u64 = trimmed.iter().map(|(_, count)| count).sum(); + let mean = if n == 0 { + 0.0 + } else { + trimmed + .iter() + .map(|(size, count)| *size as f64 * *count as f64) + .sum::() + / n as f64 + }; + let standard_deviation = if n < 2 { + 0.0 + } else { + let variance = trimmed + .iter() + .map(|(size, count)| { + let diff = *size as f64 - mean; + diff * diff * *count as f64 + }) + .sum::() + / (n - 1) as f64; + variance.sqrt() + }; + + let widths = WIDTH_PERCENTILES + .iter() + .map(|pct| width_of_percent(&histogram, median, read_pairs, *pct)) + .collect(); + + Self { + orientation, + histogram, + read_pairs, + median, + mode, + median_absolute_deviation, + min, + max, + mean, + standard_deviation, + widths, + } + } +} + +/// The value at `rank` when the histogram is expanded into a sorted list. +fn quantile(histogram: &BTreeMap, rank: u64) -> u64 { + let mut seen = 0u64; + for (value, count) in histogram { + seen += count; + if seen > rank { + return *value; + } + } + histogram.keys().next_back().copied().unwrap_or(0) +} + +/// Width of the smallest window centred on `median` covering `pct` percent of +/// `total` pairs. +fn width_of_percent(histogram: &BTreeMap, median: u64, total: u64, pct: u32) -> u64 { + if total == 0 { + return 0; + } + let target = total as f64 * pct as f64 / 100.0; + let mut covered = *histogram.get(&median).unwrap_or(&0) as f64; + let mut i = 0u64; + while covered < target { + i += 1; + covered += *histogram.get(&(median.saturating_sub(i))).unwrap_or(&0) as f64; + covered += *histogram.get(&(median + i)).unwrap_or(&0) as f64; + // Once the window spans the whole histogram there is nothing left to add. + if median + i > *histogram.keys().next_back().unwrap_or(&0) && median < i { + break; + } + } + 2 * i + 1 +} + +/// All orientations' metrics for one alignment file. +#[derive(Debug, Clone)] +pub struct InsertSizeResult { + /// One row per orientation seen, most pairs first. + pub rows: Vec, +} + +/// Format a float the way Picard's metrics writer does: up to six decimals, +/// trailing zeros removed, and a bare integer when there is no fraction. +fn fmt_picard(value: f64) -> String { + if value == value.trunc() && value.abs() < 1e15 { + return format!("{}", value as i64); + } + let text = format!("{value:.6}"); + let trimmed = text.trim_end_matches('0').trim_end_matches('.'); + trimmed.to_string() +} + +/// Write a Picard-compatible `insert_size_metrics.txt`. +/// +/// The `## htsjdk...StringHeader` preamble Picard writes is omitted: it holds +/// only the command line and a start timestamp, both of which are noise in a +/// reproducible pipeline. +pub fn write_insert_size_metrics(result: &InsertSizeResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create insert size metrics: {}", path.display()))?; + + writeln!(out, "## METRICS CLASS\tpicard.analysis.InsertSizeMetrics")?; + write!( + out, + "MEDIAN_INSERT_SIZE\tMODE_INSERT_SIZE\tMEDIAN_ABSOLUTE_DEVIATION\tMIN_INSERT_SIZE\t\ + MAX_INSERT_SIZE\tMEAN_INSERT_SIZE\tSTANDARD_DEVIATION\tREAD_PAIRS\tPAIR_ORIENTATION" + )?; + for pct in WIDTH_PERCENTILES { + write!(out, "\tWIDTH_OF_{pct}_PERCENT")?; + } + writeln!(out, "\tSAMPLE\tLIBRARY\tREAD_GROUP")?; + + for row in &result.rows { + write!( + out, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + row.median, + row.mode, + row.median_absolute_deviation, + row.min, + row.max, + fmt_picard(row.mean), + fmt_picard(row.standard_deviation), + row.read_pairs, + row.orientation.label(), + )?; + for width in &row.widths { + write!(out, "\t{width}")?; + } + // Trailing SAMPLE, LIBRARY and READ_GROUP columns are empty at the + // ALL_READS accumulation level, which is Picard's default. + writeln!(out, "\t\t\t")?; + } + + writeln!(out)?; + writeln!(out, "## HISTOGRAM\tjava.lang.Integer")?; + write!(out, "insert_size")?; + for row in &result.rows { + write!( + out, + "\tAll_Reads.{}_count", + row.orientation.histogram_column() + )?; + } + writeln!(out)?; + + // One row per insert size seen in any orientation, ascending. + let mut sizes: Vec = result + .rows + .iter() + .flat_map(|row| row.histogram.keys().copied()) + .collect(); + sizes.sort_unstable(); + sizes.dedup(); + for size in sizes { + write!(out, "{size}")?; + for row in &result.rows { + write!(out, "\t{}", row.histogram.get(&size).copied().unwrap_or(0))?; + } + writeln!(out)?; + } + writeln!(out)?; + + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hist(pairs: &[(u64, u64)]) -> BTreeMap { + pairs.iter().copied().collect() + } + + #[test] + fn orientation_follows_the_strand_flags() { + // The record carrying the positive TLEN is the leftmost mate, so its + // own strand decides between FR and RF. + assert_eq!( + orientation_of(BAM_FPAIRED | BAM_FMREVERSE), + PairOrientation::Fr, + "leftmost forward, mate reverse" + ); + assert_eq!( + orientation_of(BAM_FPAIRED | BAM_FREVERSE), + PairOrientation::Rf, + "leftmost reverse, mate forward" + ); + // Same strand either way round is tandem, including neither reversed. + assert_eq!( + orientation_of(BAM_FPAIRED), + PairOrientation::Tandem, + "both forward" + ); + assert_eq!( + orientation_of(BAM_FPAIRED | BAM_FREVERSE | BAM_FMREVERSE), + PairOrientation::Tandem, + "both reverse" + ); + } + + #[test] + fn mode_breaks_ties_towards_the_smaller_size() { + let row = InsertSizeRow::new( + PairOrientation::Fr, + hist(&[(100, 5), (200, 5)]), + DEFAULT_DEVIATIONS, + ); + assert_eq!(row.mode, 100); + } + + #[test] + fn standard_deviation_uses_the_sample_denominator() { + // Values 1, 2, 3: mean 2, sample variance 1, so SD is exactly 1. + let row = InsertSizeRow::new( + PairOrientation::Fr, + hist(&[(1, 1), (2, 1), (3, 1)]), + DEFAULT_DEVIATIONS, + ); + assert!((row.mean - 2.0).abs() < 1e-12); + assert!( + (row.standard_deviation - 1.0).abs() < 1e-12, + "got {}", + row.standard_deviation + ); + } + + #[test] + fn trimming_excludes_outliers_beyond_the_deviation_span() { + // Median 10, MAD 0, so a span of zero keeps only the median bin. + let row = InsertSizeRow::new( + PairOrientation::Fr, + hist(&[(10, 9), (1000, 1)]), + DEFAULT_DEVIATIONS, + ); + assert_eq!(row.max, 1000, "the untrimmed maximum is still reported"); + assert!( + (row.mean - 10.0).abs() < 1e-12, + "the outlier must not reach the mean, got {}", + row.mean + ); + } + + #[test] + fn width_grows_symmetrically_around_the_median() { + // Ten pairs at the median, five either side one apart. + let h = hist(&[(9, 5), (10, 10), (11, 5)]); + assert_eq!(width_of_percent(&h, 10, 20, 50), 1, "the median bin alone"); + assert_eq!(width_of_percent(&h, 10, 20, 90), 3, "one bin either side"); + } + + #[test] + fn picard_float_formatting_drops_trailing_zeros() { + assert_eq!(fmt_picard(124.442269), "124.442269"); + assert_eq!(fmt_picard(3.5), "3.5"); + assert_eq!(fmt_picard(40001.0), "40001"); + assert_eq!(fmt_picard(0.0), "0"); + } + + #[test] + fn rows_are_ordered_by_pair_count() { + let mut accum = InsertSizeAccum::new(); + accum + .histograms + .insert(PairOrientation::Rf, hist(&[(100, 1)])); + accum + .histograms + .insert(PairOrientation::Fr, hist(&[(100, 50)])); + let result = accum.into_result(DEFAULT_DEVIATIONS); + assert_eq!(result.rows[0].orientation, PairOrientation::Fr); + assert_eq!(result.rows[1].orientation, PairOrientation::Rf); + } +} diff --git a/src/dna/mod.rs b/src/dna/mod.rs index eafde9f9..3104e097 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -5,4 +5,5 @@ //! and preseq are shared with the RNA pipeline and live in [`crate::common`]. pub mod depth; +pub mod insert_size; pub mod mosdepth; diff --git a/tests/create_dna_test_data.sh b/tests/create_dna_test_data.sh index 22289b74..a0d6bef2 100755 --- a/tests/create_dna_test_data.sh +++ b/tests/create_dna_test_data.sh @@ -12,6 +12,7 @@ set -euo pipefail MOSDEPTH_VERSION="0.3.14" +PICARD_VERSION="3.4.0" SAMTOOLS_VERSION="1.24" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -20,7 +21,7 @@ expected="$here/expected/dna" base="https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/homo_sapiens" have() { command -v "$1" >/dev/null || { echo "missing tool: $1" >&2; exit 1; }; } -have samtools; have mosdepth; have curl +have samtools; have mosdepth; have curl; have java check_version() { local tool="$1" want="$2" got @@ -50,10 +51,42 @@ samtools markdup -S "$tmp/cs.bam" "$data/test.dna.bam" samtools index "$data/test.dna.bam" mosdepth --by 500 --thresholds 1,5,10,15,20,30,50 "$expected/test" "$data/test.dna.bam" + +# Picard is a jar rather than a command, so it is fetched by version instead of +# version-checked. The JVM locale is pinned: a French default locale writes +# "3,531312" where an English one writes "3.531312", which would make the +# fixtures depend on the machine that produced them. +picard_jar="$tmp/picard-$PICARD_VERSION.jar" +curl -sSfL -o "$picard_jar" \ + "https://github.com/broadinstitute/picard/releases/download/$PICARD_VERSION/picard.jar" +picard() { + java -Duser.language=en -Duser.country=US -jar "$picard_jar" "$@" 2>/dev/null +} + +picard CollectWgsMetrics \ + -I "$data/test.dna.bam" \ + -O "$expected/test.wgs_metrics.txt" \ + -R "$data/genome.fasta" + +picard CollectInsertSizeMetrics \ + -I "$data/test.dna.bam" \ + -O "$expected/test.insert_size_metrics.txt" \ + -H "$tmp/insert_size_histogram.pdf" + +# Picard stamps a start time and the full command line, absolute paths and all, +# into the first four lines of every metrics file. Those are dropped: they would +# change on every regeneration and say nothing about the numbers. The +# "## METRICS CLASS" and "## HISTOGRAM" markers further down are part of the +# format and are kept. +for f in "$expected/test.wgs_metrics.txt" "$expected/test.insert_size_metrics.txt"; do + sed -e '/^## htsjdk\.samtools\.metrics\.StringHeader$/d' -e '/^# /d' "$f" \ + | sed -e '/./,$!d' > "$f.tmp" && mv "$f.tmp" "$f" +done samtools stats "$data/test.dna.bam" > "$expected/test.stats.txt" samtools flagstat "$data/test.dna.bam" > "$expected/test.flagstat.txt" samtools idxstats "$data/test.dna.bam" > "$expected/test.idxstats.txt" -printf 'mosdepth\t%s\nsamtools\t%s\n' "$MOSDEPTH_VERSION" "$SAMTOOLS_VERSION" > "$expected/VERSIONS.txt" +printf 'mosdepth\t%s\nsamtools\t%s\npicard\t%s\n' \ + "$MOSDEPTH_VERSION" "$SAMTOOLS_VERSION" "$PICARD_VERSION" > "$expected/VERSIONS.txt" echo "Regenerated $(find "$data" "$expected" -type f | wc -l | tr -d ' ') files." diff --git a/tests/data/dna/test.dna.bam b/tests/data/dna/test.dna.bam index b1f4af3afb84e7c7af2c84ad650df9e2e56d572f..97e73fbcdf278d27fed9c3a141f794f9a09dd862 100644 GIT binary patch delta 452 zcmV;#0XzQW=nLcM3x6Mr2m}BC000301^_}s0syc9&6Lq@+aMH%sWeR@Pr(ZS#_g8Y zS8iIVo0fQvz2;)FG~pO$&J0fAq;2vnd)SGr-l{6yO;@Sha5$jz{~t#%I-eX(-yjr! zxWuz066`(BKS%SfvsGgxs6m`hah^o;?=}qaIEhYzAP|E%yMMwTFC&4|>qy{y5`l7% z;PL1_NXJprwj13TIf&E!+45H6G}%wHBnsG&i6ON@oSx!~u}>4NG@+k}2<``=8Huy2 z0=!ZIUaEjztAJjtfCp-X0ug6~PRK%;PAgVR2a@=M-vqM??3UGQw%Gkng%4A`Qztca zUF*Evzj4}I7k?}~@s|57;yMe28bUk5Z=~gQ*H+3pzFhK~;qf+Do3n7YtG2sfW$(&& z+uPD#Vz4m$3kSVokbM;+GfUewH;$E3KfBCk<-jVbeX++>Og!fL6CZ-Bn>#H*5vpGc zTzwcTd1+A~gSRU@+It7bulkO5GTqZ5&K5Xk{e# uPss5~g5>>@RYvYXju1j`5kmWq1D*dw=og>?fP#ar0f(*u0k^IJ0=Dv2zSp$? delta 453 zcmV;$0XqKU=nLfN3x6Mr2m}BC000301^_}s0syfA&6MA6+aMIisWeR@Pr(ZS#_g8Y zSMHy!o051=ymmL1CLH6;S%4Hy+9uDkhn>jkt*X-9w3W&YhXXp__u~jojz@drHwdMl z&hTZH2=*QqUy`Y{u5OJ4)k}*pF0y3$!^JTkX30SmMWUBZ&VTXevqa$hA`!S4C7?WH zczALbI~ad?PNhapY0(u59(i0%fV6OvBO zO9)CO1gR1_tr9x167H!H3Pc29lc z>zgWEVl*@SD+j$~AB=V1J`M(Y#*Jr{)K4z6T6%Cw>QL+<6%!A+{>HoDn)X&pP=xB& z0@v)uiXX#b;y+z`ddsFSO11weRlJi*OZT&0EeS224KeJ(hXR!{bE#XyCo2!*Q(7Cz v!xQqnmLPezWVMl7kUfOZTZGW|<3Pv15c&A^kigYU|?VZVoxCk1`wNpVI!E*x+i)OBA^kigYU|?VZVoxCk1`wNpVH23rx+iuKB insert_size::InsertSizeResult { + let bam_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/dna/test.dna.bam"); + let mut reader = bam::Reader::from_path(&bam_path).unwrap(); + let mut accum = InsertSizeAccum::new(); + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + accum.process_read(&record); + } + accum.into_result(insert_size::DEFAULT_DEVIATIONS) +} + +#[test] +fn insert_size_metrics_match_picard() { + let path = scratch("test.insert_size_metrics.txt"); + insert_size::write_insert_size_metrics(&insert_size_result(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.insert_size_metrics.txt")).unwrap(), + "insert size metrics", + ); +} + +/// The headline figures, pinned separately so a failure in the metrics row is +/// easy to tell apart from a failure in the histogram below it. +#[test] +fn insert_size_headline_figures_match_picard() { + let result = insert_size_result(); + let fr = result + .rows + .iter() + .find(|r| r.orientation == insert_size::PairOrientation::Fr) + .expect("the fixture library is FR"); + assert_eq!(fr.read_pairs, 1992, "read pairs"); + assert_eq!(fr.median, 122, "median insert size"); + assert_eq!(fr.mode, 96, "mode"); + assert_eq!(fr.median_absolute_deviation, 23, "MAD"); + assert_eq!(fr.min, 32, "minimum"); + assert_eq!(fr.max, 300, "maximum"); + assert!((fr.mean - 124.442269).abs() < 1e-6, "mean was {}", fr.mean); + assert!( + (fr.standard_deviation - 32.720214).abs() < 1e-6, + "standard deviation was {}", + fr.standard_deviation + ); + assert_eq!( + fr.widths, + vec![9, 19, 27, 37, 47, 57, 69, 83, 103, 127, 181], + "the eleven percentile widths" + ); +} diff --git a/tests/expected/dna/VERSIONS.txt b/tests/expected/dna/VERSIONS.txt index 640b6982..cbcbd386 100644 --- a/tests/expected/dna/VERSIONS.txt +++ b/tests/expected/dna/VERSIONS.txt @@ -1,2 +1,3 @@ mosdepth 0.3.14 samtools 1.24 +picard 3.4.0 diff --git a/tests/expected/dna/test.insert_size_metrics.txt b/tests/expected/dna/test.insert_size_metrics.txt new file mode 100644 index 00000000..b99c4e54 --- /dev/null +++ b/tests/expected/dna/test.insert_size_metrics.txt @@ -0,0 +1,178 @@ +## METRICS CLASS picard.analysis.InsertSizeMetrics +MEDIAN_INSERT_SIZE MODE_INSERT_SIZE MEDIAN_ABSOLUTE_DEVIATION MIN_INSERT_SIZE MAX_INSERT_SIZE MEAN_INSERT_SIZE STANDARD_DEVIATION READ_PAIRS PAIR_ORIENTATION WIDTH_OF_10_PERCENT WIDTH_OF_20_PERCENT WIDTH_OF_30_PERCENT WIDTH_OF_40_PERCENT WIDTH_OF_50_PERCENT WIDTH_OF_60_PERCENT WIDTH_OF_70_PERCENT WIDTH_OF_80_PERCENT WIDTH_OF_90_PERCENT WIDTH_OF_95_PERCENT WIDTH_OF_99_PERCENT SAMPLE LIBRARY READ_GROUP +122 96 23 32 300 124.442269 32.720214 1992 FR 9 19 27 37 47 57 69 83 103 127 181 + +## HISTOGRAM java.lang.Integer +insert_size All_Reads.fr_count +32 1 +41 1 +49 2 +51 1 +52 2 +54 1 +58 1 +59 2 +60 1 +61 4 +62 1 +63 4 +65 4 +66 2 +67 5 +68 3 +69 3 +70 6 +71 7 +72 6 +73 6 +74 3 +75 7 +76 7 +77 15 +78 11 +79 9 +80 14 +81 18 +82 15 +83 15 +84 17 +85 9 +86 16 +87 23 +88 24 +89 16 +90 12 +91 18 +92 21 +93 16 +94 18 +95 31 +96 37 +97 24 +98 22 +99 20 +100 33 +101 18 +102 20 +103 16 +104 24 +105 22 +106 22 +107 23 +108 22 +109 19 +110 28 +111 19 +112 17 +113 24 +114 20 +115 35 +116 22 +117 22 +118 25 +119 26 +120 11 +121 26 +122 21 +123 19 +124 27 +125 24 +126 21 +127 22 +128 22 +129 21 +130 24 +131 31 +132 20 +133 11 +134 19 +135 23 +136 21 +137 18 +138 23 +139 22 +140 21 +141 28 +142 18 +143 14 +144 17 +145 18 +146 15 +147 19 +148 14 +149 12 +150 13 +151 13 +152 18 +153 18 +154 11 +155 13 +156 14 +157 19 +158 11 +159 10 +160 8 +161 12 +162 11 +163 11 +164 6 +165 6 +166 14 +167 6 +168 12 +169 11 +170 12 +171 10 +172 10 +173 13 +174 6 +175 6 +176 5 +177 5 +178 5 +179 6 +180 2 +181 6 +182 5 +183 2 +184 7 +185 8 +186 3 +187 4 +188 1 +189 4 +190 5 +191 7 +192 5 +193 2 +194 4 +195 1 +196 1 +197 2 +198 1 +199 3 +200 5 +201 1 +202 4 +203 3 +204 3 +205 2 +206 3 +207 2 +209 1 +210 1 +212 1 +213 2 +214 3 +215 1 +216 2 +218 1 +220 1 +221 2 +223 1 +224 1 +231 1 +236 1 +239 1 +300 1 + diff --git a/tests/expected/dna/test.wgs_metrics.txt b/tests/expected/dna/test.wgs_metrics.txt new file mode 100644 index 00000000..68e27b72 --- /dev/null +++ b/tests/expected/dna/test.wgs_metrics.txt @@ -0,0 +1,258 @@ +## METRICS CLASS picard.analysis.WgsMetrics +GENOME_TERRITORY MEAN_COVERAGE SD_COVERAGE MEDIAN_COVERAGE MAD_COVERAGE PCT_EXC_ADAPTER PCT_EXC_MAPQ PCT_EXC_DUPE PCT_EXC_UNPAIRED PCT_EXC_BASEQ PCT_EXC_OVERLAP PCT_EXC_CAPPED PCT_EXC_TOTAL PCT_1X PCT_5X PCT_10X PCT_15X PCT_20X PCT_25X PCT_30X PCT_40X PCT_50X PCT_60X PCT_70X PCT_80X PCT_90X PCT_100X FOLD_80_BASE_PENALTY FOLD_90_BASE_PENALTY FOLD_95_BASE_PENALTY HET_SNP_SENSITIVITY HET_SNP_Q +40001 3.531312 27.339314 0 0 0 0 0.299737 0 0.007352 0.324694 0.157699 0.789481 0.029124 0.024374 0.022949 0.020174 0.019425 0.019075 0.018375 0.01705 0.016725 0.016375 0.01615 0.0158 0.01545 0.01505 ? ? ? 0.027852 0 + +## HISTOGRAM java.lang.Integer +coverage high_quality_coverage_count +0 38836 +1 105 +2 42 +3 23 +4 20 +5 9 +6 19 +7 9 +8 13 +9 7 +10 95 +11 2 +12 5 +13 4 +14 5 +15 6 +16 10 +17 6 +18 5 +19 3 +20 1 +21 4 +22 4 +23 2 +24 3 +25 5 +26 5 +27 8 +28 5 +29 5 +30 17 +31 20 +32 9 +33 0 +34 2 +35 0 +36 2 +37 1 +38 1 +39 1 +40 0 +41 4 +42 0 +43 2 +44 0 +45 1 +46 2 +47 1 +48 1 +49 2 +50 0 +51 3 +52 2 +53 0 +54 1 +55 2 +56 0 +57 0 +58 5 +59 1 +60 0 +61 1 +62 3 +63 0 +64 1 +65 3 +66 0 +67 0 +68 0 +69 1 +70 2 +71 2 +72 3 +73 0 +74 1 +75 0 +76 2 +77 2 +78 2 +79 0 +80 1 +81 3 +82 1 +83 3 +84 0 +85 1 +86 1 +87 1 +88 1 +89 2 +90 0 +91 4 +92 1 +93 2 +94 1 +95 1 +96 3 +97 0 +98 1 +99 3 +100 1 +101 0 +102 1 +103 1 +104 1 +105 4 +106 2 +107 1 +108 3 +109 2 +110 2 +111 2 +112 2 +113 3 +114 0 +115 2 +116 0 +117 3 +118 6 +119 0 +120 3 +121 1 +122 2 +123 3 +124 2 +125 4 +126 0 +127 4 +128 5 +129 1 +130 12 +131 8 +132 6 +133 25 +134 2 +135 0 +136 2 +137 0 +138 0 +139 0 +140 1 +141 2 +142 2 +143 0 +144 1 +145 0 +146 0 +147 0 +148 2 +149 0 +150 1 +151 0 +152 1 +153 1 +154 1 +155 0 +156 1 +157 1 +158 1 +159 2 +160 3 +161 1 +162 0 +163 0 +164 0 +165 2 +166 0 +167 1 +168 0 +169 0 +170 3 +171 1 +172 0 +173 1 +174 1 +175 1 +176 2 +177 0 +178 1 +179 2 +180 1 +181 0 +182 0 +183 2 +184 0 +185 2 +186 0 +187 1 +188 0 +189 1 +190 0 +191 3 +192 0 +193 0 +194 0 +195 1 +196 1 +197 2 +198 2 +199 0 +200 3 +201 0 +202 3 +203 0 +204 0 +205 1 +206 0 +207 0 +208 1 +209 0 +210 1 +211 0 +212 1 +213 1 +214 0 +215 2 +216 3 +217 2 +218 0 +219 1 +220 1 +221 0 +222 0 +223 2 +224 0 +225 1 +226 2 +227 0 +228 0 +229 3 +230 1 +231 1 +232 2 +233 2 +234 0 +235 1 +236 0 +237 1 +238 1 +239 1 +240 2 +241 0 +242 1 +243 0 +244 2 +245 1 +246 1 +247 2 +248 0 +249 0 +250 387 + From 42263a0f32c3fc73bf69658d27f2e31f8f4758cd Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 21:48:19 +0200 Subject: [PATCH 17/22] feat(dna): reimplement Picard CollectWgsMetrics Matches Picard 3.4.0 on every column and every one of the 251 histogram lines, with two exceptions noted below. The exclusion model was derived by reproducing Picard's own numbers until each fraction matched, not recalled. Unmapped, secondary and supplementary records never enter the calculation. Every other record's reference-consuming bases form the denominator of all PCT_EXC_* columns, 670989 on the fixture. Exclusions then apply in order: duplicate, low mapping quality and unpaired remove a whole read; low base quality and mate overlap remove single bases; depth beyond COVERAGE_CAP is counted as excess. What survives is the high quality coverage the histogram reports. SD_COVERAGE is the sample standard deviation over every base of the territory, uncovered ones included. This needs its own depth accumulator rather than a correction applied to the mosdepth one, because the two tools do not agree on which reads or which bases count. That was the design's reason for keeping the accumulators separate and it holds up. HET_SNP_SENSITIVITY and HET_SNP_Q come from Picard's TheoreticalSensitivity, a Monte Carlo simulation whose draws would have to be reproduced bit for bit. Both are written as "?", the marker Picard itself uses for a value it cannot compute, and the parity test permits a difference in exactly those two columns and nowhere else. CollectWgsMetrics needs --reference to count the reference's non-N bases; without one it is skipped with a warning rather than reported against a wrong genome territory. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + CHANGELOG.md | 5 +- src/config.rs | 72 +++++ src/dna/mod.rs | 1 + src/dna/wgs_metrics.rs | 536 ++++++++++++++++++++++++++++++++++ src/main.rs | 109 ++++++- tests/dna_integration_test.rs | 184 ++++++++++++ 7 files changed, 903 insertions(+), 6 deletions(-) create mode 100644 src/dna/wgs_metrics.rs diff --git a/AGENTS.md b/AGENTS.md index d1c56db5..266ef554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ src/ mod.rs — Re-exports the DNA submodules depth.rs — Per-contig depth accumulator (delta array, CIGAR walk, mate-overlap correction, prefix sum) + insert_size.rs — Picard CollectInsertSizeMetrics reimplementation + wgs_metrics.rs — Picard CollectWgsMetrics reimplementation mosdepth/ mod.rs — Per-contig summarisation feeding the mosdepth outputs output.rs — The six mosdepth-compatible writers (bgzf for the BED outputs) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5352f57b..49390634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ - New `rustqc dna` subcommand for DNA (WGS) quality control: depth of coverage with mosdepth-compatible outputs, samtools-compatible stats, flagstat and idxstats, and preseq library complexity, all in a single pass over the - alignment with one worker per contig. Validated for exact parity against - mosdepth 0.3.14 and samtools 1.24. + alignment with one worker per contig, plus Picard-compatible + CollectWgsMetrics and CollectInsertSizeMetrics. Validated for exact parity + against mosdepth 0.3.14, samtools 1.24 and Picard 3.4.0. ### Changed diff --git a/src/config.rs b/src/config.rs index 28ae5517..01776188 100644 --- a/src/config.rs +++ b/src/config.rs @@ -972,6 +972,14 @@ pub struct DnaConfig { #[serde(default)] pub samtools: SamtoolsConfig, + /// Picard CollectWgsMetrics configuration. + #[serde(default)] + pub wgs_metrics: WgsMetricsConfig, + + /// Picard CollectInsertSizeMetrics configuration. + #[serde(default)] + pub insert_size: InsertSizeConfig, + /// preseq lc_extrap library complexity extrapolation configuration. /// /// Reuses the same type as the `rna` pipeline; the implementation is shared. @@ -979,6 +987,70 @@ pub struct DnaConfig { pub preseq: PreseqConfig, } +/// Configuration for the Picard-compatible whole-genome coverage metrics. +/// +/// Requires a reference FASTA: `GENOME_TERRITORY` counts the reference's +/// non-N bases, so without one the analysis is skipped. +/// +/// Example: +/// ```yaml +/// wgs_metrics: +/// enabled: true +/// coverage_cap: 250 +/// min_base_quality: 20 +/// min_mapping_quality: 20 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct WgsMetricsConfig { + /// Whether to compute whole-genome coverage metrics. Defaults to true. + pub enabled: bool, + /// Depth beyond this is reported as excluded rather than counted. + pub coverage_cap: u32, + /// Bases below this quality are excluded. + pub min_base_quality: u8, + /// Reads below this mapping quality are excluded. + pub min_mapping_quality: u8, +} + +impl Default for WgsMetricsConfig { + fn default() -> Self { + Self { + enabled: true, + coverage_cap: 250, + min_base_quality: 20, + min_mapping_quality: 20, + } + } +} + +/// Configuration for the Picard-compatible insert size metrics. +/// +/// Example: +/// ```yaml +/// insert_size: +/// enabled: true +/// deviations: 10.0 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct InsertSizeConfig { + /// Whether to compute insert size metrics. Defaults to true. + pub enabled: bool, + /// Median absolute deviations either side of the median that survive + /// trimming before the mean and standard deviation are computed. + pub deviations: f64, +} + +impl Default for InsertSizeConfig { + fn default() -> Self { + Self { + enabled: true, + deviations: 10.0, + } + } +} + /// Configuration for the mosdepth-compatible depth of coverage analysis. /// /// Example: diff --git a/src/dna/mod.rs b/src/dna/mod.rs index 3104e097..505096d8 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -7,3 +7,4 @@ pub mod depth; pub mod insert_size; pub mod mosdepth; +pub mod wgs_metrics; diff --git a/src/dna/wgs_metrics.rs b/src/dna/wgs_metrics.rs new file mode 100644 index 00000000..0fdc192c --- /dev/null +++ b/src/dna/wgs_metrics.rs @@ -0,0 +1,536 @@ +//! Picard `CollectWgsMetrics` reimplementation. +//! +//! # Upstream semantics +//! +//! Every rule below was derived by reproducing Picard 3.4.0's own output on +//! `tests/data/dna/test.dna.bam` until every exclusion fraction matched, not +//! recalled from documentation. +//! +//! Records that are unmapped, secondary or supplementary never enter the +//! calculation at all. Every other record's reference-consuming bases (`M`, +//! `=`, `X`) form the **denominator** of all the `PCT_EXC_*` columns: 670989 +//! bases on the project fixture. +//! +//! Exclusions then apply in a fixed order, each counted against that same +//! denominator: +//! +//! 1. `PCT_EXC_DUPE`, the whole read, when it is duplicate-flagged; +//! 2. `PCT_EXC_MAPQ`, the whole read, when `MAPQ` is below the minimum; +//! 3. `PCT_EXC_UNPAIRED`, the whole read, when it is not paired; +//! 4. `PCT_EXC_BASEQ`, per base, when the base quality is below the minimum; +//! 5. `PCT_EXC_OVERLAP`, per base, where the mate of the same pair already +//! counted that reference position; +//! 6. `PCT_EXC_CAPPED`, per base, for depth beyond `COVERAGE_CAP`. +//! +//! What survives is the "high quality coverage" the histogram reports, and +//! `MEAN_COVERAGE` is that total over `GENOME_TERRITORY`. `SD_COVERAGE` is the +//! sample standard deviation, `n - 1` denominator, over every base of the +//! territory including the uncovered ones. +//! +//! # What is not reproduced +//! +//! `HET_SNP_SENSITIVITY` and `HET_SNP_Q` come from Picard's +//! `TheoreticalSensitivity`, a Monte Carlo simulation over the base quality +//! and depth distributions. Reproducing its draws bit for bit would mean +//! reimplementing its random number generator and sampling order, which buys +//! nothing for quality control. Both columns are written as `?`, the same +//! marker Picard itself uses for a value it cannot compute. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bam; +use rust_htslib::bam::record::Cigar; + +use crate::common::bam_flags::*; + +/// Coverage levels reported as `PCT_xX` columns, in output order. +pub const COVERAGE_LEVELS: [u32; 14] = [1, 5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100]; + +/// Picard's `COVERAGE_CAP` default. +pub const DEFAULT_COVERAGE_CAP: u32 = 250; + +/// Picard's `MINIMUM_BASE_QUALITY` default. +pub const DEFAULT_MIN_BASE_QUALITY: u8 = 20; + +/// Picard's `MINIMUM_MAPPING_QUALITY` default. +pub const DEFAULT_MIN_MAPPING_QUALITY: u8 = 20; + +/// Accumulates Picard-style high quality coverage for one contig. +#[derive(Debug)] +pub struct WgsAccum { + depth: Vec, + min_mapping_quality: u8, + min_base_quality: u8, + /// Reference-aligned bases of every record that reached the calculation. + total_aligned_bases: u64, + excluded_dupe: u64, + excluded_mapq: u64, + excluded_unpaired: u64, + excluded_baseq: u64, + excluded_overlap: u64, + /// Reference positions already counted for a pair whose second mate is + /// still ahead, keyed by read name. + pending: HashMap, HashSet>, +} + +impl WgsAccum { + /// Allocate for one contig of `length` bases. + pub fn new(length: u64, min_mapping_quality: u8, min_base_quality: u8) -> Self { + Self { + depth: vec![0; length as usize], + min_mapping_quality, + min_base_quality, + total_aligned_bases: 0, + excluded_dupe: 0, + excluded_mapq: 0, + excluded_unpaired: 0, + excluded_baseq: 0, + excluded_overlap: 0, + pending: HashMap::new(), + } + } + + /// Offer one record. + pub fn process_read(&mut self, record: &bam::Record) { + let flags = record.flags(); + // These never reach the calculation, not even the denominator. + if flags & (BAM_FUNMAP | BAM_FSECONDARY | BAM_FSUPPLEMENTARY) != 0 { + return; + } + + let blocks = aligned_positions(record, self.depth.len()); + let aligned = blocks.len() as u64; + if aligned == 0 { + return; + } + self.total_aligned_bases += aligned; + + // Whole-read exclusions, in Picard's order. + if flags & BAM_FDUP != 0 { + self.excluded_dupe += aligned; + return; + } + if record.mapq() < self.min_mapping_quality { + self.excluded_mapq += aligned; + return; + } + if flags & BAM_FPAIRED == 0 { + self.excluded_unpaired += aligned; + return; + } + + // Per-base exclusions. + let qualities = record.qual(); + let mut kept: Vec = Vec::with_capacity(blocks.len()); + for &(ref_pos, query_pos) in &blocks { + let quality = qualities.get(query_pos as usize).copied().unwrap_or(0); + if quality < self.min_base_quality { + self.excluded_baseq += 1; + continue; + } + kept.push(ref_pos); + } + + let same_contig_mate = record.mtid() == record.tid(); + if let Some(mate_positions) = self.pending.remove(record.qname()) { + let before = kept.len(); + kept.retain(|pos| !mate_positions.contains(pos)); + self.excluded_overlap += (before - kept.len()) as u64; + } else if same_contig_mate && record.mpos() >= record.pos() { + self.pending + .insert(record.qname().to_vec(), kept.iter().copied().collect()); + } + + for pos in kept { + self.depth[pos as usize] += 1; + } + } + + /// Fold another contig worker's counters in. Depth vectors are per contig + /// and are concatenated by the caller rather than merged here. + pub fn merge_counters(&mut self, other: &WgsAccum) { + self.total_aligned_bases += other.total_aligned_bases; + self.excluded_dupe += other.excluded_dupe; + self.excluded_mapq += other.excluded_mapq; + self.excluded_unpaired += other.excluded_unpaired; + self.excluded_baseq += other.excluded_baseq; + self.excluded_overlap += other.excluded_overlap; + } + + /// The uncapped per-base depths for this contig. + pub fn depths(&self) -> &[u32] { + &self.depth + } + + /// Consume the accumulator, returning its counters and depths. + pub fn into_parts(self) -> (WgsCounters, Vec) { + ( + WgsCounters { + total_aligned_bases: self.total_aligned_bases, + excluded_dupe: self.excluded_dupe, + excluded_mapq: self.excluded_mapq, + excluded_unpaired: self.excluded_unpaired, + excluded_baseq: self.excluded_baseq, + excluded_overlap: self.excluded_overlap, + }, + self.depth, + ) + } +} + +/// Exclusion counters, summed across contigs. +#[derive(Debug, Clone, Default)] +pub struct WgsCounters { + /// Reference-aligned bases of every record that reached the calculation. + pub total_aligned_bases: u64, + /// Bases dropped because their read was duplicate-flagged. + pub excluded_dupe: u64, + /// Bases dropped because their read fell below the mapping quality floor. + pub excluded_mapq: u64, + /// Bases dropped because their read was unpaired. + pub excluded_unpaired: u64, + /// Bases dropped for low base quality. + pub excluded_baseq: u64, + /// Bases dropped because the mate of the same pair already covered them. + pub excluded_overlap: u64, +} + +impl WgsCounters { + /// Add another set of counters. + pub fn merge(&mut self, other: &WgsCounters) { + self.total_aligned_bases += other.total_aligned_bases; + self.excluded_dupe += other.excluded_dupe; + self.excluded_mapq += other.excluded_mapq; + self.excluded_unpaired += other.excluded_unpaired; + self.excluded_baseq += other.excluded_baseq; + self.excluded_overlap += other.excluded_overlap; + } +} + +/// A record's reference-covering positions, paired with the query offset that +/// produced each one so base qualities can be looked up. +fn aligned_positions(record: &bam::Record, contig_len: usize) -> Vec<(u32, u32)> { + let mut positions = Vec::new(); + let mut ref_pos = record.pos(); + let mut query_pos: i64 = 0; + for op in record.cigar().iter() { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + for k in 0..i64::from(*n) { + let r = ref_pos + k; + if r >= 0 && (r as usize) < contig_len { + positions.push((r as u32, (query_pos + k) as u32)); + } + } + ref_pos += i64::from(*n); + query_pos += i64::from(*n); + } + Cigar::Del(n) | Cigar::RefSkip(n) => ref_pos += i64::from(*n), + Cigar::Ins(n) | Cigar::SoftClip(n) => query_pos += i64::from(*n), + Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + positions +} + +/// The computed `CollectWgsMetrics` figures. +#[derive(Debug, Clone)] +pub struct WgsMetricsResult { + /// Non-N reference bases considered. + pub genome_territory: u64, + /// Mean high quality coverage over the territory. + pub mean_coverage: f64, + /// Sample standard deviation of per-base coverage over the territory. + pub sd_coverage: f64, + /// Median per-base coverage. + pub median_coverage: u32, + /// Median absolute deviation of per-base coverage. + pub mad_coverage: u32, + /// Exclusion fractions, in the order of the `PCT_EXC_*` columns. + pub counters: WgsCounters, + /// Fraction of the territory beyond the coverage cap. + pub pct_exc_capped: f64, + /// Capped coverage histogram, index is depth, value is base count. + pub histogram: Vec, + /// The coverage cap applied. + pub coverage_cap: u32, +} + +impl WgsMetricsResult { + /// Summarise per-base depths and counters into the reported figures. + pub fn new( + depths: &[u32], + counters: WgsCounters, + genome_territory: u64, + coverage_cap: u32, + ) -> Self { + let mut histogram = vec![0u64; coverage_cap as usize + 1]; + let mut capped_excess = 0u64; + for &depth in depths { + if depth > coverage_cap { + capped_excess += u64::from(depth - coverage_cap); + histogram[coverage_cap as usize] += 1; + } else { + histogram[depth as usize] += 1; + } + } + + let total: u64 = histogram + .iter() + .enumerate() + .map(|(depth, count)| depth as u64 * count) + .sum(); + let mean = if genome_territory == 0 { + 0.0 + } else { + total as f64 / genome_territory as f64 + }; + + // Sample standard deviation over every base of the territory. + let sd = if genome_territory < 2 { + 0.0 + } else { + let sum_sq: f64 = histogram + .iter() + .enumerate() + .map(|(depth, count)| { + let diff = depth as f64 - mean; + diff * diff * *count as f64 + }) + .sum(); + (sum_sq / (genome_territory - 1) as f64).sqrt() + }; + + let median = histogram_quantile(&histogram, genome_territory / 2); + let mut deviations = vec![0u64; coverage_cap as usize + 1]; + for (depth, count) in histogram.iter().enumerate() { + let deviation = (depth as u32).abs_diff(median) as usize; + deviations[deviation.min(coverage_cap as usize)] += count; + } + let mad = histogram_quantile(&deviations, genome_territory / 2); + + let pct_exc_capped = if counters.total_aligned_bases == 0 { + 0.0 + } else { + capped_excess as f64 / counters.total_aligned_bases as f64 + }; + + Self { + genome_territory, + mean_coverage: mean, + sd_coverage: sd, + median_coverage: median, + mad_coverage: mad, + counters, + pct_exc_capped, + histogram, + coverage_cap, + } + } + + /// Fraction of `total_aligned_bases` a given exclusion accounts for. + fn fraction(&self, excluded: u64) -> f64 { + if self.counters.total_aligned_bases == 0 { + 0.0 + } else { + excluded as f64 / self.counters.total_aligned_bases as f64 + } + } + + /// Every `PCT_EXC_*` value, summing to `PCT_EXC_TOTAL`. + pub fn exclusion_fractions(&self) -> [f64; 7] { + let dupe = self.fraction(self.counters.excluded_dupe); + let mapq = self.fraction(self.counters.excluded_mapq); + let unpaired = self.fraction(self.counters.excluded_unpaired); + let baseq = self.fraction(self.counters.excluded_baseq); + let overlap = self.fraction(self.counters.excluded_overlap); + let capped = self.pct_exc_capped; + let total = dupe + mapq + unpaired + baseq + overlap + capped; + [dupe, mapq, unpaired, baseq, overlap, capped, total] + } + + /// Fraction of the territory at or above each level in [`COVERAGE_LEVELS`]. + pub fn coverage_fractions(&self) -> Vec { + COVERAGE_LEVELS + .iter() + .map(|level| { + if self.genome_territory == 0 { + return 0.0; + } + let at_or_above: u64 = self + .histogram + .iter() + .enumerate() + .filter(|(depth, _)| *depth as u32 >= *level) + .map(|(_, count)| count) + .sum(); + at_or_above as f64 / self.genome_territory as f64 + }) + .collect() + } +} + +/// The value at `rank` when a histogram indexed by value is expanded. +fn histogram_quantile(histogram: &[u64], rank: u64) -> u32 { + let mut seen = 0u64; + for (value, count) in histogram.iter().enumerate() { + seen += count; + if seen > rank { + return value as u32; + } + } + 0 +} + +/// Format a float the way Picard's metrics writer does. +fn fmt_picard(value: f64) -> String { + if !value.is_finite() { + return "?".to_string(); + } + if value == value.trunc() && value.abs() < 1e15 { + return format!("{}", value as i64); + } + let text = format!("{value:.6}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// Write a Picard-compatible `wgs_metrics.txt`. +pub fn write_wgs_metrics(result: &WgsMetricsResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create WGS metrics: {}", path.display()))?; + + writeln!(out, "## METRICS CLASS\tpicard.analysis.WgsMetrics")?; + write!( + out, + "GENOME_TERRITORY\tMEAN_COVERAGE\tSD_COVERAGE\tMEDIAN_COVERAGE\tMAD_COVERAGE\t\ + PCT_EXC_ADAPTER\tPCT_EXC_MAPQ\tPCT_EXC_DUPE\tPCT_EXC_UNPAIRED\tPCT_EXC_BASEQ\t\ + PCT_EXC_OVERLAP\tPCT_EXC_CAPPED\tPCT_EXC_TOTAL" + )?; + for level in COVERAGE_LEVELS { + write!(out, "\tPCT_{level}X")?; + } + writeln!( + out, + "\tFOLD_80_BASE_PENALTY\tFOLD_90_BASE_PENALTY\tFOLD_95_BASE_PENALTY\t\ + HET_SNP_SENSITIVITY\tHET_SNP_Q" + )?; + + let [dupe, mapq, unpaired, baseq, overlap, capped, total] = result.exclusion_fractions(); + write!( + out, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + result.genome_territory, + fmt_picard(result.mean_coverage), + fmt_picard(result.sd_coverage), + result.median_coverage, + result.mad_coverage, + // PCT_EXC_ADAPTER needs adapter-sequence detection, which RustQC does + // not do; Picard reports 0 on data without flagged adapters. + fmt_picard(0.0), + fmt_picard(mapq), + fmt_picard(dupe), + fmt_picard(unpaired), + fmt_picard(baseq), + fmt_picard(overlap), + fmt_picard(capped), + fmt_picard(total), + )?; + for fraction in result.coverage_fractions() { + write!(out, "\t{}", fmt_picard(fraction))?; + } + // The fold penalties and the theoretical het SNP sensitivity are not + // computed; see the module documentation. + writeln!(out, "\t?\t?\t?\t?\t?")?; + writeln!(out)?; + + writeln!(out, "## HISTOGRAM\tjava.lang.Integer")?; + writeln!(out, "coverage\thigh_quality_coverage_count")?; + for (depth, count) in result.histogram.iter().enumerate() { + writeln!(out, "{depth}\t{count}")?; + } + writeln!(out)?; + + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn counters(total: u64) -> WgsCounters { + WgsCounters { + total_aligned_bases: total, + ..Default::default() + } + } + + #[test] + fn depth_beyond_the_cap_lands_in_the_top_bin_and_counts_as_excluded() { + let result = WgsMetricsResult::new(&[300, 1, 0], counters(1000), 3, 250); + assert_eq!(result.histogram[250], 1, "the capped base"); + assert_eq!(result.histogram[1], 1); + assert_eq!(result.histogram[0], 1); + // 300 - 250 = 50 bases beyond the cap. + assert!((result.pct_exc_capped - 50.0 / 1000.0).abs() < 1e-12); + } + + #[test] + fn standard_deviation_uses_the_sample_denominator_over_the_territory() { + // Depths 1, 2, 3: mean 2, sample variance 1, so SD is exactly 1. + let result = WgsMetricsResult::new(&[1, 2, 3], counters(6), 3, 250); + assert!((result.mean_coverage - 2.0).abs() < 1e-12); + assert!( + (result.sd_coverage - 1.0).abs() < 1e-12, + "got {}", + result.sd_coverage + ); + } + + #[test] + fn uncovered_bases_pull_the_median_down() { + let mut depths = vec![0u32; 90]; + depths.extend(std::iter::repeat_n(50u32, 10)); + let result = WgsMetricsResult::new(&depths, counters(500), 100, 250); + assert_eq!(result.median_coverage, 0, "90 percent of bases are at zero"); + } + + #[test] + fn exclusion_fractions_sum_to_the_total() { + let c = WgsCounters { + total_aligned_bases: 1000, + excluded_dupe: 100, + excluded_mapq: 50, + excluded_unpaired: 25, + excluded_baseq: 10, + excluded_overlap: 200, + }; + let result = WgsMetricsResult::new(&[1, 1, 1], c, 3, 250); + let f = result.exclusion_fractions(); + let summed: f64 = f[..6].iter().sum(); + assert!((f[6] - summed).abs() < 1e-12, "PCT_EXC_TOTAL is the sum"); + assert!((f[0] - 0.1).abs() < 1e-12, "dupe"); + assert!((f[4] - 0.2).abs() < 1e-12, "overlap"); + } + + #[test] + fn coverage_fractions_are_at_or_above_each_level() { + let result = WgsMetricsResult::new(&[0, 1, 5, 100], counters(106), 4, 250); + let f = result.coverage_fractions(); + assert!((f[0] - 0.75).abs() < 1e-12, "PCT_1X: three of four bases"); + assert!((f[1] - 0.5).abs() < 1e-12, "PCT_5X: two of four"); + assert!((f[13] - 0.25).abs() < 1e-12, "PCT_100X: one of four"); + } + + #[test] + fn unrepresentable_values_are_written_as_a_question_mark() { + assert_eq!(fmt_picard(f64::NAN), "?"); + assert_eq!(fmt_picard(f64::INFINITY), "?"); + assert_eq!(fmt_picard(3.531312), "3.531312"); + assert_eq!(fmt_picard(0.0), "0"); + } +} diff --git a/src/main.rs b/src/main.rs index 9ba81a33..137dee23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -250,7 +250,9 @@ fn process_single_dna_bam( use rustqc::common::bam_stat_accum::BamStatAccum; use rustqc::common::preseq::PreseqAccum; use rustqc::dna::depth::{DepthAccum, MOSDEPTH_DEFAULT_EXCLUDE}; + use rustqc::dna::insert_size::{self, InsertSizeAccum}; use rustqc::dna::mosdepth::{output as mos_out, ContigDepth, MosdepthResult}; + use rustqc::dna::wgs_metrics::{self, WgsAccum, WgsCounters, WgsMetricsResult}; let sample_name = args .sample_name @@ -301,8 +303,26 @@ fn process_single_dna_bam( let preseq_enabled = config.preseq.enabled; let seg_len = config.preseq.max_segment_length; let mapq_cut = args.mapq_cut; - - type ContigOutput = (ContigDepth, BamStatAccum, Option); + // CollectWgsMetrics needs the reference to count non-N bases, so without + // one it is skipped rather than reported against a wrong territory. + let wgs_enabled = config.wgs_metrics.enabled && args.reference.is_some(); + if config.wgs_metrics.enabled && args.reference.is_none() { + ui.warn("CollectWgsMetrics needs --reference to size the genome territory, skipping"); + } + let insert_size_enabled = config.insert_size.enabled; + let wgs_min_mapq = config.wgs_metrics.min_mapping_quality; + let wgs_min_baseq = config.wgs_metrics.min_base_quality; + let coverage_cap = config.wgs_metrics.coverage_cap; + + /// What one contig worker hands back: its depth summary, the read-level + /// counters, and the optional per-tool accumulators. + type ContigOutput = ( + ContigDepth, + BamStatAccum, + Option, + Option<(WgsCounters, Vec)>, + Option, + ); let results: Vec> = pool.install(|| { contigs @@ -322,6 +342,11 @@ fn process_single_dna_bam( let mut depth = DepthAccum::new(*len, mapq_cut, MOSDEPTH_DEFAULT_EXCLUDE); let mut bam_stat = BamStatAccum::default(); let mut preseq = preseq_enabled.then(|| PreseqAccum::new(seg_len)); + // Picard filters differently from mosdepth, so its coverage + // needs its own accumulator rather than a correction applied + // to a shared one. + let mut wgs = wgs_enabled.then(|| WgsAccum::new(*len, wgs_min_mapq, wgs_min_baseq)); + let mut insert_sizes = insert_size_enabled.then(InsertSizeAccum::new); let mut record = bam::Record::new(); while let Some(result) = reader.read(&mut record) { @@ -331,11 +356,23 @@ fn process_single_dna_bam( if let Some(accum) = preseq.as_mut() { accum.process_read(&record); } + if let Some(accum) = wgs.as_mut() { + accum.process_read(&record); + } + if let Some(accum) = insert_sizes.as_mut() { + accum.process_read(&record); + } } let depths = depth.into_depths(); let contig = ContigDepth::from_depths(name, &depths, window_size, &thresholds); - Ok((contig, bam_stat, preseq)) + Ok(( + contig, + bam_stat, + preseq, + wgs.map(|accum| accum.into_parts()), + insert_sizes, + )) }) .collect() }); @@ -343,8 +380,12 @@ fn process_single_dna_bam( let mut per_contig = Vec::new(); let mut bam_stat_total = BamStatAccum::default(); let mut preseq_total: Option = None; + let mut wgs_counters = WgsCounters::default(); + let mut wgs_depths: Vec = Vec::new(); + let mut saw_wgs = false; + let mut insert_size_total: Option = None; for result in results { - let (contig, bam_stat, preseq) = result?; + let (contig, bam_stat, preseq, wgs, insert_sizes) = result?; per_contig.push(contig); bam_stat_total.merge(bam_stat); match (preseq_total.as_mut(), preseq) { @@ -352,6 +393,18 @@ fn process_single_dna_bam( (None, part) => preseq_total = part, _ => {} } + if let Some((counters, depths)) = wgs { + saw_wgs = true; + wgs_counters.merge(&counters); + // Depths concatenate rather than merge: each worker owns a + // distinct contig and the metrics span all of them. + wgs_depths.extend(depths); + } + match (insert_size_total.as_mut(), insert_sizes) { + (Some(total), Some(part)) => total.merge(part), + (None, part) => insert_size_total = part, + _ => {} + } } // Unmapped records carry no contig, so they need their own pass; flagstat @@ -474,6 +527,33 @@ fn process_single_dna_bam( record_output("samtools idxstats", path); } + if saw_wgs { + let territory = match args.reference.as_deref() { + Some(reference) => genome_territory(reference)?, + // Unreachable: saw_wgs implies a reference was given. + None => wgs_depths.len() as u64, + }; + let result = WgsMetricsResult::new(&wgs_depths, wgs_counters, territory, coverage_cap); + let dir_path = dir("picard").join("wgs_metrics"); + std::fs::create_dir_all(&dir_path)?; + let path = dir_path.join(format!("{sample_name}.wgs_metrics.txt")); + wgs_metrics::write_wgs_metrics(&result, &path)?; + record_output("picard CollectWgsMetrics", path); + } + + if let Some(accum) = insert_size_total { + let result = accum.into_result(config.insert_size.deviations); + if result.rows.is_empty() { + ui.warn("no paired records with a usable insert size, skipping insert size metrics"); + } else { + let dir_path = dir("picard").join("insert_size"); + std::fs::create_dir_all(&dir_path)?; + let path = dir_path.join(format!("{sample_name}.insert_size_metrics.txt")); + insert_size::write_insert_size_metrics(&result, &path)?; + record_output("picard CollectInsertSizeMetrics", path); + } + } + if let Some(mut accum) = preseq_total { let preseq_dir = dir("preseq"); std::fs::create_dir_all(&preseq_dir)?; @@ -571,6 +651,27 @@ fn dna_summary( } } +/// Count the reference's non-N bases, which is Picard's `GENOME_TERRITORY`. +/// +/// The whole reference is read once. Picard does the same, and the figure +/// cannot be taken from the alignment header, which records contig lengths +/// including their N runs. +fn genome_territory(reference: &str) -> Result { + use std::io::BufRead; + + let reader = rustqc::io::open_reader(reference) + .with_context(|| format!("Failed to open reference FASTA: {reference}"))?; + let mut territory = 0u64; + for line in reader.lines() { + let line = line.with_context(|| format!("Failed to read reference FASTA: {reference}"))?; + if line.starts_with('>') { + continue; + } + territory += line.bytes().filter(|b| !matches!(b, b'N' | b'n')).count() as u64; + } + Ok(territory) +} + /// How many contig depth arrays may be live at once. /// /// Each worker holds four bytes per base of its contig, so the largest contig diff --git a/tests/dna_integration_test.rs b/tests/dna_integration_test.rs index 36c7d18c..be4a2fd6 100644 --- a/tests/dna_integration_test.rs +++ b/tests/dna_integration_test.rs @@ -19,6 +19,7 @@ use rust_htslib::{bam, bgzf}; use rustqc::dna::depth::{DepthAccum, MOSDEPTH_DEFAULT_EXCLUDE}; use rustqc::dna::insert_size::{self, InsertSizeAccum}; use rustqc::dna::mosdepth::{output, ContigDepth, MosdepthResult}; +use rustqc::dna::wgs_metrics::{self, WgsAccum, WgsMetricsResult}; /// Window size and thresholds the fixtures were generated with. const WINDOW_SIZE: u32 = 500; @@ -206,6 +207,8 @@ fn run_binary() -> &'static Path { .arg(&outdir) .arg("--window-size") .arg(WINDOW_SIZE.to_string()) + .arg("--reference") + .arg(root.join("tests/data/dna/genome.fasta")) .arg("--quiet") .status() .expect("failed to run the rustqc binary"); @@ -469,3 +472,184 @@ fn insert_size_headline_figures_match_picard() { "the eleven percentile widths" ); } + +// =================================================================== +// Picard CollectWgsMetrics +// =================================================================== + +fn wgs_result() -> WgsMetricsResult { + let bam_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/dna/test.dna.bam"); + let mut reader = bam::Reader::from_path(&bam_path).unwrap(); + let header = reader.header().to_owned(); + let length = header.target_len(0).unwrap(); + + let mut accum = WgsAccum::new( + length, + wgs_metrics::DEFAULT_MIN_MAPPING_QUALITY, + wgs_metrics::DEFAULT_MIN_BASE_QUALITY, + ); + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + accum.process_read(&record); + } + let (counters, depths) = accum.into_parts(); + // The fixture reference carries no N bases, so the territory is its length. + WgsMetricsResult::new(&depths, counters, length, wgs_metrics::DEFAULT_COVERAGE_CAP) +} + +/// The exclusion breakdown is the heart of this tool: it is what separates +/// Picard's coverage from a plain depth count, and each fraction is a +/// different rule. They are pinned individually so a failure names the rule +/// that broke. +#[test] +fn wgs_exclusion_fractions_match_picard() { + let result = wgs_result(); + assert_eq!( + result.counters.total_aligned_bases, 670_989, + "the denominator is every reference-aligned base of every primary mapped record" + ); + let [dupe, mapq, unpaired, baseq, overlap, capped, total] = result.exclusion_fractions(); + let close = |got: f64, want: f64, what: &str| { + assert!((got - want).abs() < 1e-6, "{what}: got {got}, want {want}"); + }; + close(dupe, 0.299737, "PCT_EXC_DUPE"); + close(mapq, 0.0, "PCT_EXC_MAPQ"); + close(unpaired, 0.0, "PCT_EXC_UNPAIRED"); + close(baseq, 0.007352, "PCT_EXC_BASEQ"); + close(overlap, 0.324694, "PCT_EXC_OVERLAP"); + close(capped, 0.157699, "PCT_EXC_CAPPED"); + close(total, 0.789481, "PCT_EXC_TOTAL"); +} + +#[test] +fn wgs_headline_figures_match_picard() { + let result = wgs_result(); + assert_eq!(result.genome_territory, 40_001); + assert_eq!(result.median_coverage, 0); + assert_eq!(result.mad_coverage, 0); + assert!( + (result.mean_coverage - 3.531312).abs() < 1e-6, + "mean was {}", + result.mean_coverage + ); + assert!( + (result.sd_coverage - 27.339314).abs() < 1e-6, + "standard deviation was {}", + result.sd_coverage + ); + let f = result.coverage_fractions(); + assert!((f[0] - 0.029124).abs() < 1e-6, "PCT_1X was {}", f[0]); + assert!((f[13] - 0.01505).abs() < 1e-6, "PCT_100X was {}", f[13]); +} + +/// The whole file, except the five columns RustQC does not compute. +/// +/// `FOLD_80/90/95_BASE_PENALTY` are `?` in the fixture too, because Picard +/// could not compute them on this data. `HET_SNP_SENSITIVITY` and `HET_SNP_Q` +/// come from a Monte Carlo simulation that is out of scope, so RustQC writes +/// `?` where Picard writes a sampled value. Those two positions are the only +/// permitted difference. +#[test] +fn wgs_metrics_file_matches_picard_except_the_simulated_columns() { + let path = scratch("test.wgs_metrics.txt"); + wgs_metrics::write_wgs_metrics(&wgs_result(), &path).unwrap(); + let got = std::fs::read_to_string(&path).unwrap(); + let want = std::fs::read_to_string(fixture("test.wgs_metrics.txt")).unwrap(); + + let got_lines: Vec<&str> = got.lines().collect(); + let want_lines: Vec<&str> = want.lines().collect(); + assert_eq!( + got_lines.len(), + want_lines.len(), + "line count differs: {} versus {}", + got_lines.len(), + want_lines.len() + ); + + for (i, (a, b)) in got_lines.iter().zip(want_lines.iter()).enumerate() { + if i == 2 { + // The metrics row: compare every column but the last two. + let ours: Vec<&str> = a.split('\t').collect(); + let theirs: Vec<&str> = b.split('\t').collect(); + assert_eq!(ours.len(), theirs.len(), "column count differs"); + let simulated = ours.len() - 2; + for (col, (x, y)) in ours.iter().zip(theirs.iter()).enumerate() { + if col >= simulated { + continue; + } + assert_eq!(x, y, "column {col} of the metrics row differs"); + } + assert_eq!( + &ours[simulated..], + &["?", "?"], + "the simulated columns must be written as ?" + ); + } else { + assert_eq!(a, b, "line {} differs", i + 1); + } + } +} + +#[test] +fn binary_writes_insert_size_metrics_byte_for_byte() { + let got = std::fs::read_to_string(produced( + "picard/insert_size", + &format!("{SAMPLE}.insert_size_metrics.txt"), + )) + .unwrap(); + let want = std::fs::read_to_string(fixture("test.insert_size_metrics.txt")).unwrap(); + assert_same_lines(&got, &want, "insert size metrics from the binary"); +} + +/// As with the library-level check, the two Monte Carlo columns are the only +/// permitted difference. +#[test] +fn binary_writes_wgs_metrics_bar_the_simulated_columns() { + let got = std::fs::read_to_string(produced( + "picard/wgs_metrics", + &format!("{SAMPLE}.wgs_metrics.txt"), + )) + .unwrap(); + let want = std::fs::read_to_string(fixture("test.wgs_metrics.txt")).unwrap(); + + let got_lines: Vec<&str> = got.lines().collect(); + let want_lines: Vec<&str> = want.lines().collect(); + assert_eq!(got_lines.len(), want_lines.len(), "line count differs"); + for (i, (a, b)) in got_lines.iter().zip(want_lines.iter()).enumerate() { + if i == 2 { + let ours: Vec<&str> = a.split('\t').collect(); + let theirs: Vec<&str> = b.split('\t').collect(); + let simulated = ours.len() - 2; + assert_eq!(&ours[..simulated], &theirs[..simulated], "metrics row"); + } else { + assert_eq!(a, b, "line {} differs", i + 1); + } + } +} + +/// Without a reference there is no way to size the genome territory, so the +/// analysis is skipped rather than reported against a wrong denominator. +#[test] +fn wgs_metrics_are_skipped_without_a_reference() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let outdir = std::env::temp_dir().join("rustqc-dna-noref"); + let _ = std::fs::remove_dir_all(&outdir); + let status = std::process::Command::new(env!("CARGO_BIN_EXE_rustqc")) + .arg("dna") + .arg(root.join("tests/data/dna/test.dna.bam")) + .arg("--outdir") + .arg(&outdir) + .arg("--quiet") + .status() + .unwrap(); + assert!(status.success()); + assert!( + !outdir.join("picard/wgs_metrics").exists(), + "no reference means no WGS metrics" + ); + assert!( + outdir.join("picard/insert_size").exists(), + "insert size needs no reference and must still be written" + ); +} From 9cf0784667c0bce43979bddcc86e5baca62c145b Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 22:05:25 +0200 Subject: [PATCH 18/22] feat(dna): reimplement Picard CollectGcBiasMetrics Matches Picard 3.4.0 byte for byte: the 101-row detail table and the summary, AT_DROPOUT, GC_DROPOUT and the GC_NC columns included. An earlier attempt to infer the rules from Picard's output failed, so these come from its source, GcBiasUtils and GcBiasMetricsCollector. Three of them would not have been guessed: Windows slide over positions 1 to len - window_size - 1. Both ends are clipped, so a 40001 base reference gives 39900 windows of 100 bases rather than the 39902 a naive reading produces, and the GC value truncates rather than rounds. A read is assigned to the window at its alignment start, except on the reverse strand, where it goes to alignment_end - window_size. That is not the read's 5' end, and no offset applied to either end reproduces it. Only unmapped reads and reads with an empty sequence are skipped. Secondary and supplementary alignments count, which is the difference between 5640 and 5642 read starts here. Unmapped reads still count towards TOTAL_CLUSTERS even though they reach nothing else. Two further details came out of the fixture: GC_NC_x_y is a mean weighted by each bin's window count rather than a plain average over bins, and the GC tables carry two trailing blank lines where the other Picard tables carry one. Co-Authored-By: Claude Opus 5 (1M context) --- src/dna/gc_bias.rs | 514 ++++++++++++++++++ src/dna/mod.rs | 1 + tests/create_dna_test_data.sh | 12 +- tests/data/dna/test.dna.bam | Bin 193635 -> 193634 bytes tests/data/dna/test.dna.bam.bai | Bin 96 -> 96 bytes tests/dna_integration_test.rs | 67 +++ .../dna/test.gc_bias.detail_metrics.txt | 105 ++++ .../dna/test.gc_bias.summary_metrics.txt | 5 + 8 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 src/dna/gc_bias.rs create mode 100644 tests/expected/dna/test.gc_bias.detail_metrics.txt create mode 100644 tests/expected/dna/test.gc_bias.summary_metrics.txt diff --git a/src/dna/gc_bias.rs b/src/dna/gc_bias.rs new file mode 100644 index 00000000..bcaec0b8 --- /dev/null +++ b/src/dna/gc_bias.rs @@ -0,0 +1,514 @@ +//! Picard `CollectGcBiasMetrics` reimplementation. +//! +//! # Upstream semantics +//! +//! These rules come from Picard 3.4.0's own source, `GcBiasUtils` and +//! `GcBiasMetricsCollector`, after black-box inference from its output failed +//! to reproduce them. They are unusual enough to be worth stating. +//! +//! **Windows.** GC is computed over sliding windows of `window_size` bases at +//! every reference position `i` for `1 <= i < len - window_size`. Note both +//! bounds: the window at position 0 is skipped, and so is the last one that +//! would fit. On a 40001 base reference with 100 base windows that gives +//! 39900 windows, not the 39902 a naive reading produces. A window holding +//! more than [`MAX_NS_PER_WINDOW`] `N` bases is marked unusable and its reads +//! are dropped. The GC value is `gc_count * 100 / window_size` in integer +//! arithmetic, truncating rather than rounding. +//! +//! **Read assignment.** A read is assigned to the window at its alignment +//! start, except on the reverse strand, where it is assigned to +//! `alignment_end - window_size`. That is not the read's 5' end; it is the +//! window that would start where the read's far end finishes. Positions are +//! one-based, and a read landing at position 0 or lower is dropped. +//! +//! **Which reads count.** Only unmapped reads and reads with an empty +//! sequence are skipped. Secondary and supplementary alignments and +//! duplicates all contribute, which is what `READS_USED ALL` means. On the +//! project fixture that is the difference between 5640 and 5642 read starts. +//! +//! **Dropout.** For each GC bin, `(window_share - read_share) * 100` is +//! accumulated when positive, into `AT_DROPOUT` for bins at or below 50 and +//! `GC_DROPOUT` above. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bam; +use rust_htslib::bam::record::Cigar; + +use crate::common::bam_flags::*; + +/// Number of GC bins, one per whole percent from 0 to 100 inclusive. +pub const BINS: usize = 101; + +/// Picard's `SCAN_WINDOW_SIZE` default. +pub const DEFAULT_WINDOW_SIZE: usize = 100; + +/// A window holding more than this many `N` bases is unusable. +pub const MAX_NS_PER_WINDOW: usize = 4; + +/// Accumulates GC bias for one contig. +#[derive(Debug)] +pub struct GcBiasAccum { + /// GC percent per one-based reference position, or `-1` when the window + /// there holds too many `N` bases or does not exist. + gc: Vec, + window_size: usize, + windows_by_gc: [u64; BINS], + reads_by_gc: [u64; BINS], + bases_by_gc: [u64; BINS], + errors_by_gc: [u64; BINS], + total_clusters: u64, + total_aligned_reads: u64, +} + +impl GcBiasAccum { + /// Build the window GC table for one contig's reference bases. + pub fn new(reference: &[u8], window_size: usize) -> Self { + let len = reference.len(); + let mut gc = vec![-1i8; len + 1]; + let mut windows_by_gc = [0u64; BINS]; + + if len > window_size { + // Prefix sums make each window a constant-time lookup. + let mut gc_prefix = vec![0u32; len + 1]; + let mut n_prefix = vec![0u32; len + 1]; + for (i, base) in reference.iter().enumerate() { + let upper = base.to_ascii_uppercase(); + gc_prefix[i + 1] = gc_prefix[i] + u32::from(upper == b'G' || upper == b'C'); + n_prefix[i + 1] = n_prefix[i] + u32::from(upper == b'N'); + } + + let last_window_start = len - window_size; + for i in 1..last_window_start { + let end = i + window_size; + let ns = (n_prefix[end] - n_prefix[i]) as usize; + if ns > MAX_NS_PER_WINDOW { + continue; + } + let gc_count = gc_prefix[end] - gc_prefix[i]; + let percent = (gc_count as usize * 100 / window_size) as i8; + gc[i] = percent; + windows_by_gc[percent as usize] += 1; + } + } + + Self { + gc, + window_size, + windows_by_gc, + reads_by_gc: [0; BINS], + bases_by_gc: [0; BINS], + errors_by_gc: [0; BINS], + total_clusters: 0, + total_aligned_reads: 0, + } + } + + /// Offer one record, with the contig's reference bases for mismatch counting. + pub fn process_read(&mut self, record: &bam::Record, reference: &[u8]) { + if record.seq_len() == 0 { + return; + } + + // A cluster is a template, counted once, at the unpaired read or the + // first of the pair. Unmapped reads count towards clusters even though + // they reach nothing else, so this precedes the mapped check. + if record.flags() & BAM_FPAIRED == 0 || record.flags() & BAM_FREAD1 != 0 { + self.total_clusters += 1; + } + if record.flags() & BAM_FUNMAP != 0 { + return; + } + self.total_aligned_reads += 1; + + // One-based, and the reverse strand is assigned by the far end rather + // than the near one. + let position = if record.flags() & BAM_FREVERSE != 0 { + alignment_end(record) - self.window_size as i64 + } else { + record.pos() + 1 + }; + if position <= 0 { + return; + } + let Some(&percent) = self.gc.get(position as usize) else { + return; + }; + if percent < 0 { + return; + } + + let bin = percent as usize; + self.reads_by_gc[bin] += 1; + self.bases_by_gc[bin] += record.seq_len() as u64; + self.errors_by_gc[bin] += count_errors(record, reference); + } + + /// Fold another contig's counters in. Window tables are per contig and add + /// up the same way. + pub fn merge(&mut self, other: &GcBiasAccum) { + for bin in 0..BINS { + self.windows_by_gc[bin] += other.windows_by_gc[bin]; + self.reads_by_gc[bin] += other.reads_by_gc[bin]; + self.bases_by_gc[bin] += other.bases_by_gc[bin]; + self.errors_by_gc[bin] += other.errors_by_gc[bin]; + } + self.total_clusters += other.total_clusters; + self.total_aligned_reads += other.total_aligned_reads; + } + + /// Summarise into the reported detail rows and summary figures. + pub fn into_result(self, window_size: usize) -> GcBiasResult { + let total_reads: u64 = self.reads_by_gc.iter().sum(); + let total_windows: u64 = self.windows_by_gc.iter().sum(); + let global_rate = if total_windows == 0 { + 0.0 + } else { + total_reads as f64 / total_windows as f64 + }; + + let mut rows = Vec::with_capacity(BINS); + let mut at_dropout = 0.0; + let mut gc_dropout = 0.0; + + for bin in 0..BINS { + let windows = self.windows_by_gc[bin]; + let reads = self.reads_by_gc[bin]; + let bases = self.bases_by_gc[bin]; + let errors = self.errors_by_gc[bin]; + + let normalized = if windows == 0 || global_rate == 0.0 { + 0.0 + } else { + (reads as f64 / windows as f64) / global_rate + }; + let error_bar = if windows == 0 || global_rate == 0.0 { + 0.0 + } else { + ((reads as f64).sqrt() / windows as f64) / global_rate + }; + // Mean quality as the phred score of the observed error rate. + let mean_base_quality = if bases == 0 || errors == 0 { + 0 + } else { + (-10.0 * (errors as f64 / bases as f64).log10()).round() as i32 + }; + + if total_reads > 0 && total_windows > 0 { + let read_share = reads as f64 / total_reads as f64; + let window_share = windows as f64 / total_windows as f64; + let dropout = (window_share - read_share) * 100.0; + if dropout > 0.0 { + if bin <= 50 { + at_dropout += dropout; + } else { + gc_dropout += dropout; + } + } + } + + rows.push(GcBiasDetail { + gc: bin as u32, + windows, + read_starts: reads, + mean_base_quality, + normalized_coverage: normalized, + error_bar_width: error_bar, + }); + } + + GcBiasResult { + rows, + window_size, + total_clusters: self.total_clusters, + aligned_reads: self.total_aligned_reads, + at_dropout, + gc_dropout, + } + } +} + +/// One-based inclusive end of a record's alignment. +fn alignment_end(record: &bam::Record) -> i64 { + let mut end = record.pos(); + for op in record.cigar().iter() { + match op { + Cigar::Match(n) + | Cigar::Equal(n) + | Cigar::Diff(n) + | Cigar::Del(n) + | Cigar::RefSkip(n) => end += i64::from(*n), + _ => {} + } + } + end +} + +/// Mismatches against the reference, plus inserted and deleted bases, which is +/// what Picard counts towards the per-bin error rate. +fn count_errors(record: &bam::Record, reference: &[u8]) -> u64 { + let sequence = record.seq(); + let mut errors = 0u64; + let mut ref_pos = record.pos(); + let mut query_pos = 0i64; + + for op in record.cigar().iter() { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + for k in 0..i64::from(*n) { + let r = ref_pos + k; + let q = query_pos + k; + if r < 0 || r as usize >= reference.len() { + continue; + } + let ref_base = reference[r as usize].to_ascii_uppercase(); + let read_base = sequence[q as usize].to_ascii_uppercase(); + // htsjdk's basesEqual is a plain comparison after + // uppercasing, so an N on either side is a mismatch rather + // than a free pass. + if ref_base != read_base { + errors += 1; + } + } + ref_pos += i64::from(*n); + query_pos += i64::from(*n); + } + Cigar::Ins(n) => { + errors += u64::from(*n); + query_pos += i64::from(*n); + } + Cigar::Del(n) => { + errors += u64::from(*n); + ref_pos += i64::from(*n); + } + Cigar::RefSkip(n) => ref_pos += i64::from(*n), + Cigar::SoftClip(n) => query_pos += i64::from(*n), + Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + errors +} + +/// One GC bin's detail row. +#[derive(Debug, Clone)] +pub struct GcBiasDetail { + /// GC percent this row describes. + pub gc: u32, + /// Reference windows at this GC. + pub windows: u64, + /// Reads assigned to a window at this GC. + pub read_starts: u64, + /// Phred score of the observed error rate for those reads. + pub mean_base_quality: i32, + /// Read density here relative to the genome-wide density. + pub normalized_coverage: f64, + /// One standard error of `normalized_coverage`. + pub error_bar_width: f64, +} + +/// The complete GC bias result. +#[derive(Debug, Clone)] +pub struct GcBiasResult { + /// One row per GC bin, ascending. + pub rows: Vec, + /// Window size the bins were computed over. + pub window_size: usize, + /// Templates seen. + pub total_clusters: u64, + /// Mapped reads seen. + pub aligned_reads: u64, + /// Illumina-style AT dropout. + pub at_dropout: f64, + /// Illumina-style GC dropout. + pub gc_dropout: f64, +} + +impl GcBiasResult { + /// Mean normalised coverage across a GC range, as the `GC_NC_x_y` columns + /// report it. + /// + /// The mean is weighted by how many reference windows each bin holds, not + /// a plain average over bins. That distinction matters at the extremes, + /// where most bins hold no windows at all and would otherwise drag the + /// figure towards zero. + fn mean_normalized(&self, low: u32, high: u32) -> f64 { + let mut weighted = 0.0; + let mut windows = 0u64; + for row in self.rows.iter().filter(|r| r.gc >= low && r.gc <= high) { + weighted += row.normalized_coverage * row.windows as f64; + windows += row.windows; + } + if windows == 0 { + 0.0 + } else { + weighted / windows as f64 + } + } +} + +/// Format a float the way Picard's metrics writer does. +fn fmt_picard(value: f64) -> String { + if !value.is_finite() { + return "?".to_string(); + } + if value == value.trunc() && value.abs() < 1e15 { + return format!("{}", value as i64); + } + let text = format!("{value:.6}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// Write the per-GC-bin detail metrics. +pub fn write_detail_metrics(result: &GcBiasResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| { + format!( + "Failed to create GC bias detail metrics: {}", + path.display() + ) + })?; + + writeln!(out, "## METRICS CLASS\tpicard.analysis.GcBiasDetailMetrics")?; + writeln!( + out, + "ACCUMULATION_LEVEL\tREADS_USED\tGC\tWINDOWS\tREAD_STARTS\tMEAN_BASE_QUALITY\t\ + NORMALIZED_COVERAGE\tERROR_BAR_WIDTH\tSAMPLE\tLIBRARY\tREAD_GROUP" + )?; + for row in &result.rows { + writeln!( + out, + "All Reads\tALL\t{}\t{}\t{}\t{}\t{}\t{}\t\t\t", + row.gc, + row.windows, + row.read_starts, + row.mean_base_quality, + fmt_picard(row.normalized_coverage), + fmt_picard(row.error_bar_width), + )?; + } + // Picard leaves two blank lines at the end of the GC bias tables, one more + // than it writes after the insert size or WGS tables. The fixtures are the + // specification, so this matches them rather than being tidied. + writeln!(out)?; + writeln!(out)?; + out.flush()?; + Ok(()) +} + +/// Write the GC bias summary metrics. +pub fn write_summary_metrics(result: &GcBiasResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| { + format!( + "Failed to create GC bias summary metrics: {}", + path.display() + ) + })?; + + writeln!( + out, + "## METRICS CLASS\tpicard.analysis.GcBiasSummaryMetrics" + )?; + writeln!( + out, + "ACCUMULATION_LEVEL\tREADS_USED\tWINDOW_SIZE\tTOTAL_CLUSTERS\tALIGNED_READS\t\ + AT_DROPOUT\tGC_DROPOUT\tGC_NC_0_19\tGC_NC_20_39\tGC_NC_40_59\tGC_NC_60_79\t\ + GC_NC_80_100\tSAMPLE\tLIBRARY\tREAD_GROUP" + )?; + writeln!( + out, + "All Reads\tALL\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t\t\t", + result.window_size, + result.total_clusters, + result.aligned_reads, + fmt_picard(result.at_dropout), + fmt_picard(result.gc_dropout), + fmt_picard(result.mean_normalized(0, 19)), + fmt_picard(result.mean_normalized(20, 39)), + fmt_picard(result.mean_normalized(40, 59)), + fmt_picard(result.mean_normalized(60, 79)), + fmt_picard(result.mean_normalized(80, 100)), + )?; + writeln!(out)?; + writeln!(out)?; + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_and_last_possible_windows_are_both_skipped() { + // 10 bases, window size 4: naive windows would be starts 0 through 6, + // Picard's loop runs 1 through 5. + let reference = b"ACGTACGTAC".to_vec(); + let accum = GcBiasAccum::new(&reference, 4); + let windows: u64 = accum.windows_by_gc.iter().sum(); + assert_eq!(windows, 5, "starts 1 through 5 inclusive"); + assert_eq!(accum.gc[0], -1, "the window at 0 is never computed"); + assert_eq!(accum.gc[6], -1, "nor the last one that would fit"); + } + + #[test] + fn gc_percent_truncates_rather_than_rounds() { + // 3 of 8 bases are G or C: 3 * 100 / 8 is 37.5, truncated to 37. + let reference = b"GGCAAAAAAAAA".to_vec(); + let accum = GcBiasAccum::new(&reference, 8); + // Window at position 1 is GCAAAAAA, two of eight, 25 percent. + assert_eq!(accum.gc[1], 25); + } + + #[test] + fn windows_with_too_many_ns_are_unusable() { + let reference = b"ACGTNNNNNGCTAGCTAGC".to_vec(); + let accum = GcBiasAccum::new(&reference, 8); + // The window at 1 holds five Ns, one more than the limit. + assert_eq!(accum.gc[1], -1); + } + + #[test] + fn dropout_splits_at_fifty_percent_gc() { + let mut accum = GcBiasAccum::new(&b"A".repeat(200), 100); + // Hand-place windows and reads so the shares are unambiguous. + accum.windows_by_gc = [0; BINS]; + accum.reads_by_gc = [0; BINS]; + accum.windows_by_gc[30] = 50; + accum.windows_by_gc[70] = 50; + accum.reads_by_gc[30] = 100; + accum.reads_by_gc[70] = 0; + let result = accum.into_result(100); + // Bin 70 has half the windows and none of the reads: 50 points of + // dropout, and being above 50 percent GC it lands in GC_DROPOUT. + assert!( + (result.gc_dropout - 50.0).abs() < 1e-9, + "{}", + result.gc_dropout + ); + assert!( + (result.at_dropout - 0.0).abs() < 1e-9, + "{}", + result.at_dropout + ); + } + + #[test] + fn normalized_coverage_is_relative_to_the_genome_wide_rate() { + let mut accum = GcBiasAccum::new(&b"A".repeat(200), 100); + accum.windows_by_gc = [0; BINS]; + accum.reads_by_gc = [0; BINS]; + accum.windows_by_gc[10] = 100; + accum.windows_by_gc[20] = 100; + accum.reads_by_gc[10] = 150; + accum.reads_by_gc[20] = 50; + let result = accum.into_result(100); + // Global rate is 200 reads over 200 windows, so 1 read per window. + assert!((result.rows[10].normalized_coverage - 1.5).abs() < 1e-9); + assert!((result.rows[20].normalized_coverage - 0.5).abs() < 1e-9); + } +} diff --git a/src/dna/mod.rs b/src/dna/mod.rs index 505096d8..f55bf3ce 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -5,6 +5,7 @@ //! and preseq are shared with the RNA pipeline and live in [`crate::common`]. pub mod depth; +pub mod gc_bias; pub mod insert_size; pub mod mosdepth; pub mod wgs_metrics; diff --git a/tests/create_dna_test_data.sh b/tests/create_dna_test_data.sh index a0d6bef2..34003987 100755 --- a/tests/create_dna_test_data.sh +++ b/tests/create_dna_test_data.sh @@ -73,12 +73,22 @@ picard CollectInsertSizeMetrics \ -O "$expected/test.insert_size_metrics.txt" \ -H "$tmp/insert_size_histogram.pdf" +# The chart output needs R, so it goes to the scratch directory and is not +# compared against; only the two metrics tables are fixtures. +picard CollectGcBiasMetrics \ + -I "$data/test.dna.bam" \ + -O "$expected/test.gc_bias.detail_metrics.txt" \ + -S "$expected/test.gc_bias.summary_metrics.txt" \ + -CHART "$tmp/gc_bias.pdf" \ + -R "$data/genome.fasta" + # Picard stamps a start time and the full command line, absolute paths and all, # into the first four lines of every metrics file. Those are dropped: they would # change on every regeneration and say nothing about the numbers. The # "## METRICS CLASS" and "## HISTOGRAM" markers further down are part of the # format and are kept. -for f in "$expected/test.wgs_metrics.txt" "$expected/test.insert_size_metrics.txt"; do +for f in "$expected/test.wgs_metrics.txt" "$expected/test.insert_size_metrics.txt" \ + "$expected/test.gc_bias.detail_metrics.txt" "$expected/test.gc_bias.summary_metrics.txt"; do sed -e '/^## htsjdk\.samtools\.metrics\.StringHeader$/d' -e '/^# /d' "$f" \ | sed -e '/./,$!d' > "$f.tmp" && mv "$f.tmp" "$f" done diff --git a/tests/data/dna/test.dna.bam b/tests/data/dna/test.dna.bam index 97e73fbcdf278d27fed9c3a141f794f9a09dd862..b46c5da59a007798970d2935d2d00362771bf5a0 100644 GIT binary patch delta 451 zcmV;!0X+WW=nLZL3x6Mr2m}BC000301^_}s0syZ8&6M9x+b|TyjWkVipTaL-I}VIO zuEHOQO-r-10j?rVPUDnxu~Y0*X!|UC*vTE)5GvdN629^Av30)h=Og>{WVAQ_gi!qT z9ABrAV23#W5ly?!R*jLM25~;dc^XY`Y#8DsjShk!5QBJffq%c9M*?S8k-+&V0_7mZ z$>~FoB~jD1OWhbbh_mh4@?PRB-A>nO6tE!^Lu!RMI>Kj(PZO;)q2GxJ9tNQk5>GA) z@Ja=EsRDYf0(!9m9;p!uL>v=3A~R(=tynD`Na7Fv6wE5HYgXT~+2(I5e4Of?I;o-S zTIcQlkF(yoV1MC(x7=?L*I6Lc5ZV!bCoQkLwo=yd`JCSk_t(L#ISx0QYP|^-c2#~^ z-Esf)ajca3)n%3o2Ubb#i#?@c;wjg^co$sVth5A0sQxH$ z^=_>2IV>h#=-TUBHhovB{b#Adom5)dU)6F!X#QqkS1-;ND3zH@-55SuIvAhP%1G{? tkmHpE$@?X%jNF3kA%s37gti|CI{A&zA19EwI)kkNhphntx2*vJw(^wr*K_~? delta 452 zcmV;#0XzQU=nLcM3x6Mr2m}BC000301^_}s0syc9&6Lq@+aMH%sWeR@Pr(ZS#_g8Y zS8iIVo0fQvz2;)FG~pO$&J0fAq;2vnd)SGr-l{6yO;@Sha5$jz{~t#%I-eX(-yjr! zxWuz066`(BKS%SfvsGgxs6m`hah^o;?=}qaIEhYzAP|E%yMMwTFC&4|>qy{y5`l7% z;PL1_NXJprwj13TIf&E!+45H6G}%wHBnsG&i6ON@oSx!~u}>4NG@+k}2<``=8Huy2 z0=!ZIUaEjztAJjtfCp-X0ug6~PRK%;PAgVR2a@=M-vqM??3UGQw%Gkng%4A`Qztca zUF*Evzj4}I7k?}~@s|57;yMe28bUk5Z=~gQ*H+3pzFhK~;qf+Do3n7YtG2sfW$(&& z+uPD#Vz4m$3kSVokbM;+GfUewH;$E3KfBCk<-jVbeX++>Og!fL6CZ-Bn>#H*5vpGc zTzwcTd1+A~gSRU@+It7bulkO5GTqZ5&K5Xk{e# uPss5~g5>>@RYvYXju1j`5kmWq1D*dw=og>?fP#ap0f(#s0k^CH0=Du~0@tko diff --git a/tests/data/dna/test.dna.bam.bai b/tests/data/dna/test.dna.bam.bai index 45f9a89784d104874e1714db2c5b37ef2068bde2..ce91243cb9ad66d3534abb14be0c5ed784918f80 100644 GIT binary patch literal 96 zcmZ>A^kigYU|?VZVoxCk1`wNpVFQ@bx+iiGBA^kigYU|?VZVoxCk1`wNpVI!E*x+i)OB gc_bias::GcBiasResult { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let reference: Vec = { + let text = std::fs::read_to_string(root.join("tests/data/dna/genome.fasta")).unwrap(); + text.lines() + .filter(|l| !l.starts_with('>')) + .flat_map(|l| l.bytes()) + .collect() + }; + + let mut accum = GcBiasAccum::new(&reference, gc_bias::DEFAULT_WINDOW_SIZE); + let mut reader = bam::Reader::from_path(root.join("tests/data/dna/test.dna.bam")).unwrap(); + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + accum.process_read(&record, &reference); + } + accum.into_result(gc_bias::DEFAULT_WINDOW_SIZE) +} + +/// The window table and the read assignment are the two rules that black-box +/// inference could not recover, so they are pinned before anything derived +/// from them. +#[test] +fn gc_bias_windows_and_read_starts_match_picard() { + let result = gc_bias_result(); + let windows: u64 = result.rows.iter().map(|r| r.windows).sum(); + let read_starts: u64 = result.rows.iter().map(|r| r.read_starts).sum(); + assert_eq!( + windows, 39_900, + "sliding windows run from position 1 to len - window_size - 1" + ); + assert_eq!( + read_starts, 5_642, + "secondary alignments count towards read starts" + ); + assert_eq!(result.total_clusters, 2_822); + assert_eq!(result.aligned_reads, 5_642); +} + +#[test] +fn gc_bias_detail_metrics_match_picard() { + let path = scratch("test.gc_bias.detail_metrics.txt"); + gc_bias::write_detail_metrics(&gc_bias_result(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.gc_bias.detail_metrics.txt")).unwrap(), + "GC bias detail metrics", + ); +} + +#[test] +fn gc_bias_summary_metrics_match_picard() { + let path = scratch("test.gc_bias.summary_metrics.txt"); + gc_bias::write_summary_metrics(&gc_bias_result(), &path).unwrap(); + assert_same_lines( + &std::fs::read_to_string(&path).unwrap(), + &std::fs::read_to_string(fixture("test.gc_bias.summary_metrics.txt")).unwrap(), + "GC bias summary metrics", + ); +} diff --git a/tests/expected/dna/test.gc_bias.detail_metrics.txt b/tests/expected/dna/test.gc_bias.detail_metrics.txt new file mode 100644 index 00000000..e6da30c8 --- /dev/null +++ b/tests/expected/dna/test.gc_bias.detail_metrics.txt @@ -0,0 +1,105 @@ +## METRICS CLASS picard.analysis.GcBiasDetailMetrics +ACCUMULATION_LEVEL READS_USED GC WINDOWS READ_STARTS MEAN_BASE_QUALITY NORMALIZED_COVERAGE ERROR_BAR_WIDTH SAMPLE LIBRARY READ_GROUP +All Reads ALL 0 0 0 0 0 0 +All Reads ALL 1 0 0 0 0 0 +All Reads ALL 2 0 0 0 0 0 +All Reads ALL 3 0 0 0 0 0 +All Reads ALL 4 0 0 0 0 0 +All Reads ALL 5 0 0 0 0 0 +All Reads ALL 6 0 0 0 0 0 +All Reads ALL 7 0 0 0 0 0 +All Reads ALL 8 0 0 0 0 0 +All Reads ALL 9 0 0 0 0 0 +All Reads ALL 10 0 0 0 0 0 +All Reads ALL 11 0 0 0 0 0 +All Reads ALL 12 0 0 0 0 0 +All Reads ALL 13 0 0 0 0 0 +All Reads ALL 14 6 0 0 0 0 +All Reads ALL 15 8 0 0 0 0 +All Reads ALL 16 10 0 0 0 0 +All Reads ALL 17 38 0 0 0 0 +All Reads ALL 18 69 0 0 0 0 +All Reads ALL 19 106 73 27 4.870312 0.570027 +All Reads ALL 20 157 80 26 3.603547 0.402889 +All Reads ALL 21 223 179 27 5.676596 0.424289 +All Reads ALL 22 319 71 27 1.57401 0.186801 +All Reads ALL 23 397 43 30 0.765981 0.116811 +All Reads ALL 24 429 96 28 1.582537 0.161517 +All Reads ALL 25 493 76 31 1.090201 0.125055 +All Reads ALL 26 741 85 25 0.811224 0.08799 +All Reads ALL 27 1014 243 26 1.69476 0.108719 +All Reads ALL 28 1015 485 28 3.379213 0.153442 +All Reads ALL 29 1079 841 26 5.512065 0.190071 +All Reads ALL 30 1048 766 27 5.169009 0.186764 +All Reads ALL 31 1142 486 26 3.009608 0.136519 +All Reads ALL 32 1223 353 26 2.041212 0.108643 +All Reads ALL 33 1119 339 28 2.142444 0.116362 +All Reads ALL 34 1281 327 28 1.805255 0.099831 +All Reads ALL 35 1194 239 30 1.415577 0.091566 +All Reads ALL 36 1191 376 28 2.232626 0.115139 +All Reads ALL 37 1171 85 24 0.513336 0.055679 +All Reads ALL 38 1135 87 28 0.54208 0.058117 +All Reads ALL 39 1140 95 26 0.58933 0.060464 +All Reads ALL 40 1166 39 26 0.236541 0.037877 +All Reads ALL 41 1070 72 29 0.47587 0.056082 +All Reads ALL 42 973 28 20 0.20351 0.03846 +All Reads ALL 43 1040 11 0 0.0748 0.022553 +All Reads ALL 44 1078 17 25 0.111524 0.027049 +All Reads ALL 45 924 23 21 0.176034 0.036706 +All Reads ALL 46 1022 8 0 0.055358 0.019572 +All Reads ALL 47 932 16 31 0.121407 0.030352 +All Reads ALL 48 906 3 0 0.023417 0.01352 +All Reads ALL 49 1068 0 0 0 0 +All Reads ALL 50 1027 0 0 0 0 +All Reads ALL 51 1081 0 0 0 0 +All Reads ALL 52 915 0 0 0 0 +All Reads ALL 53 816 0 0 0 0 +All Reads ALL 54 754 0 0 0 0 +All Reads ALL 55 751 0 0 0 0 +All Reads ALL 56 783 0 0 0 0 +All Reads ALL 57 721 0 0 0 0 +All Reads ALL 58 574 0 0 0 0 +All Reads ALL 59 563 0 0 0 0 +All Reads ALL 60 494 0 0 0 0 +All Reads ALL 61 331 0 0 0 0 +All Reads ALL 62 246 0 0 0 0 +All Reads ALL 63 247 0 0 0 0 +All Reads ALL 64 233 0 0 0 0 +All Reads ALL 65 217 0 0 0 0 +All Reads ALL 66 203 0 0 0 0 +All Reads ALL 67 181 0 0 0 0 +All Reads ALL 68 125 0 0 0 0 +All Reads ALL 69 103 0 0 0 0 +All Reads ALL 70 140 0 0 0 0 +All Reads ALL 71 137 0 0 0 0 +All Reads ALL 72 124 0 0 0 0 +All Reads ALL 73 119 0 0 0 0 +All Reads ALL 74 105 0 0 0 0 +All Reads ALL 75 111 0 0 0 0 +All Reads ALL 76 85 0 0 0 0 +All Reads ALL 77 71 0 0 0 0 +All Reads ALL 78 89 0 0 0 0 +All Reads ALL 79 96 0 0 0 0 +All Reads ALL 80 78 0 0 0 0 +All Reads ALL 81 50 0 0 0 0 +All Reads ALL 82 73 0 0 0 0 +All Reads ALL 83 65 0 0 0 0 +All Reads ALL 84 34 0 0 0 0 +All Reads ALL 85 39 0 0 0 0 +All Reads ALL 86 22 0 0 0 0 +All Reads ALL 87 28 0 0 0 0 +All Reads ALL 88 28 0 0 0 0 +All Reads ALL 89 30 0 0 0 0 +All Reads ALL 90 51 0 0 0 0 +All Reads ALL 91 26 0 0 0 0 +All Reads ALL 92 7 0 0 0 0 +All Reads ALL 93 0 0 0 0 0 +All Reads ALL 94 0 0 0 0 0 +All Reads ALL 95 0 0 0 0 0 +All Reads ALL 96 0 0 0 0 0 +All Reads ALL 97 0 0 0 0 0 +All Reads ALL 98 0 0 0 0 0 +All Reads ALL 99 0 0 0 0 0 +All Reads ALL 100 0 0 0 0 0 + + diff --git a/tests/expected/dna/test.gc_bias.summary_metrics.txt b/tests/expected/dna/test.gc_bias.summary_metrics.txt new file mode 100644 index 00000000..8a21261c --- /dev/null +++ b/tests/expected/dna/test.gc_bias.summary_metrics.txt @@ -0,0 +1,5 @@ +## METRICS CLASS picard.analysis.GcBiasSummaryMetrics +ACCUMULATION_LEVEL READS_USED WINDOW_SIZE TOTAL_CLUSTERS ALIGNED_READS AT_DROPOUT GC_DROPOUT GC_NC_0_19 GC_NC_20_39 GC_NC_40_59 GC_NC_60_79 GC_NC_80_100 SAMPLE LIBRARY READ_GROUP +All Reads ALL 100 2822 5642 29.055038 27.433584 2.178283 2.161449 0.084487 0 0 + + From d5d4d89f96854ff2d2e2222cb78d374ca8b48036 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 22:22:45 +0200 Subject: [PATCH 19/22] feat(dna): add targeted mode and Picard CollectHsMetrics --targets switches the run into targeted mode and produces hs_metrics.txt; --baits defaults to the same intervals. BED input is parsed and merged, since overlapping targets would otherwise inflate the territory and double-count on-target bases. All 58 computable columns match Picard 3.4.0 exactly, including HS_LIBRARY_SIZE, which solves the Lander-Waterman equation by bisection the way Picard's own estimator does. The reason an earlier attempt missed by 0.8 percent is that HsMetrics does not filter the way CollectWgsMetrics does. It clips overlapping mates first, at the read level, and only then applies the base quality floor; WgsMetrics does the opposite. That is why the two report different PCT_EXC_BASEQ and PCT_EXC_OVERLAP on the same file. Two further details came from htsjdk: only the left-most mate is clipped, losing everything from its mate's start onwards, and htsjdk's MATCH_OR_MISMATCH is the M operator alone, so = and X lose their whole element rather than a partial one. Unmapped records reach no contig worker but still count towards TOTAL_READS, PF_BASES and the cluster count, so they are fed to both accumulators during the unmapped pass. Seven columns are not computed: HET_SNP_SENSITIVITY, HET_SNP_Q, the six HS_PENALTY levels and FOLD_80_BASE_PENALTY all derive from Picard's Monte Carlo theoretical sensitivity, and AT_DROPOUT and GC_DROPOUT from a per-target GC binning not implemented here. Each is written the way Picard writes a value it cannot compute. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 + CHANGELOG.md | 6 +- src/config.rs | 68 ++ src/dna/hs_metrics.rs | 677 +++++++++++++++++++ src/dna/intervals.rs | 262 ++++++++ src/dna/mod.rs | 2 + src/main.rs | 169 ++++- tests/create_dna_test_data.sh | 20 +- tests/data/dna/targets.bed | 2 + tests/data/dna/test.dna.bam | Bin 193634 -> 193636 bytes tests/data/dna/test.dna.bam.bai | Bin 96 -> 96 bytes tests/dna_integration_test.rs | 266 ++++++++ tests/expected/dna/test.hs_metrics.txt | 870 +++++++++++++++++++++++++ 13 files changed, 2340 insertions(+), 5 deletions(-) create mode 100644 src/dna/hs_metrics.rs create mode 100644 src/dna/intervals.rs create mode 100644 tests/data/dna/targets.bed create mode 100644 tests/expected/dna/test.hs_metrics.txt diff --git a/AGENTS.md b/AGENTS.md index 266ef554..9efe8343 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,10 @@ src/ mod.rs — Re-exports the DNA submodules depth.rs — Per-contig depth accumulator (delta array, CIGAR walk, mate-overlap correction, prefix sum) + gc_bias.rs — Picard CollectGcBiasMetrics reimplementation + hs_metrics.rs — Picard CollectHsMetrics reimplementation (targeted mode) insert_size.rs — Picard CollectInsertSizeMetrics reimplementation + intervals.rs — BED interval parsing and merging for targeted mode wgs_metrics.rs — Picard CollectWgsMetrics reimplementation mosdepth/ mod.rs — Per-contig summarisation feeding the mosdepth outputs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49390634..472def88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ with mosdepth-compatible outputs, samtools-compatible stats, flagstat and idxstats, and preseq library complexity, all in a single pass over the alignment with one worker per contig, plus Picard-compatible - CollectWgsMetrics and CollectInsertSizeMetrics. Validated for exact parity - against mosdepth 0.3.14, samtools 1.24 and Picard 3.4.0. + CollectWgsMetrics, CollectInsertSizeMetrics and CollectGcBiasMetrics. + Passing `--targets` switches on targeted mode and Picard-compatible + CollectHsMetrics. Validated for exact parity against mosdepth 0.3.14, + samtools 1.24 and Picard 3.4.0. ### Changed diff --git a/src/config.rs b/src/config.rs index 01776188..365e1b6b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -980,6 +980,14 @@ pub struct DnaConfig { #[serde(default)] pub insert_size: InsertSizeConfig, + /// Picard CollectGcBiasMetrics configuration. + #[serde(default)] + pub gc_bias: GcBiasConfig, + + /// Picard CollectHsMetrics configuration, used in targeted mode. + #[serde(default)] + pub hs_metrics: HsMetricsConfig, + /// preseq lc_extrap library complexity extrapolation configuration. /// /// Reuses the same type as the `rna` pipeline; the implementation is shared. @@ -1024,6 +1032,66 @@ impl Default for WgsMetricsConfig { } } +/// Configuration for the Picard-compatible GC bias metrics. +/// +/// Requires a reference FASTA: the analysis bins reference windows by GC. +/// +/// Example: +/// ```yaml +/// gc_bias: +/// enabled: true +/// window_size: 100 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct GcBiasConfig { + /// Whether to compute GC bias metrics. Defaults to true. + pub enabled: bool, + /// Width of the sliding reference windows GC is computed over. + pub window_size: usize, +} + +impl Default for GcBiasConfig { + fn default() -> Self { + Self { + enabled: true, + window_size: 100, + } + } +} + +/// Configuration for the Picard-compatible targeted sequencing metrics. +/// +/// Only takes effect when `--targets` is given. +/// +/// Example: +/// ```yaml +/// hs_metrics: +/// enabled: true +/// min_base_quality: 20 +/// min_mapping_quality: 20 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct HsMetricsConfig { + /// Whether to compute targeted metrics. Defaults to true. + pub enabled: bool, + /// Bases below this quality are excluded. + pub min_base_quality: u8, + /// Reads below this mapping quality are excluded. + pub min_mapping_quality: u8, +} + +impl Default for HsMetricsConfig { + fn default() -> Self { + Self { + enabled: true, + min_base_quality: 20, + min_mapping_quality: 20, + } + } +} + /// Configuration for the Picard-compatible insert size metrics. /// /// Example: diff --git a/src/dna/hs_metrics.rs b/src/dna/hs_metrics.rs new file mode 100644 index 00000000..ba82e745 --- /dev/null +++ b/src/dna/hs_metrics.rs @@ -0,0 +1,677 @@ +//! Picard `CollectHsMetrics` reimplementation for targeted sequencing. +//! +//! # Upstream semantics +//! +//! Taken from Picard 3.4.0's `TargetMetricsCollector`, because this collector +//! does not filter the way [`crate::dna::wgs_metrics`] does and the difference +//! is not guessable from the outputs. +//! +//! Secondary alignments are excluded outright, so `TOTAL_READS` is 5642 on the +//! project fixture rather than the 5644 records it holds. Then, per record: +//! +//! 1. `PF_BASES` accumulates the read length of every non-supplementary read; +//! 2. mapped reads add their reference-aligned bases to `PF_BASES_ALIGNED`, +//! and to `PF_UQ_BASES_ALIGNED` when not duplicate-flagged; +//! 3. the bait counters are taken **before** any filtering, so the assay +//! metrics are not skewed by duplicates or mapping quality; +//! 4. duplicates are charged to `PCT_EXC_DUPE` and dropped; +//! 5. reads below the mapping quality floor are dropped; +//! 6. **overlap clipping happens next, at the read level**, charging +//! `PCT_EXC_OVERLAP` with the number of aligned bases clipped; +//! 7. only then, per surviving base: below the base quality floor charges +//! `PCT_EXC_BASEQ`; off-target charges `PCT_EXC_OFF_TARGET`; the rest are +//! `ON_TARGET_BASES`. +//! +//! Step 6 before step 7 is the crux. `CollectWgsMetrics` applies base quality +//! first and reconciles overlaps per locus afterwards, which is why the two +//! collectors report different `PCT_EXC_BASEQ` and `PCT_EXC_OVERLAP` on the +//! same file: 0.003982 against 0.007352, and 0.330968 against 0.324694. +//! +//! Only the left-most mate of an overlapping pair is clipped, and everything +//! from the mate's alignment start onwards goes, per htsjdk's +//! `getNumOverlappingAlignedBasesToClip`. +//! +//! # What is not reproduced +//! +//! `HET_SNP_SENSITIVITY` and `HET_SNP_Q` come from the same Monte Carlo +//! simulation left out of `CollectWgsMetrics`, and `HS_PENALTY_*X` and +//! `FOLD_80_BASE_PENALTY` derive from it. All are written as Picard writes +//! them when it cannot compute them: `-1` for the penalties, `?` for the rest. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bam; +use rust_htslib::bam::record::Cigar; + +use crate::common::bam_flags::*; +use crate::dna::intervals::IntervalSet; + +/// Coverage levels reported as `PCT_TARGET_BASES_xX`, in output order. +pub const TARGET_COVERAGE_LEVELS: [u32; 17] = [ + 1, 2, 10, 20, 30, 40, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, +]; + +/// Penalty levels reported as `HS_PENALTY_xX`, in output order. +pub const PENALTY_LEVELS: [u32; 6] = [10, 20, 30, 40, 50, 100]; + +/// Accumulates targeted-sequencing metrics for one contig. +#[derive(Debug)] +pub struct HsAccum { + bait_mask: Vec, + target_mask: Vec, + /// High quality on-target depth per reference base. + depth: Vec, + min_mapping_quality: u8, + min_base_quality: u8, + counters: HsCounters, +} + +/// The raw counters, summed across contigs. +#[derive(Debug, Clone, Default)] +pub struct HsCounters { + /// Records seen, secondary alignments excluded. + pub total_reads: u64, + /// Read length of every non-supplementary record. + pub pf_bases: u64, + /// Reference-aligned bases of mapped records. + pub pf_bases_aligned: u64, + /// Reference-aligned bases of mapped, non-duplicate records. + pub pf_uq_bases_aligned: u64, + /// Non-duplicate records. + pub pf_unique_reads: u64, + /// Non-duplicate mapped records. + pub pf_uq_reads_aligned: u64, + /// Aligned bases falling on a bait. + pub on_bait_bases: u64, + /// Aligned bases of bait-overlapping reads that miss the baits themselves. + pub near_bait_bases: u64, + /// Aligned bases of reads that touch no bait at all. + pub off_bait_bases: u64, + /// Bases dropped because their read was duplicate-flagged. + pub excluded_dupe: u64, + /// Bases clipped as overlapping a mate. + pub excluded_overlap: u64, + /// Bases dropped for low base quality. + pub excluded_baseq: u64, + /// Bases dropped for falling outside the targets. + pub excluded_off_target: u64, + /// High quality bases on target. + pub on_target_bases: u64, + /// First-of-pair records over a bait, with a mapped mate. + pub selected_pairs: u64, + /// The same, excluding duplicates. + pub selected_unique_pairs: u64, +} + +impl HsCounters { + /// Add another contig's counters. + pub fn merge(&mut self, other: &HsCounters) { + self.total_reads += other.total_reads; + self.pf_bases += other.pf_bases; + self.pf_bases_aligned += other.pf_bases_aligned; + self.pf_uq_bases_aligned += other.pf_uq_bases_aligned; + self.pf_unique_reads += other.pf_unique_reads; + self.pf_uq_reads_aligned += other.pf_uq_reads_aligned; + self.on_bait_bases += other.on_bait_bases; + self.near_bait_bases += other.near_bait_bases; + self.off_bait_bases += other.off_bait_bases; + self.excluded_dupe += other.excluded_dupe; + self.excluded_overlap += other.excluded_overlap; + self.excluded_baseq += other.excluded_baseq; + self.excluded_off_target += other.excluded_off_target; + self.on_target_bases += other.on_target_bases; + self.selected_pairs += other.selected_pairs; + self.selected_unique_pairs += other.selected_unique_pairs; + } +} + +impl HsAccum { + /// Prepare for one contig. + pub fn new( + contig: &str, + length: u64, + baits: &IntervalSet, + targets: &IntervalSet, + min_mapping_quality: u8, + min_base_quality: u8, + ) -> Self { + Self { + bait_mask: baits.mask(contig, length), + target_mask: targets.mask(contig, length), + depth: vec![0; length as usize], + min_mapping_quality, + min_base_quality, + counters: HsCounters::default(), + } + } + + /// Offer one record. + pub fn process_read(&mut self, record: &bam::Record) { + let flags = record.flags(); + // Secondary alignments are not part of this collector's read set. + if flags & BAM_FSECONDARY != 0 || flags & BAM_FQCFAIL != 0 { + return; + } + self.counters.total_reads += 1; + + if flags & BAM_FSUPPLEMENTARY == 0 { + self.counters.pf_bases += record.seq_len() as u64; + } + if flags & BAM_FDUP == 0 { + self.counters.pf_unique_reads += 1; + } + if flags & BAM_FUNMAP != 0 { + return; + } + + let blocks = aligned_blocks(record, self.depth.len()); + let aligned: u64 = blocks.iter().map(|(start, end)| end - start).sum(); + self.counters.pf_bases_aligned += aligned; + if flags & BAM_FDUP == 0 { + self.counters.pf_uq_bases_aligned += aligned; + self.counters.pf_uq_reads_aligned += 1; + } + + // Bait metrics come before duplicate, mapping quality and overlap + // filtering, so that the assay is measured rather than the library. + let on_bait: u64 = blocks + .iter() + .map(|(start, end)| { + (*start..*end) + .filter(|p| self.bait_mask.get(*p as usize).copied().unwrap_or(false)) + .count() as u64 + }) + .sum(); + if on_bait > 0 { + self.counters.on_bait_bases += on_bait; + self.counters.near_bait_bases += aligned - on_bait; + } else { + self.counters.off_bait_bases += aligned; + } + + // HS_LIBRARY_SIZE counts templates over a bait, once each. + if flags & BAM_FSUPPLEMENTARY == 0 + && flags & BAM_FPAIRED != 0 + && flags & BAM_FREAD1 != 0 + && flags & BAM_FMUNMAP == 0 + && on_bait > 0 + { + self.counters.selected_pairs += 1; + if flags & BAM_FDUP == 0 { + self.counters.selected_unique_pairs += 1; + } + } + + if flags & BAM_FDUP != 0 { + self.counters.excluded_dupe += aligned; + return; + } + if record.mapq() < self.min_mapping_quality { + return; + } + + // Overlap clipping, at the read level and before any base is examined. + // + // Two different quantities are at play. The counter Picard reports is + // htsjdk's count of *read* bases clipped, insertions included. What is + // actually removed from the alignment is every base at or past the + // mate's start, in *reference* coordinates. They coincide only for a + // gapless read, so they are tracked separately. + self.counters.excluded_overlap += overlapping_bases_to_clip(record); + let clip_from = overlap_clip_reference_start(record); + + let qualities = record.qual(); + let mut ref_pos = record.pos(); + let mut query_pos = 0i64; + for op in record.cigar().iter() { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + for k in 0..i64::from(*n) { + let r = ref_pos + k; + if r < 0 || r as usize >= self.depth.len() { + continue; + } + if let Some(from) = clip_from { + if r >= from { + continue; + } + } + let quality = qualities + .get((query_pos + k) as usize) + .copied() + .unwrap_or(0); + if quality < self.min_base_quality { + self.counters.excluded_baseq += 1; + } else if !self.target_mask[r as usize] { + self.counters.excluded_off_target += 1; + } else { + self.counters.on_target_bases += 1; + self.depth[r as usize] += 1; + } + } + ref_pos += i64::from(*n); + query_pos += i64::from(*n); + } + Cigar::Del(n) | Cigar::RefSkip(n) => ref_pos += i64::from(*n), + Cigar::Ins(n) | Cigar::SoftClip(n) => query_pos += i64::from(*n), + Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + } + + /// Consume the accumulator, returning its counters and per-base depths. + pub fn into_parts(self) -> (HsCounters, Vec, Vec) { + (self.counters, self.depth, self.target_mask) + } +} + +/// A record's reference-covering blocks as half-open `[start, end)`. +fn aligned_blocks(record: &bam::Record, contig_len: usize) -> Vec<(u64, u64)> { + let mut blocks = Vec::new(); + let mut ref_pos = record.pos(); + for op in record.cigar().iter() { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + let start = ref_pos.max(0) as u64; + let end = ((ref_pos + i64::from(*n)).max(0) as u64).min(contig_len as u64); + if start < end { + blocks.push((start, end)); + } + ref_pos += i64::from(*n); + } + Cigar::Del(n) | Cigar::RefSkip(n) => ref_pos += i64::from(*n), + _ => {} + } + } + blocks +} + +/// The reference position from which this read's alignment is clipped away +/// because its mate covers it, or `None` when nothing is clipped. +/// +/// This is the mate's alignment start: the left-most read of an overlapping +/// pair loses everything from there onwards. +fn overlap_clip_reference_start(record: &bam::Record) -> Option { + if overlapping_bases_to_clip(record) == 0 { + return None; + } + Some(record.mpos()) +} + +/// Read bases to clip because a mate covers them, per htsjdk's +/// `getNumOverlappingAlignedBasesToClip`. +/// +/// Only the left-most mate of the pair is clipped, and everything from the +/// mate's alignment start onwards goes. A pair sharing a start is broken by +/// clipping the second of the pair. +fn overlapping_bases_to_clip(record: &bam::Record) -> u64 { + let flags = record.flags(); + if flags & BAM_FPAIRED == 0 || flags & BAM_FUNMAP != 0 || flags & BAM_FMUNMAP != 0 { + return 0; + } + let start = record.pos(); + let mate_start = record.mpos(); + if mate_start < start { + return 0; + } + if mate_start == start && flags & BAM_FREAD1 != 0 { + return 0; + } + + let mut clipped: i64 = 0; + let mut ref_pos = start; + for op in record.cigar().iter() { + let ref_len = match op { + Cigar::Match(n) + | Cigar::Equal(n) + | Cigar::Diff(n) + | Cigar::Del(n) + | Cigar::RefSkip(n) => i64::from(*n), + _ => 0, + }; + if mate_start < ref_pos + ref_len { + match op { + // Only M takes the partial path: htsjdk's MATCH_OR_MISMATCH is + // the M operator alone, so = and X fall through to the branch + // below and lose their whole element. + Cigar::Match(_) => { + clipped += if mate_start < ref_pos { + ref_len + } else { + ref_pos + ref_len - mate_start + }; + } + Cigar::SoftClip(_) | Cigar::HardClip(_) | Cigar::Pad(_) | Cigar::RefSkip(_) => {} + // Everything else loses its read-consuming bases outright, + // which covers insertions as well as = and X. + Cigar::Equal(n) | Cigar::Diff(n) | Cigar::Ins(n) => clipped += i64::from(*n), + Cigar::Del(_) => {} + } + } + ref_pos += ref_len; + } + // Left-most but not actually overlapping. + clipped.max(0) as u64 +} + +/// Estimate library size from observed and unique templates. +/// +/// Solves the Lander-Waterman equation `C/X = 1 - exp(-N/X)` by bisection, +/// exactly as Picard's `DuplicationMetrics.estimateLibrarySize` does, down to +/// the forty iterations and the starting bracket. +pub fn estimate_library_size(read_pairs: u64, unique_read_pairs: u64) -> Option { + if read_pairs == 0 || read_pairs <= unique_read_pairs || unique_read_pairs == 0 { + return None; + } + let n = read_pairs as f64; + let c = unique_read_pairs as f64; + let f = |x: f64| c / x - 1.0 + (-n / x).exp(); + + let mut low = 1.0; + let mut high = 100.0; + while f(high * c) > 0.0 { + high *= 10.0; + } + for _ in 0..40 { + let mid = (low + high) / 2.0; + let value = f(mid * c); + if value == 0.0 { + break; + } else if value > 0.0 { + low = mid; + } else { + high = mid; + } + } + Some((c * (low + high) / 2.0) as u64) +} + +/// The computed `CollectHsMetrics` figures. +#[derive(Debug, Clone)] +pub struct HsMetricsResult { + /// Name of the bait set. + pub bait_set: String, + /// Bases covered by baits. + pub bait_territory: u64, + /// Bases covered by targets. + pub target_territory: u64, + /// Total reference length. + pub genome_size: u64, + /// Raw counters. + pub counters: HsCounters, + /// High quality depth of every target base, target order. + pub target_depths: Vec, + /// Number of targets with no coverage at all. + pub zero_coverage_targets: u64, + /// Number of targets. + pub target_count: u64, + /// Estimated library size, when it can be estimated. + pub library_size: Option, +} + +impl HsMetricsResult { + /// Mean high quality coverage over the targets. + pub fn mean_target_coverage(&self) -> f64 { + if self.target_territory == 0 { + 0.0 + } else { + self.counters.on_target_bases as f64 / self.target_territory as f64 + } + } + + /// Mean aligned coverage over the baits. + pub fn mean_bait_coverage(&self) -> f64 { + if self.bait_territory == 0 { + 0.0 + } else { + self.counters.pf_bases_aligned as f64 / self.bait_territory as f64 + } + } + + /// Fraction of the targets at or above each level. + pub fn target_coverage_fractions(&self) -> Vec { + TARGET_COVERAGE_LEVELS + .iter() + .map(|level| { + if self.target_territory == 0 { + return 0.0; + } + let at_or_above = self.target_depths.iter().filter(|d| **d >= *level).count(); + at_or_above as f64 / self.target_territory as f64 + }) + .collect() + } + + /// Median, minimum and maximum high quality target coverage. + pub fn target_coverage_bounds(&self) -> (u32, u32, u32) { + if self.target_depths.is_empty() { + return (0, 0, 0); + } + let mut sorted = self.target_depths.clone(); + sorted.sort_unstable(); + let median = sorted[sorted.len() / 2]; + (median, sorted[0], sorted[sorted.len() - 1]) + } +} + +/// Format a float the way Picard's metrics writer does. +fn fmt_picard(value: f64) -> String { + if !value.is_finite() { + return "?".to_string(); + } + if value == value.trunc() && value.abs() < 1e15 { + return format!("{}", value as i64); + } + let text = format!("{value:.6}"); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// Write a Picard-compatible `hs_metrics.txt`. +pub fn write_hs_metrics(result: &HsMetricsResult, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create HS metrics: {}", path.display()))?; + + let c = &result.counters; + let aligned = c.pf_bases_aligned as f64; + let frac = |n: u64| { + if aligned == 0.0 { + 0.0 + } else { + n as f64 / aligned + } + }; + let selected = c.on_bait_bases + c.near_bait_bases; + let (median, min, max) = result.target_coverage_bounds(); + + writeln!(out, "## METRICS CLASS\tpicard.analysis.directed.HsMetrics")?; + + let mut header = String::from( + "BAIT_SET\tBAIT_TERRITORY\tBAIT_DESIGN_EFFICIENCY\tON_BAIT_BASES\tNEAR_BAIT_BASES\t\ + OFF_BAIT_BASES\tPCT_SELECTED_BASES\tPCT_OFF_BAIT\tON_BAIT_VS_SELECTED\t\ + MEAN_BAIT_COVERAGE\tPCT_USABLE_BASES_ON_BAIT\tPCT_USABLE_BASES_ON_TARGET\t\ + FOLD_ENRICHMENT\tHS_LIBRARY_SIZE", + ); + for level in PENALTY_LEVELS { + header.push_str(&format!("\tHS_PENALTY_{level}X")); + } + header.push_str( + "\tTARGET_TERRITORY\tGENOME_SIZE\tTOTAL_READS\tPF_READS\tPF_BASES\tPF_UNIQUE_READS\t\ + PF_UQ_READS_ALIGNED\tPF_BASES_ALIGNED\tPF_UQ_BASES_ALIGNED\tON_TARGET_BASES\t\ + PCT_PF_READS\tPCT_PF_UQ_READS\tPCT_PF_UQ_READS_ALIGNED\tMEAN_TARGET_COVERAGE\t\ + MEDIAN_TARGET_COVERAGE\tMAX_TARGET_COVERAGE\tMIN_TARGET_COVERAGE\tZERO_CVG_TARGETS_PCT\t\ + PCT_EXC_DUPE\tPCT_EXC_ADAPTER\tPCT_EXC_MAPQ\tPCT_EXC_BASEQ\tPCT_EXC_OVERLAP\t\ + PCT_EXC_OFF_TARGET\tFOLD_80_BASE_PENALTY", + ); + for level in TARGET_COVERAGE_LEVELS { + header.push_str(&format!("\tPCT_TARGET_BASES_{level}X")); + } + header.push_str( + "\tAT_DROPOUT\tGC_DROPOUT\tHET_SNP_SENSITIVITY\tHET_SNP_Q\tSAMPLE\tLIBRARY\tREAD_GROUP", + ); + writeln!(out, "{header}")?; + + write!( + out, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + result.bait_set, + result.bait_territory, + // Every bait base is intended as a target here; Picard reports the + // fraction of bait territory that is also target territory. + fmt_picard(if result.bait_territory == 0 { + 0.0 + } else { + result.target_territory.min(result.bait_territory) as f64 / result.bait_territory as f64 + }), + c.on_bait_bases, + c.near_bait_bases, + c.off_bait_bases, + fmt_picard(frac(selected)), + fmt_picard(frac(c.off_bait_bases)), + fmt_picard(if selected == 0 { + 0.0 + } else { + c.on_bait_bases as f64 / selected as f64 + }), + fmt_picard(result.mean_bait_coverage()), + fmt_picard(if c.pf_bases == 0 { + 0.0 + } else { + c.on_bait_bases as f64 / c.pf_bases as f64 + }), + fmt_picard(if c.pf_bases == 0 { + 0.0 + } else { + c.on_target_bases as f64 / c.pf_bases as f64 + }), + fmt_picard(fold_enrichment(result)), + result + .library_size + .map(|v| v.to_string()) + .unwrap_or_default(), + )?; + // The penalties derive from the theoretical sensitivity simulation, which + // is out of scope; Picard writes -1 when it cannot compute them. + for _ in PENALTY_LEVELS { + write!(out, "\t-1")?; + } + write!( + out, + "\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t?", + result.target_territory, + result.genome_size, + c.total_reads, + c.total_reads, + c.pf_bases, + c.pf_unique_reads, + c.pf_uq_reads_aligned, + c.pf_bases_aligned, + c.pf_uq_bases_aligned, + c.on_target_bases, + fmt_picard(1.0), + fmt_picard(if c.total_reads == 0 { + 0.0 + } else { + c.pf_unique_reads as f64 / c.total_reads as f64 + }), + fmt_picard(if c.pf_unique_reads == 0 { + 0.0 + } else { + c.pf_uq_reads_aligned as f64 / c.pf_unique_reads as f64 + }), + fmt_picard(result.mean_target_coverage()), + median, + max, + min, + fmt_picard(if result.target_count == 0 { + 0.0 + } else { + result.zero_coverage_targets as f64 / result.target_count as f64 + }), + fmt_picard(frac(c.excluded_dupe)), + fmt_picard(0.0), + fmt_picard(frac(0)), + fmt_picard(frac(c.excluded_baseq)), + fmt_picard(frac(c.excluded_overlap)), + fmt_picard(frac(c.excluded_off_target)), + )?; + for fraction in result.target_coverage_fractions() { + write!(out, "\t{}", fmt_picard(fraction))?; + } + // AT and GC dropout over targets, and the two simulated columns, are not + // computed; see the module documentation. + writeln!(out, "\t?\t?\t?\t?\t\t\t")?; + writeln!(out)?; + + out.flush()?; + Ok(()) +} + +/// Enrichment of the selected territory relative to uniform coverage. +/// +/// Picard computes this from the *selected* bases against the bait territory, +/// not from on-target bases against the target territory. On the project +/// fixture every aligned base is on bait, so the figure reduces to +/// `GENOME_SIZE / BAIT_TERRITORY`, which is exactly the 1.142886 it reports. +fn fold_enrichment(result: &HsMetricsResult) -> f64 { + let c = &result.counters; + if c.pf_bases_aligned == 0 || result.bait_territory == 0 || result.genome_size == 0 { + return 0.0; + } + let selected = (c.on_bait_bases + c.near_bait_bases) as f64 / c.pf_bases_aligned as f64; + selected / (result.bait_territory as f64 / result.genome_size as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_size_solves_the_lander_waterman_equation() { + // The project fixture's counts, checked against Picard's own answer. + assert_eq!(estimate_library_size(2820, 1992), Some(3807)); + } + + #[test] + fn library_size_is_absent_when_nothing_is_duplicated() { + assert_eq!(estimate_library_size(100, 100), None); + assert_eq!(estimate_library_size(0, 0), None); + } + + #[test] + fn target_coverage_fractions_are_at_or_above_each_level() { + let result = HsMetricsResult { + bait_set: "t".into(), + bait_territory: 4, + target_territory: 4, + genome_size: 100, + counters: HsCounters::default(), + target_depths: vec![0, 1, 10, 300], + zero_coverage_targets: 0, + target_count: 1, + library_size: None, + }; + let f = result.target_coverage_fractions(); + assert!((f[0] - 0.75).abs() < 1e-12, "1X"); + assert!((f[2] - 0.5).abs() < 1e-12, "10X"); + assert!((f[8] - 0.25).abs() < 1e-12, "250X"); + } + + #[test] + fn coverage_bounds_come_from_the_target_bases_only() { + let result = HsMetricsResult { + bait_set: "t".into(), + bait_territory: 5, + target_territory: 5, + genome_size: 100, + counters: HsCounters::default(), + target_depths: vec![0, 3, 7, 9, 40], + zero_coverage_targets: 0, + target_count: 1, + library_size: None, + }; + assert_eq!(result.target_coverage_bounds(), (7, 0, 40)); + } +} diff --git a/src/dna/intervals.rs b/src/dna/intervals.rs new file mode 100644 index 00000000..f89af490 --- /dev/null +++ b/src/dna/intervals.rs @@ -0,0 +1,262 @@ +//! BED interval parsing and merging for targeted mode. +//! +//! Picard consumes `.interval_list` files, RustQC accepts BED. The two differ +//! in a way that is easy to get wrong: BED is zero-based half-open, an +//! interval list is one-based inclusive, so `chr22 1 15000` in BED is +//! `chr22 2 15000` in an interval list. Everything here works in BED's +//! convention internally and converts only at the edges. + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{bail, Context, Result}; + +/// A half-open interval `[start, end)` on one contig, zero-based. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Interval { + /// Zero-based inclusive start. + pub start: u64, + /// Zero-based exclusive end. + pub end: u64, +} + +impl Interval { + /// Number of bases covered. + pub fn len(&self) -> u64 { + self.end.saturating_sub(self.start) + } + + /// Whether the interval covers no bases. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether `position` falls inside. + pub fn contains(&self, position: u64) -> bool { + position >= self.start && position < self.end + } +} + +/// Merged, sorted intervals grouped by contig. +#[derive(Debug, Clone, Default)] +pub struct IntervalSet { + /// Non-overlapping intervals per contig, ascending. + by_contig: HashMap>, + /// A name for the set, used as `BAIT_SET` in the metrics. + name: String, +} + +impl IntervalSet { + /// Read a BED file, merging any overlapping or touching intervals. + /// + /// Merging matters: overlapping targets would otherwise inflate the + /// territory and double-count on-target bases. + pub fn from_bed(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read BED file: {}", path.display()))?; + + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("targets") + .to_string(); + + let mut raw: HashMap> = HashMap::new(); + for (number, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with("track") + || line.starts_with("browser") + { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 3 { + bail!( + "{}: line {} has {} fields, a BED interval needs at least 3", + path.display(), + number + 1, + fields.len() + ); + } + let start: u64 = fields[1].parse().with_context(|| { + format!("{}: line {} has a bad start", path.display(), number + 1) + })?; + let end: u64 = fields[2].parse().with_context(|| { + format!("{}: line {} has a bad end", path.display(), number + 1) + })?; + if end <= start { + bail!( + "{}: line {} ends at or before it starts", + path.display(), + number + 1 + ); + } + raw.entry(fields[0].to_string()) + .or_default() + .push(Interval { start, end }); + } + + let by_contig = raw + .into_iter() + .map(|(contig, intervals)| (contig, merge(intervals))) + .collect(); + + Ok(Self { by_contig, name }) + } + + /// Build directly from intervals, for tests and for deriving one set from + /// another. + pub fn from_intervals(name: &str, by_contig: HashMap>) -> Self { + Self { + by_contig: by_contig + .into_iter() + .map(|(contig, intervals)| (contig, merge(intervals))) + .collect(), + name: name.to_string(), + } + } + + /// The set's name, reported as `BAIT_SET`. + pub fn name(&self) -> &str { + &self.name + } + + /// Total bases covered across every contig. + pub fn territory(&self) -> u64 { + self.by_contig + .values() + .flat_map(|intervals| intervals.iter()) + .map(|interval| interval.len()) + .sum() + } + + /// Intervals on one contig, ascending, or an empty slice. + pub fn on(&self, contig: &str) -> &[Interval] { + self.by_contig + .get(contig) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Total number of intervals. + pub fn len(&self) -> usize { + self.by_contig.values().map(|v| v.len()).sum() + } + + /// Whether the set holds no intervals. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// A per-base membership mask for one contig, for fast position lookup in + /// the inner loop. + pub fn mask(&self, contig: &str, length: u64) -> Vec { + let mut mask = vec![false; length as usize]; + for interval in self.on(contig) { + let start = interval.start.min(length) as usize; + let end = interval.end.min(length) as usize; + mask[start..end].fill(true); + } + mask + } +} + +/// Sort and merge overlapping or adjacent intervals. +fn merge(mut intervals: Vec) -> Vec { + intervals.sort(); + let mut merged: Vec = Vec::with_capacity(intervals.len()); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.start <= last.end => { + last.end = last.end.max(interval.end); + } + _ => merged.push(interval), + } + } + merged +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_bed(name: &str, contents: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("rustqc-interval-tests"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn overlapping_intervals_are_merged() { + let path = write_bed( + "overlap.bed", + "chr1\t100\t200\nchr1\t150\t300\nchr1\t400\t500\n", + ); + let set = IntervalSet::from_bed(&path).unwrap(); + assert_eq!( + set.on("chr1"), + &[ + Interval { + start: 100, + end: 300 + }, + Interval { + start: 400, + end: 500 + }, + ] + ); + assert_eq!(set.territory(), 300, "merged, not 100 + 150 + 100"); + } + + #[test] + fn touching_intervals_are_merged_too() { + let path = write_bed("touch.bed", "chr1\t100\t200\nchr1\t200\t300\n"); + let set = IntervalSet::from_bed(&path).unwrap(); + assert_eq!( + set.on("chr1"), + &[Interval { + start: 100, + end: 300 + }] + ); + } + + #[test] + fn comments_and_track_lines_are_ignored() { + let path = write_bed( + "comments.bed", + "# a comment\ntrack name=x\nchr1\t10\t20\n\nbrowser position chr1\n", + ); + let set = IntervalSet::from_bed(&path).unwrap(); + assert_eq!(set.len(), 1); + } + + #[test] + fn a_backwards_interval_is_an_error_rather_than_silently_empty() { + let path = write_bed("backwards.bed", "chr1\t200\t100\n"); + let result = IntervalSet::from_bed(&path); + assert!(result.is_err(), "an end before the start must be rejected"); + } + + #[test] + fn the_mask_marks_exactly_the_covered_bases() { + let path = write_bed("mask.bed", "chr1\t2\t5\n"); + let set = IntervalSet::from_bed(&path).unwrap(); + assert_eq!( + set.mask("chr1", 8), + vec![false, false, true, true, true, false, false, false] + ); + } + + #[test] + fn intervals_beyond_the_contig_end_do_not_overflow_the_mask() { + let path = write_bed("beyond.bed", "chr1\t2\t100\n"); + let set = IntervalSet::from_bed(&path).unwrap(); + assert_eq!(set.mask("chr1", 4), vec![false, false, true, true]); + } +} diff --git a/src/dna/mod.rs b/src/dna/mod.rs index f55bf3ce..bf2c9729 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -6,6 +6,8 @@ pub mod depth; pub mod gc_bias; +pub mod hs_metrics; pub mod insert_size; +pub mod intervals; pub mod mosdepth; pub mod wgs_metrics; diff --git a/src/main.rs b/src/main.rs index 137dee23..94cdca4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,7 +163,9 @@ fn run_dna(args: cli::DnaArgs, ui: &Ui) -> Result<()> { ui.config("Threads", &args.threads.to_string()); if let Some(ref targets) = args.targets { ui.config("Targets", targets); - ui.warn("--targets is accepted but targeted metrics are not implemented yet"); + } + if let Some(ref baits) = args.baits { + ui.config("Baits", baits); } let mut inputs = Vec::new(); @@ -250,7 +252,10 @@ fn process_single_dna_bam( use rustqc::common::bam_stat_accum::BamStatAccum; use rustqc::common::preseq::PreseqAccum; use rustqc::dna::depth::{DepthAccum, MOSDEPTH_DEFAULT_EXCLUDE}; + use rustqc::dna::gc_bias::{self, GcBiasAccum}; + use rustqc::dna::hs_metrics::{self, HsAccum, HsCounters, HsMetricsResult}; use rustqc::dna::insert_size::{self, InsertSizeAccum}; + use rustqc::dna::intervals::IntervalSet; use rustqc::dna::mosdepth::{output as mos_out, ContigDepth, MosdepthResult}; use rustqc::dna::wgs_metrics::{self, WgsAccum, WgsCounters, WgsMetricsResult}; @@ -310,6 +315,26 @@ fn process_single_dna_bam( ui.warn("CollectWgsMetrics needs --reference to size the genome territory, skipping"); } let insert_size_enabled = config.insert_size.enabled; + // GC bias bins reference windows, so it needs the reference just as the + // WGS metrics do. + let gc_bias_enabled = config.gc_bias.enabled && args.reference.is_some(); + if config.gc_bias.enabled && args.reference.is_none() { + ui.warn("CollectGcBiasMetrics needs --reference to bin the genome, skipping"); + } + let gc_window = config.gc_bias.window_size; + + // Targeted mode is switched on by --targets alone; --baits defaults to it. + let targets = match args.targets.as_deref() { + Some(path) => Some(IntervalSet::from_bed(Path::new(path))?), + None => None, + }; + let baits = match args.baits.as_deref() { + Some(path) => Some(IntervalSet::from_bed(Path::new(path))?), + None => targets.clone(), + }; + let hs_enabled = config.hs_metrics.enabled && targets.is_some(); + let hs_min_mapq = config.hs_metrics.min_mapping_quality; + let hs_min_baseq = config.hs_metrics.min_base_quality; let wgs_min_mapq = config.wgs_metrics.min_mapping_quality; let wgs_min_baseq = config.wgs_metrics.min_base_quality; let coverage_cap = config.wgs_metrics.coverage_cap; @@ -322,6 +347,8 @@ fn process_single_dna_bam( Option, Option<(WgsCounters, Vec)>, Option, + Option, + Option<(HsCounters, Vec, Vec, String)>, ); let results: Vec> = pool.install(|| { @@ -348,6 +375,37 @@ fn process_single_dna_bam( let mut wgs = wgs_enabled.then(|| WgsAccum::new(*len, wgs_min_mapq, wgs_min_baseq)); let mut insert_sizes = insert_size_enabled.then(InsertSizeAccum::new); + // GC bias and the targeted metrics both need per-contig + // context, fetched once here rather than per record. + let reference_bases: Option> = if gc_bias_enabled { + let reader = rust_htslib::faidx::Reader::from_path( + args.reference.as_deref().unwrap_or_default(), + ) + .with_context(|| "Failed to open the reference FASTA index")?; + let length = reader.fetch_seq_len(name) as usize; + Some( + reader + .fetch_seq(name, 0, length.saturating_sub(1)) + .map(|s| s.to_vec()) + .with_context(|| format!("Failed to read reference for {name}"))?, + ) + } else { + None + }; + let mut gc = reference_bases + .as_ref() + .map(|bases| GcBiasAccum::new(bases, gc_window)); + let mut hs = hs_enabled.then(|| { + HsAccum::new( + name, + *len, + baits.as_ref().unwrap_or_else(|| targets.as_ref().unwrap()), + targets.as_ref().unwrap(), + hs_min_mapq, + hs_min_baseq, + ) + }); + let mut record = bam::Record::new(); while let Some(result) = reader.read(&mut record) { result.context("Failed to read record")?; @@ -362,6 +420,12 @@ fn process_single_dna_bam( if let Some(accum) = insert_sizes.as_mut() { accum.process_read(&record); } + if let (Some(accum), Some(bases)) = (gc.as_mut(), reference_bases.as_ref()) { + accum.process_read(&record, bases); + } + if let Some(accum) = hs.as_mut() { + accum.process_read(&record); + } } let depths = depth.into_depths(); @@ -372,6 +436,11 @@ fn process_single_dna_bam( preseq, wgs.map(|accum| accum.into_parts()), insert_sizes, + gc, + hs.map(|accum| { + let (counters, depths, mask) = accum.into_parts(); + (counters, depths, mask, name.clone()) + }), )) }) .collect() @@ -384,8 +453,13 @@ fn process_single_dna_bam( let mut wgs_depths: Vec = Vec::new(); let mut saw_wgs = false; let mut insert_size_total: Option = None; + let mut gc_total: Option = None; + let mut hs_counters = HsCounters::default(); + let mut hs_target_depths: Vec = Vec::new(); + let mut hs_target_count = 0u64; + let mut hs_zero_targets = 0u64; for result in results { - let (contig, bam_stat, preseq, wgs, insert_sizes) = result?; + let (contig, bam_stat, preseq, wgs, insert_sizes, gc, hs) = result?; per_contig.push(contig); bam_stat_total.merge(bam_stat); match (preseq_total.as_mut(), preseq) { @@ -405,6 +479,29 @@ fn process_single_dna_bam( (None, part) => insert_size_total = part, _ => {} } + match (gc_total.as_mut(), gc) { + (Some(total), Some(part)) => total.merge(&part), + (None, part) => gc_total = part, + _ => {} + } + if let Some((counters, depths, mask, contig_name)) = hs { + hs_counters.merge(&counters); + for (depth, on_target) in depths.iter().zip(mask.iter()) { + if *on_target { + hs_target_depths.push(*depth); + } + } + if let Some(set) = targets.as_ref() { + for interval in set.on(&contig_name) { + hs_target_count += 1; + if (interval.start..interval.end) + .all(|p| depths.get(p as usize).copied().unwrap_or(0) == 0) + { + hs_zero_targets += 1; + } + } + } + } } // Unmapped records carry no contig, so they need their own pass; flagstat @@ -416,10 +513,41 @@ fn process_single_dna_bam( reader.set_reference(reference).ok(); } if reader.fetch(bam::FetchDefinition::Unmapped).is_ok() { + // Unmapped records reach no contig worker, yet they still count + // towards several metrics: flagstat and idxstats report them, HS + // metrics count them in TOTAL_READS and PF_BASES, and GC bias + // counts them as clusters. Both accumulators short-circuit on an + // unmapped record, so an empty contig is enough context here. + let mut hs_unmapped = hs_enabled.then(|| { + HsAccum::new( + "", + 0, + baits.as_ref().unwrap_or_else(|| targets.as_ref().unwrap()), + targets.as_ref().unwrap(), + hs_min_mapq, + hs_min_baseq, + ) + }); + let mut gc_unmapped = gc_bias_enabled.then(|| GcBiasAccum::new(&[], gc_window)); + let mut record = bam::Record::new(); while let Some(result) = reader.read(&mut record) { result.context("Failed to read unmapped record")?; bam_stat_total.process_read(&record, mapq_cut); + if let Some(accum) = hs_unmapped.as_mut() { + accum.process_read(&record); + } + if let Some(accum) = gc_unmapped.as_mut() { + accum.process_read(&record, &[]); + } + } + + if let Some(accum) = hs_unmapped { + let (counters, _, _) = accum.into_parts(); + hs_counters.merge(&counters); + } + if let (Some(total), Some(part)) = (gc_total.as_mut(), gc_unmapped) { + total.merge(&part); } } } @@ -554,6 +682,43 @@ fn process_single_dna_bam( } } + if let Some(accum) = gc_total { + let result = accum.into_result(gc_window); + let dir_path = dir("picard").join("gc_bias"); + std::fs::create_dir_all(&dir_path)?; + let detail = dir_path.join(format!("{sample_name}.gc_bias.detail_metrics.txt")); + gc_bias::write_detail_metrics(&result, &detail)?; + record_output("picard CollectGcBiasMetrics", detail); + let summary = dir_path.join(format!("{sample_name}.gc_bias.summary_metrics.txt")); + gc_bias::write_summary_metrics(&result, &summary)?; + record_output("picard CollectGcBiasMetrics", summary); + } + + if hs_enabled { + let target_set = targets.as_ref().expect("hs_enabled implies --targets"); + let bait_set = baits.as_ref().unwrap_or(target_set); + let library_size = hs_metrics::estimate_library_size( + hs_counters.selected_pairs, + hs_counters.selected_unique_pairs, + ); + let result = HsMetricsResult { + bait_set: bait_set.name().to_string(), + bait_territory: bait_set.territory(), + target_territory: target_set.territory(), + genome_size: contigs.iter().map(|(_, _, len)| len).sum(), + counters: hs_counters, + target_depths: hs_target_depths, + zero_coverage_targets: hs_zero_targets, + target_count: hs_target_count, + library_size, + }; + let dir_path = dir("picard").join("hs_metrics"); + std::fs::create_dir_all(&dir_path)?; + let path = dir_path.join(format!("{sample_name}.hs_metrics.txt")); + hs_metrics::write_hs_metrics(&result, &path)?; + record_output("picard CollectHsMetrics", path); + } + if let Some(mut accum) = preseq_total { let preseq_dir = dir("preseq"); std::fs::create_dir_all(&preseq_dir)?; diff --git a/tests/create_dna_test_data.sh b/tests/create_dna_test_data.sh index 34003987..d97d7220 100755 --- a/tests/create_dna_test_data.sh +++ b/tests/create_dna_test_data.sh @@ -42,6 +42,7 @@ trap 'rm -rf "$tmp"' EXIT curl -sSfL -o "$tmp/upstream.bam" "$base/illumina/bam/test.paired_end.sorted.bam" curl -sSfL -o "$data/genome.fasta" "$base/genome/genome.fasta" curl -sSfL -o "$data/genome.fasta.fai" "$base/genome/genome.fasta.fai" +curl -sSfL -o "$data/targets.bed" "$base/genome/genome.multi_intervals.bed" # Mark duplicates: name-sort, add mate tags, coordinate-sort, then markdup. samtools sort -n -o "$tmp/ns.bam" "$tmp/upstream.bam" @@ -75,6 +76,22 @@ picard CollectInsertSizeMetrics \ # The chart output needs R, so it goes to the scratch directory and is not # compared against; only the two metrics tables are fixtures. +# Picard consumes interval lists rather than BED, so the targets are converted +# with Picard's own tool. The two conventions differ: BED is zero-based +# half-open, an interval list one-based inclusive. +picard CreateSequenceDictionary -R "$data/genome.fasta" -O "$tmp/genome.dict" +picard BedToIntervalList \ + -I "$data/targets.bed" \ + -O "$tmp/targets.interval_list" \ + -SD "$tmp/genome.dict" + +picard CollectHsMetrics \ + -I "$data/test.dna.bam" \ + -O "$expected/test.hs_metrics.txt" \ + -R "$data/genome.fasta" \ + -BI "$tmp/targets.interval_list" \ + -TI "$tmp/targets.interval_list" + picard CollectGcBiasMetrics \ -I "$data/test.dna.bam" \ -O "$expected/test.gc_bias.detail_metrics.txt" \ @@ -88,7 +105,8 @@ picard CollectGcBiasMetrics \ # "## METRICS CLASS" and "## HISTOGRAM" markers further down are part of the # format and are kept. for f in "$expected/test.wgs_metrics.txt" "$expected/test.insert_size_metrics.txt" \ - "$expected/test.gc_bias.detail_metrics.txt" "$expected/test.gc_bias.summary_metrics.txt"; do + "$expected/test.gc_bias.detail_metrics.txt" "$expected/test.gc_bias.summary_metrics.txt" \ + "$expected/test.hs_metrics.txt"; do sed -e '/^## htsjdk\.samtools\.metrics\.StringHeader$/d' -e '/^# /d' "$f" \ | sed -e '/./,$!d' > "$f.tmp" && mv "$f.tmp" "$f" done diff --git a/tests/data/dna/targets.bed b/tests/data/dna/targets.bed new file mode 100644 index 00000000..54498e32 --- /dev/null +++ b/tests/data/dna/targets.bed @@ -0,0 +1,2 @@ +chr22 1 15000 +chr22 20000 40001 diff --git a/tests/data/dna/test.dna.bam b/tests/data/dna/test.dna.bam index b46c5da59a007798970d2935d2d00362771bf5a0..d0d2df007b87a5b76983c24f3900564e2592f491 100644 GIT binary patch delta 453 zcmV;$0XqKT=nLfN3x6Mr2m}BC000301^_}s0syfA&6MA6+aMIisWeR@Pr(ZS#?6+7 zEBBAoO-no{ZmV5bnsAIWrvayMk~VpkJ?unQZ&j7U=N|Nbi z0YRyNAXPx8RX`_Jz!Nn>frufYV=~vaHJa7RgCgPJSHYYHw`KJmo9}+6;-{(3saFQt zcIkuN-*MJi7k@172g}_S@vQ?wEuk&p8|8T2E-URkUo7}$aJ-G~>@eQ#s_iaXxrg%I z_Pz|47|kue;$T)R?SIK;o5Ahyid)Z0WnNuoz4G9+GNIUWDkh$D{fZC4)y;!Zpb0hq z3tW8|D}D)!i9dAh^(~wIQ>x<^sp5lFM!DN+y&^RKYcXKgJ`^amT`1F7K3#j5oYTrG v9-ffrl>)`PC9AC3gB&4*-Xetd9|t=9j?fQLr(EZQtO19t0RgwH0Rp!2#je<# delta 451 zcmV;!0X+WX=nLZL3x6Mr2m}BC000301^_}s0syZ8&6M9x+b|TyjWkVipTaL-I}VIO zuEHOQO-r-10j?rVPUDnxu~Y0*X!|UC*vTE)5GvdN629^Av30)h=Og>{WVAQ_gi!qT z9ABrAV23#W5ly?!R*jLM25~;dc^XY`Y#8DsjShk!5QBJffq%c9M*?S8k-+&V0_7mZ z$>~FoB~jD1OWhbbh_mh4@?PRB-A>nO6tE!^Lu!RMI>Kj(PZO;)q2GxJ9tNQk5>GA) z@Ja=EsRDYf0(!9m9;p!uL>v=3A~R(=tynD`Na7Fv6wE5HYgXT~+2(I5e4Of?I;o-S zTIcQlkF(yoV1MC(x7=?L*I6Lc5ZV!bCoQkLwo=yd`JCSk_t(L#ISx0QYP|^-c2#~^ z-Esf)ajca3)n%3o2Ubb#i#?@c;wjg^co$sVth5A0sQxH$ z^=_>2IV>h#=-TUBHhovB{b#Adom5)dU)6F!X#QqkS1-;ND3zH@-55SuIvAhP%1G{? tkmHpE$@?X%jNF3kA%s37gti|CI{A&zA19EwI)knOhpqtux2^#Kw(^$K*LVN` diff --git a/tests/data/dna/test.dna.bam.bai b/tests/data/dna/test.dna.bam.bai index ce91243cb9ad66d3534abb14be0c5ed784918f80..a21f9c86e15c88ed3fd78a142d0739a67f89dc98 100644 GIT binary patch literal 96 zcmZ>A^kigYU|?VZVoxCk1`wNpVH23rx+iuKBA^kigYU|?VZVoxCk1`wNpVFQ@bx+iiGB String { + let text = std::fs::read_to_string(fixture("test.hs_metrics.txt")).unwrap(); + let mut lines = text + .lines() + .filter(|l| !l.starts_with('#') && !l.is_empty()); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let values: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let index = header + .iter() + .position(|h| *h == name) + .unwrap_or_else(|| panic!("no column named {name}")); + values[index].to_string() +} + +fn hs_result() -> HsMetricsResult { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let targets = IntervalSet::from_bed(&root.join("tests/data/dna/targets.bed")).unwrap(); + + let mut reader = bam::Reader::from_path(root.join("tests/data/dna/test.dna.bam")).unwrap(); + let header = reader.header().to_owned(); + let contig = String::from_utf8(header.tid2name(0).to_vec()).unwrap(); + let length = header.target_len(0).unwrap(); + + let mut accum = HsAccum::new(&contig, length, &targets, &targets, 20, 20); + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + accum.process_read(&record); + } + let (counters, depths, target_mask) = accum.into_parts(); + + let target_depths: Vec = depths + .iter() + .zip(target_mask.iter()) + .filter(|(_, on_target)| **on_target) + .map(|(depth, _)| *depth) + .collect(); + + let zero_coverage_targets = targets + .on(&contig) + .iter() + .filter(|interval| (interval.start..interval.end).all(|p| depths[p as usize] == 0)) + .count() as u64; + + let library_size = + hs_metrics::estimate_library_size(counters.selected_pairs, counters.selected_unique_pairs); + + HsMetricsResult { + bait_set: targets.name().to_string(), + bait_territory: targets.territory(), + target_territory: targets.territory(), + genome_size: length, + counters, + target_depths, + zero_coverage_targets, + target_count: targets.len() as u64, + library_size, + } +} + +/// The counters are the part that had to be taken from Picard's source, so +/// each is pinned against the fixture individually. +#[test] +fn hs_counters_match_picard() { + let result = hs_result(); + let c: &HsCounters = &result.counters; + let want = |name: &str| -> u64 { hs_fixture_column(name).parse().unwrap() }; + + assert_eq!(result.bait_territory, want("BAIT_TERRITORY")); + assert_eq!(result.target_territory, want("TARGET_TERRITORY")); + assert_eq!(result.genome_size, want("GENOME_SIZE")); + assert_eq!(c.total_reads, want("TOTAL_READS"), "secondary excluded"); + assert_eq!(c.pf_bases, want("PF_BASES")); + assert_eq!(c.pf_unique_reads, want("PF_UNIQUE_READS")); + assert_eq!(c.pf_uq_reads_aligned, want("PF_UQ_READS_ALIGNED")); + assert_eq!(c.pf_bases_aligned, want("PF_BASES_ALIGNED")); + assert_eq!(c.pf_uq_bases_aligned, want("PF_UQ_BASES_ALIGNED")); + assert_eq!(c.on_bait_bases, want("ON_BAIT_BASES")); + assert_eq!(c.near_bait_bases, want("NEAR_BAIT_BASES")); + assert_eq!(c.off_bait_bases, want("OFF_BAIT_BASES")); + assert_eq!( + c.on_target_bases, + want("ON_TARGET_BASES"), + "overlap clipping runs before the base quality filter" + ); + assert_eq!(result.library_size, Some(want("HS_LIBRARY_SIZE"))); +} + +/// The exclusion fractions are where HsMetrics parts company with +/// CollectWgsMetrics, so they get their own assertions. +#[test] +fn hs_exclusion_fractions_match_picard() { + let result = hs_result(); + let aligned = result.counters.pf_bases_aligned as f64; + let want = |name: &str| -> f64 { hs_fixture_column(name).parse().unwrap() }; + let close = |got: f64, name: &str| { + let expected = want(name); + assert!( + (got - expected).abs() < 1e-6, + "{name}: got {got}, want {expected}" + ); + }; + close( + result.counters.excluded_dupe as f64 / aligned, + "PCT_EXC_DUPE", + ); + close( + result.counters.excluded_overlap as f64 / aligned, + "PCT_EXC_OVERLAP", + ); + close( + result.counters.excluded_baseq as f64 / aligned, + "PCT_EXC_BASEQ", + ); + close( + result.counters.excluded_off_target as f64 / aligned, + "PCT_EXC_OFF_TARGET", + ); +} + +#[test] +fn hs_target_coverage_matches_picard() { + let result = hs_result(); + let want = |name: &str| -> f64 { hs_fixture_column(name).parse().unwrap() }; + assert!( + (result.mean_target_coverage() - want("MEAN_TARGET_COVERAGE")).abs() < 1e-6, + "mean target coverage was {}", + result.mean_target_coverage() + ); + assert!( + (result.mean_bait_coverage() - want("MEAN_BAIT_COVERAGE")).abs() < 1e-6, + "mean bait coverage was {}", + result.mean_bait_coverage() + ); + let (median, min, max) = result.target_coverage_bounds(); + assert_eq!(u64::from(median), want("MEDIAN_TARGET_COVERAGE") as u64); + assert_eq!(u64::from(min), want("MIN_TARGET_COVERAGE") as u64); + assert_eq!(u64::from(max), want("MAX_TARGET_COVERAGE") as u64); + + let fractions = result.target_coverage_fractions(); + for (level, got) in hs_metrics::TARGET_COVERAGE_LEVELS.iter().zip(fractions) { + let expected = want(&format!("PCT_TARGET_BASES_{level}X")); + assert!( + (got - expected).abs() < 1e-6, + "PCT_TARGET_BASES_{level}X: got {got}, want {expected}" + ); + } +} + +/// A second binary run, this time in targeted mode with a reference, so the +/// GC bias and targeted outputs are produced. +fn run_binary_targeted() -> &'static Path { + static OUTDIR: std::sync::OnceLock = std::sync::OnceLock::new(); + OUTDIR.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let outdir = std::env::temp_dir().join("rustqc-dna-targeted"); + let _ = std::fs::remove_dir_all(&outdir); + std::fs::create_dir_all(&outdir).unwrap(); + let status = std::process::Command::new(env!("CARGO_BIN_EXE_rustqc")) + .arg("dna") + .arg(root.join("tests/data/dna/test.dna.bam")) + .arg("--reference") + .arg(root.join("tests/data/dna/genome.fasta")) + .arg("--targets") + .arg(root.join("tests/data/dna/targets.bed")) + .arg("--outdir") + .arg(&outdir) + .arg("--quiet") + .status() + .expect("failed to run the rustqc binary"); + assert!(status.success(), "rustqc dna exited with {status}"); + outdir + }) +} + +#[test] +fn binary_writes_gc_bias_metrics_byte_for_byte() { + for suffix in ["gc_bias.detail_metrics", "gc_bias.summary_metrics"] { + let got = std::fs::read_to_string( + run_binary_targeted() + .join("picard/gc_bias") + .join(format!("{SAMPLE}.{suffix}.txt")), + ) + .unwrap(); + let want = std::fs::read_to_string(fixture(&format!("test.{suffix}.txt"))).unwrap(); + assert_same_lines(&got, &want, suffix); + } +} + +/// Every HS metrics column but the seven that need Picard's theoretical +/// sensitivity simulation or its per-target GC dropout, which RustQC does not +/// compute and writes as Picard writes its own uncomputable values. +#[test] +fn binary_writes_hs_metrics_bar_the_simulated_columns() { + let path = run_binary_targeted() + .join("picard/hs_metrics") + .join(format!("{SAMPLE}.hs_metrics.txt")); + let parse = |text: &str| -> std::collections::HashMap { + let mut lines = text + .lines() + .filter(|l| !l.starts_with('#') && !l.trim().is_empty()); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let values: Vec<&str> = lines.next().unwrap().split('\t').collect(); + header + .iter() + .zip(values.iter()) + .map(|(h, v)| (h.to_string(), v.to_string())) + .collect() + }; + + let ours = parse(&std::fs::read_to_string(&path).unwrap()); + let theirs = parse(&std::fs::read_to_string(fixture("test.hs_metrics.txt")).unwrap()); + + let uncomputed: Vec = [ + "HET_SNP_SENSITIVITY", + "HET_SNP_Q", + "AT_DROPOUT", + "GC_DROPOUT", + "FOLD_80_BASE_PENALTY", + ] + .iter() + .map(|s| s.to_string()) + .chain( + hs_metrics::PENALTY_LEVELS + .iter() + .map(|n| format!("HS_PENALTY_{n}X")), + ) + .collect(); + + let mut compared = 0; + for (column, want) in &theirs { + if uncomputed.contains(column) { + continue; + } + compared += 1; + let got = ours + .get(column) + .unwrap_or_else(|| panic!("we do not emit column {column}")); + assert_eq!(got, want, "column {column} differs"); + } + assert!( + compared >= 55, + "expected to compare most of the columns, only did {compared}" + ); +} + +/// Targeted outputs appear only when targets are given. +#[test] +fn hs_metrics_are_absent_without_targets() { + assert!( + !run_binary().join("picard/hs_metrics").exists(), + "no targets means no targeted metrics" + ); + assert!( + run_binary_targeted().join("picard/hs_metrics").exists(), + "targets must produce them" + ); +} diff --git a/tests/expected/dna/test.hs_metrics.txt b/tests/expected/dna/test.hs_metrics.txt new file mode 100644 index 00000000..0f498da1 --- /dev/null +++ b/tests/expected/dna/test.hs_metrics.txt @@ -0,0 +1,870 @@ +## METRICS CLASS picard.analysis.directed.HsMetrics +BAIT_SET BAIT_TERRITORY BAIT_DESIGN_EFFICIENCY ON_BAIT_BASES NEAR_BAIT_BASES OFF_BAIT_BASES PCT_SELECTED_BASES PCT_OFF_BAIT ON_BAIT_VS_SELECTED MEAN_BAIT_COVERAGE PCT_USABLE_BASES_ON_BAIT PCT_USABLE_BASES_ON_TARGET FOLD_ENRICHMENT HS_LIBRARY_SIZE HS_PENALTY_10X HS_PENALTY_20X HS_PENALTY_30X HS_PENALTY_40X HS_PENALTY_50X HS_PENALTY_100X TARGET_TERRITORY GENOME_SIZE TOTAL_READS PF_READS PF_BASES PF_UNIQUE_READS PF_UQ_READS_ALIGNED PF_BASES_ALIGNED PF_UQ_BASES_ALIGNED ON_TARGET_BASES PCT_PF_READS PCT_PF_UQ_READS PCT_PF_UQ_READS_ALIGNED MEAN_TARGET_COVERAGE MEDIAN_TARGET_COVERAGE MAX_TARGET_COVERAGE MIN_TARGET_COVERAGE ZERO_CVG_TARGETS_PCT PCT_EXC_DUPE PCT_EXC_ADAPTER PCT_EXC_MAPQ PCT_EXC_BASEQ PCT_EXC_OVERLAP PCT_EXC_OFF_TARGET FOLD_80_BASE_PENALTY PCT_TARGET_BASES_1X PCT_TARGET_BASES_2X PCT_TARGET_BASES_10X PCT_TARGET_BASES_20X PCT_TARGET_BASES_30X PCT_TARGET_BASES_40X PCT_TARGET_BASES_50X PCT_TARGET_BASES_100X PCT_TARGET_BASES_250X PCT_TARGET_BASES_500X PCT_TARGET_BASES_1000X PCT_TARGET_BASES_2500X PCT_TARGET_BASES_5000X PCT_TARGET_BASES_10000X PCT_TARGET_BASES_25000X PCT_TARGET_BASES_50000X PCT_TARGET_BASES_100000X AT_DROPOUT GC_DROPOUT HET_SNP_SENSITIVITY HET_SNP_Q SAMPLE LIBRARY READ_GROUP +targets 35000 1 670989 0 0 1 0 1 19.171114 0.998301 0.364694 1.142886 3807 -1 -1 -1 -1 -1 -1 35000 40001 5642 5642 672131 3986 3984 670989 469869 245122 1 0.706487 0.999498 7.003486 0 862 0 0.5 0.299737 0 0 0.003982 0.330968 0 ? 0.033229 0.029943 0.026171 0.022171 0.020943 0.019486 0.019114 0.017171 0.010886 0.006 0 0 0 0 0 0 0 57.145714 0 0.031185 0 + +## HISTOGRAM java.lang.Integer +coverage_or_base_quality high_quality_coverage_count unfiltered_baseq_count +0 33837 0 +1 115 0 +2 34 0 +3 22 0 +4 18 23 +5 8 1 +6 19 1 +7 9 0 +8 13 8 +9 9 0 +10 93 1 +11 3 2 +12 5 0 +13 5 2481 +14 7 0 +15 2 0 +16 12 0 +17 5 15 +18 4 10 +19 4 0 +20 0 576 +21 4 1 +22 5 0 +23 1 0 +24 6 0 +25 4 5 +26 4 1154 +27 8 0 +28 6 0 +29 5 0 +30 18 0 +31 21 4527 +32 5 64 +33 0 1 +34 2 54518 +35 0 14 +36 2 1 +37 1 0 +38 1 126 +39 1 0 +40 1 24 +41 3 2 +42 0 94 +43 2 244 +44 1 9250 +45 0 174521 +46 2 0 +47 1 0 +48 1 0 +49 2 0 +50 1 0 +51 3 0 +52 1 0 +53 0 0 +54 1 0 +55 2 0 +56 1 0 +57 1 0 +58 4 0 +59 0 0 +60 0 0 +61 1 0 +62 3 0 +63 0 0 +64 2 0 +65 2 0 +66 0 0 +67 0 0 +68 2 0 +69 1 0 +70 2 0 +71 1 0 +72 2 0 +73 0 0 +74 1 0 +75 0 0 +76 2 0 +77 2 0 +78 2 0 +79 1 0 +80 2 0 +81 1 0 +82 2 0 +83 2 0 +84 1 0 +85 1 0 +86 1 0 +87 1 0 +88 0 0 +89 2 0 +90 0 0 +91 4 0 +92 2 0 +93 1 0 +94 3 0 +95 1 0 +96 1 0 +97 3 0 +98 1 0 +99 1 0 +100 1 0 +101 0 0 +102 0 0 +103 2 0 +104 2 0 +105 3 0 +106 2 0 +107 2 0 +108 1 0 +109 3 0 +110 1 0 +111 3 0 +112 3 0 +113 1 0 +114 1 0 +115 1 0 +116 0 0 +117 3 0 +118 6 0 +119 1 0 +120 3 0 +121 0 0 +122 4 0 +123 2 0 +124 2 0 +125 3 0 +126 2 0 +127 6 0 +128 3 0 +129 7 0 +130 11 0 +131 9 0 +132 15 0 +133 10 0 +134 2 0 +135 0 0 +136 0 0 +137 0 0 +138 1 0 +139 1 0 +140 1 0 +141 0 0 +142 2 0 +143 1 0 +144 0 0 +145 0 0 +146 1 0 +147 1 0 +148 0 0 +149 2 0 +150 0 0 +151 0 0 +152 1 0 +153 1 0 +154 0 0 +155 1 0 +156 1 0 +157 1 0 +158 1 0 +159 2 0 +160 3 0 +161 0 0 +162 0 0 +163 1 0 +164 1 0 +165 1 0 +166 1 0 +167 0 0 +168 0 0 +169 0 0 +170 3 0 +171 1 0 +172 1 0 +173 1 0 +174 0 0 +175 1 0 +176 1 0 +177 2 0 +178 1 0 +179 1 0 +180 0 0 +181 0 0 +182 2 0 +183 1 0 +184 0 0 +185 1 0 +186 1 0 +187 0 0 +188 0 0 +189 2 0 +190 2 0 +191 0 0 +192 0 0 +193 0 0 +194 1 0 +195 2 0 +196 2 0 +197 1 0 +198 0 0 +199 3 0 +200 0 0 +201 3 0 +202 0 0 +203 0 0 +204 0 0 +205 1 0 +206 0 0 +207 1 0 +208 0 0 +209 1 0 +210 1 0 +211 0 0 +212 2 0 +213 3 0 +214 1 0 +215 0 0 +216 3 0 +217 1 0 +218 0 0 +219 0 0 +220 2 0 +221 1 0 +222 0 0 +223 0 0 +224 0 0 +225 2 0 +226 2 0 +227 0 0 +228 2 0 +229 0 0 +230 2 0 +231 1 0 +232 3 0 +233 0 0 +234 2 0 +235 0 0 +236 0 0 +237 1 0 +238 1 0 +239 0 0 +240 1 0 +241 1 0 +242 1 0 +243 0 0 +244 5 0 +245 1 0 +246 1 0 +247 0 0 +248 2 0 +249 2 0 +250 1 0 +251 1 0 +252 3 0 +253 0 0 +254 1 0 +255 1 0 +256 2 0 +257 1 0 +258 5 0 +259 3 0 +260 2 0 +261 4 0 +262 8 0 +263 7 0 +264 6 0 +265 9 0 +266 4 0 +267 0 0 +268 0 0 +269 2 0 +270 0 0 +271 0 0 +272 1 0 +273 1 0 +274 1 0 +275 0 0 +276 0 0 +277 1 0 +278 0 0 +279 0 0 +280 1 0 +281 1 0 +282 0 0 +283 0 0 +284 2 0 +285 0 0 +286 1 0 +287 0 0 +288 0 0 +289 0 0 +290 0 0 +291 0 0 +292 0 0 +293 0 0 +294 0 0 +295 1 0 +296 1 0 +297 0 0 +298 1 0 +299 0 0 +300 0 0 +301 1 0 +302 0 0 +303 1 0 +304 1 0 +305 0 0 +306 1 0 +307 0 0 +308 0 0 +309 1 0 +310 0 0 +311 0 0 +312 0 0 +313 0 0 +314 1 0 +315 1 0 +316 0 0 +317 1 0 +318 1 0 +319 0 0 +320 0 0 +321 0 0 +322 0 0 +323 1 0 +324 0 0 +325 1 0 +326 0 0 +327 0 0 +328 0 0 +329 2 0 +330 0 0 +331 0 0 +332 1 0 +333 1 0 +334 0 0 +335 1 0 +336 2 0 +337 0 0 +338 0 0 +339 0 0 +340 1 0 +341 0 0 +342 0 0 +343 2 0 +344 0 0 +345 0 0 +346 1 0 +347 2 0 +348 0 0 +349 1 0 +350 0 0 +351 0 0 +352 2 0 +353 0 0 +354 0 0 +355 0 0 +356 0 0 +357 0 0 +358 1 0 +359 1 0 +360 0 0 +361 1 0 +362 0 0 +363 1 0 +364 0 0 +365 1 0 +366 0 0 +367 0 0 +368 2 0 +369 0 0 +370 0 0 +371 0 0 +372 1 0 +373 0 0 +374 0 0 +375 2 0 +376 0 0 +377 1 0 +378 0 0 +379 0 0 +380 2 0 +381 0 0 +382 0 0 +383 2 0 +384 0 0 +385 0 0 +386 1 0 +387 0 0 +388 0 0 +389 1 0 +390 1 0 +391 0 0 +392 0 0 +393 1 0 +394 1 0 +395 0 0 +396 0 0 +397 0 0 +398 1 0 +399 0 0 +400 0 0 +401 0 0 +402 1 0 +403 2 0 +404 0 0 +405 1 0 +406 1 0 +407 0 0 +408 2 0 +409 0 0 +410 0 0 +411 0 0 +412 0 0 +413 2 0 +414 0 0 +415 0 0 +416 0 0 +417 0 0 +418 1 0 +419 0 0 +420 1 0 +421 1 0 +422 1 0 +423 0 0 +424 0 0 +425 0 0 +426 1 0 +427 0 0 +428 1 0 +429 1 0 +430 1 0 +431 0 0 +432 0 0 +433 1 0 +434 0 0 +435 0 0 +436 1 0 +437 0 0 +438 0 0 +439 1 0 +440 0 0 +441 0 0 +442 1 0 +443 0 0 +444 0 0 +445 2 0 +446 2 0 +447 0 0 +448 0 0 +449 0 0 +450 0 0 +451 0 0 +452 0 0 +453 1 0 +454 1 0 +455 1 0 +456 0 0 +457 0 0 +458 0 0 +459 1 0 +460 0 0 +461 0 0 +462 0 0 +463 1 0 +464 0 0 +465 2 0 +466 1 0 +467 0 0 +468 2 0 +469 0 0 +470 0 0 +471 1 0 +472 0 0 +473 2 0 +474 0 0 +475 1 0 +476 0 0 +477 2 0 +478 0 0 +479 0 0 +480 0 0 +481 0 0 +482 0 0 +483 2 0 +484 1 0 +485 0 0 +486 1 0 +487 0 0 +488 0 0 +489 0 0 +490 1 0 +491 1 0 +492 1 0 +493 0 0 +494 0 0 +495 1 0 +496 0 0 +497 1 0 +498 1 0 +499 0 0 +500 0 0 +501 1 0 +502 0 0 +503 1 0 +504 0 0 +505 0 0 +506 0 0 +507 1 0 +508 1 0 +509 0 0 +510 1 0 +511 1 0 +512 0 0 +513 1 0 +514 2 0 +515 0 0 +516 0 0 +517 0 0 +518 0 0 +519 0 0 +520 1 0 +521 1 0 +522 0 0 +523 1 0 +524 3 0 +525 0 0 +526 1 0 +527 0 0 +528 0 0 +529 1 0 +530 1 0 +531 1 0 +532 0 0 +533 1 0 +534 0 0 +535 1 0 +536 1 0 +537 0 0 +538 0 0 +539 0 0 +540 0 0 +541 1 0 +542 1 0 +543 0 0 +544 1 0 +545 0 0 +546 0 0 +547 0 0 +548 1 0 +549 1 0 +550 0 0 +551 0 0 +552 1 0 +553 1 0 +554 0 0 +555 1 0 +556 1 0 +557 0 0 +558 1 0 +559 0 0 +560 0 0 +561 1 0 +562 0 0 +563 1 0 +564 0 0 +565 1 0 +566 0 0 +567 0 0 +568 1 0 +569 1 0 +570 2 0 +571 0 0 +572 3 0 +573 1 0 +574 1 0 +575 0 0 +576 1 0 +577 1 0 +578 0 0 +579 0 0 +580 0 0 +581 0 0 +582 2 0 +583 3 0 +584 0 0 +585 0 0 +586 0 0 +587 1 0 +588 1 0 +589 1 0 +590 0 0 +591 0 0 +592 1 0 +593 2 0 +594 0 0 +595 0 0 +596 3 0 +597 2 0 +598 1 0 +599 1 0 +600 1 0 +601 0 0 +602 2 0 +603 1 0 +604 0 0 +605 1 0 +606 0 0 +607 0 0 +608 0 0 +609 2 0 +610 1 0 +611 0 0 +612 2 0 +613 1 0 +614 1 0 +615 0 0 +616 3 0 +617 0 0 +618 0 0 +619 3 0 +620 1 0 +621 1 0 +622 0 0 +623 1 0 +624 0 0 +625 2 0 +626 0 0 +627 1 0 +628 2 0 +629 1 0 +630 1 0 +631 1 0 +632 2 0 +633 0 0 +634 1 0 +635 1 0 +636 1 0 +637 0 0 +638 2 0 +639 0 0 +640 3 0 +641 0 0 +642 2 0 +643 1 0 +644 1 0 +645 2 0 +646 2 0 +647 3 0 +648 1 0 +649 0 0 +650 2 0 +651 0 0 +652 0 0 +653 0 0 +654 1 0 +655 2 0 +656 1 0 +657 1 0 +658 1 0 +659 1 0 +660 0 0 +661 1 0 +662 0 0 +663 0 0 +664 0 0 +665 1 0 +666 0 0 +667 0 0 +668 0 0 +669 0 0 +670 0 0 +671 0 0 +672 1 0 +673 0 0 +674 0 0 +675 0 0 +676 0 0 +677 1 0 +678 2 0 +679 0 0 +680 0 0 +681 0 0 +682 1 0 +683 1 0 +684 0 0 +685 0 0 +686 0 0 +687 0 0 +688 0 0 +689 1 0 +690 0 0 +691 0 0 +692 0 0 +693 0 0 +694 0 0 +695 0 0 +696 1 0 +697 1 0 +698 0 0 +699 0 0 +700 0 0 +701 0 0 +702 1 0 +703 0 0 +704 1 0 +705 0 0 +706 0 0 +707 0 0 +708 0 0 +709 0 0 +710 1 0 +711 0 0 +712 0 0 +713 1 0 +714 2 0 +715 0 0 +716 0 0 +717 0 0 +718 0 0 +719 0 0 +720 1 0 +721 0 0 +722 0 0 +723 0 0 +724 0 0 +725 0 0 +726 0 0 +727 0 0 +728 0 0 +729 1 0 +730 0 0 +731 0 0 +732 1 0 +733 0 0 +734 1 0 +735 0 0 +736 0 0 +737 1 0 +738 0 0 +739 1 0 +740 0 0 +741 0 0 +742 0 0 +743 3 0 +744 0 0 +745 0 0 +746 0 0 +747 0 0 +748 0 0 +749 1 0 +750 0 0 +751 0 0 +752 1 0 +753 0 0 +754 1 0 +755 0 0 +756 0 0 +757 0 0 +758 0 0 +759 0 0 +760 0 0 +761 1 0 +762 0 0 +763 0 0 +764 0 0 +765 1 0 +766 0 0 +767 1 0 +768 1 0 +769 0 0 +770 1 0 +771 1 0 +772 0 0 +773 0 0 +774 0 0 +775 0 0 +776 0 0 +777 0 0 +778 0 0 +779 0 0 +780 1 0 +781 1 0 +782 0 0 +783 0 0 +784 2 0 +785 0 0 +786 0 0 +787 1 0 +788 0 0 +789 0 0 +790 1 0 +791 0 0 +792 0 0 +793 2 0 +794 1 0 +795 1 0 +796 2 0 +797 0 0 +798 0 0 +799 0 0 +800 0 0 +801 0 0 +802 1 0 +803 0 0 +804 1 0 +805 2 0 +806 0 0 +807 0 0 +808 0 0 +809 2 0 +810 2 0 +811 0 0 +812 1 0 +813 2 0 +814 0 0 +815 0 0 +816 0 0 +817 1 0 +818 0 0 +819 0 0 +820 0 0 +821 0 0 +822 2 0 +823 0 0 +824 0 0 +825 1 0 +826 0 0 +827 1 0 +828 1 0 +829 1 0 +830 0 0 +831 1 0 +832 0 0 +833 1 0 +834 1 0 +835 0 0 +836 0 0 +837 0 0 +838 1 0 +839 0 0 +840 0 0 +841 1 0 +842 1 0 +843 0 0 +844 0 0 +845 0 0 +846 0 0 +847 0 0 +848 2 0 +849 1 0 +850 1 0 +851 1 0 +852 3 0 +853 0 0 +854 0 0 +855 1 0 +856 0 0 +857 1 0 +858 1 0 +859 0 0 +860 1 0 +861 1 0 +862 2 0 + From a96e30c899c283cfe04861d1c20ac7a0f5d3f935 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 22:27:25 +0200 Subject: [PATCH 20/22] docs: add the DNA pages and the dna CLI reference Three pages under docs/dna: an overview of what the pipeline runs and writes, a mosdepth page, and a Picard page covering all four collectors. They document the things that actually catch people out rather than restating the flags: that --mapq defaults to 0 here and to 30 for rna, that mate-overlap correction is often a factor of two rather than a rounding detail, that the distribution files always emit depths 0 to 300 and never the maximum above that range, that reverse-strand reads are GC-binned by their far end, and that CollectHsMetrics and CollectWgsMetrics filter in opposite orders so their coverage figures are not comparable to each other. Each page also states plainly which columns are not reproduced and why. Co-Authored-By: Claude Opus 5 (1M context) --- docs/astro.config.mjs | 8 ++ docs/src/content/docs/dna/mosdepth.mdx | 69 ++++++++++ docs/src/content/docs/dna/overview.mdx | 99 +++++++++++++++ docs/src/content/docs/dna/picard.mdx | 118 ++++++++++++++++++ docs/src/content/docs/usage/cli-reference.mdx | 64 ++++++++++ 5 files changed, 358 insertions(+) create mode 100644 docs/src/content/docs/dna/mosdepth.mdx create mode 100644 docs/src/content/docs/dna/overview.mdx create mode 100644 docs/src/content/docs/dna/picard.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 4e1c2f42..93a77e98 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -72,6 +72,14 @@ export default defineConfig({ { label: "Samtools", slug: "rna/samtools" }, ], }, + { + label: "DNA", + items: [ + { label: "Overview", slug: "dna/overview" }, + { label: "mosdepth", slug: "dna/mosdepth" }, + { label: "Picard metrics", slug: "dna/picard" }, + ], + }, { label: "About", items: [ diff --git a/docs/src/content/docs/dna/mosdepth.mdx b/docs/src/content/docs/dna/mosdepth.mdx new file mode 100644 index 00000000..0c4e12fd --- /dev/null +++ b/docs/src/content/docs/dna/mosdepth.mdx @@ -0,0 +1,69 @@ +--- +title: mosdepth +description: Depth of coverage outputs compatible with mosdepth, and the semantics RustQC reproduces. +--- + +import { Aside } from "@astrojs/starlight/components"; + +

+ +RustQC reproduces mosdepth's outputs exactly. On the project's test alignment +every one of the six files is identical to mosdepth 0.3.14's, including the +1094-line global distribution and the 721-interval per-base BED. + +## Files + +| File | Written when | +| --- | --- | +| `{sample}.mosdepth.summary.txt` | always | +| `{sample}.mosdepth.global.dist.txt` | always | +| `{sample}.per-base.bed.gz` (+ `.csi`) | unless `--skip-per-base` | +| `{sample}.regions.bed.gz` (+ `.csi`) | with `--window-size` | +| `{sample}.mosdepth.region.dist.txt` | with `--window-size` | +| `{sample}.thresholds.bed.gz` (+ `.csi`) | with `--window-size` and thresholds | + +Compressed outputs are bgzf with a CSI companion index, so `tabix` can seek +into them just as it can into mosdepth's own. + +## What counts towards depth + +RustQC applies mosdepth's default filters: + +- records carrying any of `UNMAP`, `SECONDARY`, `QCFAIL` or `DUP` are skipped, + which is mosdepth's `-F 1796`; +- records below `--mapq` are skipped, defaulting to 0; +- `M`, `=` and `X` cover the reference; `D` and `N` advance without covering; + `I`, `S`, `H` and `P` do not advance at all; +- **a base covered by both mates of one pair counts once.** + + + +## Reading the distribution files + +`{sample}.mosdepth.global.dist.txt` holds `chrom`, `depth` and `proportion` +rows in descending depth order, where the proportion is the fraction of that +contig's bases at depth **at or above** the given value, ending at depth 0 with +`1.00`. + +Which depths get a row is worth knowing, because it is not simply "every depth +seen": + +- depths 0 through 300 always get a row, whether or not any base sits at that + exact depth; +- above 300, only depths that actually occur; +- the maximum observed depth gets a row when it falls inside that dense range, + and none when it does not. + +The region distribution follows the same rules but is computed over windows and +their **rounded mean** depth, not over individual bases. diff --git a/docs/src/content/docs/dna/overview.mdx b/docs/src/content/docs/dna/overview.mdx new file mode 100644 index 00000000..31799902 --- /dev/null +++ b/docs/src/content/docs/dna/overview.mdx @@ -0,0 +1,99 @@ +--- +title: DNA QC Overview +description: What the rustqc dna subcommand runs, what it writes, and how it differs from the RNA pipeline. +--- + +import { Aside, FileTree } from "@astrojs/starlight/components"; + +`rustqc dna` runs a DNA (whole-genome or targeted) quality control pipeline in +a single pass over each alignment file. Unlike [`rustqc rna`](/rna/dupradar/), +it needs no gene annotation. + +```bash +rustqc dna sample.bam --reference genome.fasta --outdir results/ +``` + +## What it runs + +| Upstream tool | What RustQC produces | +| --- | --- | +| [mosdepth](/dna/mosdepth/) | depth of coverage, per base, per window and per region | +| [Picard `CollectWgsMetrics`](/dna/picard/) | genome-wide coverage metrics with the exclusion breakdown | +| [Picard `CollectInsertSizeMetrics`](/dna/picard/) | insert size distribution per pair orientation | +| [Picard `CollectGcBiasMetrics`](/dna/picard/) | coverage bias against reference GC content | +| [Picard `CollectHsMetrics`](/dna/picard/) | targeted enrichment metrics, when `--targets` is given | +| [Samtools](/rna/samtools/) | `stats`, `flagstat` and `idxstats` | +| [Preseq](/rna/preseq/) | library complexity extrapolation | + +Every one of them is fed from the same record stream, so the alignment is read +once no matter how many are enabled. + +## Requirements + +The input must be **duplicate-marked, not duplicate-removed**. Duplicate rate +is a headline metric here, and several exclusion fractions are defined against +it. RustQC refuses input with no duplicate flags at all unless you pass +`--skip-dup-check`. + +A reference FASTA is needed for three things: reading CRAM, sizing +`GENOME_TERRITORY` for `CollectWgsMetrics`, and binning reference windows for +`CollectGcBiasMetrics`. Without one, those two analyses are skipped with a +warning and everything else still runs. + + + +## Output tree + + +- results/ + - mosdepth/ + - sample.mosdepth.summary.txt + - sample.mosdepth.global.dist.txt + - sample.mosdepth.region.dist.txt + - sample.per-base.bed.gz + - sample.per-base.bed.gz.csi + - sample.regions.bed.gz + - sample.thresholds.bed.gz + - picard/ + - wgs_metrics/ + - insert_size/ + - gc_bias/ + - hs_metrics/ + - samtools/ + - preseq/ + - rustqc_summary.json + - CITATIONS.md + + +Pass `--flat-output` to write everything directly into the output directory +instead. + +## Targeted mode + +Passing `--targets targets.bed` switches the run into targeted mode and adds +[`CollectHsMetrics`](/dna/picard/#collecthsmetrics). `--baits` defaults to the +same intervals; give it separately when the capture baits differ from the +regions you want reported. + +Intervals are merged on load. Overlapping targets would otherwise inflate the +reported territory and count the same base twice. + +## Memory + +The depth engine holds one array of four bytes per base for each contig being +processed, so the largest contig sets the cost per worker: roughly 1 GB for +GRCh38 chr1. `--max-depth-workers` bounds how many are live at once, defaulting +to a 4 GB budget divided by the largest contig. Raise it if you have the memory +and want more parallelism; lower it on a shared machine. + +## JSON summary + +`--json-summary` writes a machine-readable summary carrying genome length, +covered bases, mean, median and maximum coverage, the percentage of the +reference at or above each requested threshold, and the duplicate rate. The +coverage thresholds are a list rather than a map so that the order you asked +for survives. diff --git a/docs/src/content/docs/dna/picard.mdx b/docs/src/content/docs/dna/picard.mdx new file mode 100644 index 00000000..9cef0c28 --- /dev/null +++ b/docs/src/content/docs/dna/picard.mdx @@ -0,0 +1,118 @@ +--- +title: Picard metrics +description: CollectWgsMetrics, CollectInsertSizeMetrics, CollectGcBiasMetrics and CollectHsMetrics, and exactly which columns RustQC reproduces. +--- + +import { Aside } from "@astrojs/starlight/components"; + + + +RustQC reproduces four Picard collectors, validated against Picard 3.4.0. Three +match byte for byte; the fourth matches on every column that does not require a +Monte Carlo simulation. + +## CollectWgsMetrics + +Written to `picard/wgs_metrics/{sample}.wgs_metrics.txt`. Needs `--reference`, +because `GENOME_TERRITORY` counts the reference's non-N bases and cannot be +taken from the alignment header. + +Every column and all 251 histogram lines match Picard, except +`HET_SNP_SENSITIVITY` and `HET_SNP_Q`, which come from Picard's Monte Carlo +`TheoreticalSensitivity` and are written as `?`. + +### The exclusion breakdown + +This is what separates Picard's coverage from a plain depth count, and reading +it is the point of the tool. Unmapped, secondary and supplementary records +never enter the calculation. Every other record's reference-consuming bases +form the denominator of all the `PCT_EXC_*` columns. Exclusions then apply in a +fixed order: + +1. `PCT_EXC_DUPE`, the whole read, when duplicate-flagged; +2. `PCT_EXC_MAPQ`, the whole read, below the mapping quality floor; +3. `PCT_EXC_UNPAIRED`, the whole read, when unpaired; +4. `PCT_EXC_BASEQ`, per base, below the base quality floor; +5. `PCT_EXC_OVERLAP`, per base, where the mate already covered it; +6. `PCT_EXC_CAPPED`, per base, for depth beyond `--coverage-cap`. + +What survives is the high quality coverage the histogram reports. The figures +reconcile: on the test data, 670989 aligned bases less 201120 duplicate, 4933 +low quality, 217866 overlapping and 105814 capped leaves the 141256 the +histogram holds. + +`SD_COVERAGE` is the sample standard deviation over **every** base of the +territory, uncovered ones included, which is why it can dwarf the mean on a +targeted library. + +## CollectInsertSizeMetrics + +Written to `picard/insert_size/{sample}.insert_size_metrics.txt`. Matches +Picard byte for byte, metrics row and histogram. + +A pair counts when it is paired, neither secondary, supplementary, duplicate +nor unmapped, has a mapped mate, and carries a positive `TLEN`, which is what +counts each pair once. Proper-pair is deliberately not required. + +`MEAN_INSERT_SIZE` and `STANDARD_DEVIATION` are computed over the histogram +trimmed to `--deviations` median absolute deviations either side of the median, +while `MIN` and `MAX` are over the untrimmed set, so a single far outlier moves +the reported maximum but not the mean. + +## CollectGcBiasMetrics + +Written to `picard/gc_bias/{sample}.gc_bias.detail_metrics.txt` and +`.summary_metrics.txt`. Needs `--reference`. Both files match Picard byte for +byte. + +`NORMALIZED_COVERAGE` is the read density in a GC bin relative to the +genome-wide density, so 1.0 means a bin is covered exactly in proportion to how +much of the reference sits at that GC content. `AT_DROPOUT` and `GC_DROPOUT` +accumulate, over the bins where reads are under-represented, how many +percentage points of the reference are being missed. + + + +## CollectHsMetrics + +Written to `picard/hs_metrics/{sample}.hs_metrics.txt`, and only when +`--targets` is given. + +All 58 computable columns match Picard, including `HS_LIBRARY_SIZE`, which +solves the Lander-Waterman equation the same way Picard's estimator does. + +Seven columns are not computed and are written the way Picard writes its own +uncomputable values: + +| Column | Why | +| --- | --- | +| `HET_SNP_SENSITIVITY`, `HET_SNP_Q` | Monte Carlo theoretical sensitivity | +| `HS_PENALTY_10X` … `HS_PENALTY_100X` | derived from the same simulation | +| `FOLD_80_BASE_PENALTY` | derived from the same simulation | +| `AT_DROPOUT`, `GC_DROPOUT` | per-target GC binning, not implemented | + + + +## Reproducibility of the reference outputs + +The fixtures RustQC is validated against are regenerated by +`tests/create_dna_test_data.sh`, which pins the tool versions and forces the +JVM locale to English. A French default locale makes Picard write `3,531312` +where an English one writes `3.531312`, which would make the reference outputs +depend on the machine that produced them. diff --git a/docs/src/content/docs/usage/cli-reference.mdx b/docs/src/content/docs/usage/cli-reference.mdx index deca6b40..3c638844 100644 --- a/docs/src/content/docs/usage/cli-reference.mdx +++ b/docs/src/content/docs/usage/cli-reference.mdx @@ -246,6 +246,70 @@ Preseq runs by default and can be skipped entirely with `--skip-preseq`. --- +## `dna` + +DNA quality control: depth of coverage, Picard metrics, samtools-compatible +outputs and library complexity, in a single pass. Needs no annotation. See the +[DNA overview](/dna/overview/) for what each output contains. + +### Synopsis + +```bash +rustqc dna ... [OPTIONS] +``` + +### Shared options + +`-o/--outdir`, `--sample-name`, `--flat-output`, `-c/--config`, +`-j/--json-summary`, `-t/--threads`, `-p/--paired`, `-q/--quiet`, +`-v/--verbose`, `--skip-dup-check`, `--skip-preseq` and the `--preseq-*` family +all behave exactly as they do for `rna`, with the same short flags and the same +`RUSTQC_*` environment variables. + + + +### DNA-specific options + +| Option | Default | Description | +| ------------------------------- | ---------------------- | -------------------------------------------------------------------- | +| `-r, --reference ` | none | Required for CRAM, `CollectWgsMetrics` and `CollectGcBiasMetrics` | +| `--targets ` | none | Switches on targeted mode and `CollectHsMetrics` | +| `--baits ` | same as `--targets` | Capture bait intervals, when they differ from the targets | +| `--depth-thresholds ` | `1,5,10,15,20,30,50` | Coverage thresholds to report | +| `--window-size ` | none | Fixed-width windows for per-window depth, mosdepth's `--by` | +| `--coverage-cap ` | `250` | Picard `COVERAGE_CAP` | +| `--min-base-quality ` | `20` | Picard `MINIMUM_BASE_QUALITY` | +| `--skip-per-base` | off | Suppress `per-base.bed.gz`, by far the largest output | +| `--skip-gc-bias` | off | Skip `CollectGcBiasMetrics` | +| `--max-depth-workers ` | derived from RAM | Cap on concurrently live per-contig depth arrays | + +### Examples + +```bash +# Whole genome, with the reference so every metric is available +rustqc dna sample.bam --reference genome.fasta --outdir results/ + +# Per-window depth and a custom threshold set +rustqc dna sample.bam -r genome.fasta --window-size 500 --depth-thresholds 1,10,30 + +# Targeted (exome or panel) mode +rustqc dna sample.bam -r genome.fasta --targets exome.bed --outdir results/ + +# Large genome on a shared machine: bound the depth memory explicitly +rustqc dna sample.bam -r genome.fasta --threads 16 --max-depth-workers 2 +``` + + + +--- + ## Exit codes | Code | Meaning | From fc1a045ec7c86f034c0389bdf809ed3801faac2a Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 28 Aug 2026 23:13:58 +0200 Subject: [PATCH 21/22] feat(dna): reimplement Qualimap bamqc Writes genome_results.txt, ten of the raw data tables and an HTML summary. genome_results.txt matches Qualimap 2.3 on every line but four, and the clipping profile, nucleotide content and mapping quality histogram match byte for byte. Several of Qualimap's rules are surprising and none were guessable from its output alone. The reference is split into ceil(len / ceil(len / 400)) windows, so 397 rather than 400. Coverage counts every primary mapped record with no filtering at all, counts deletions, and does not correct mate overlaps, which is why it reports 16.77 where mosdepth reports 6.20. The global mean mapping quality is the mean of the per-window means with empty windows contributing zero, hence 2.4178 rather than about 60, while the per-position histogram truncates that mean instead of rounding it. Mismatches are NM less inserted bases only. Base composition is counted in reference orientation while the clipped span selecting which positions count is taken in sequencing orientation; mixing the two is what Qualimap does and matching it is the only way the composition agrees. Four residuals are documented rather than papered over. The mean mapping quality and the coverage standard deviation differ in the fourth decimal because Qualimap accumulates them per window. About five reference positions of 40001 sit one deeper here, which carries into the coverage histogram and the fractions derived from it. The homopolymer indel classification differs outright: Qualimap reads a reference context this does not reconstruct, and reports two polyC indels that no read-derived rule produces, since the deleted bases are not in the read. Qualimap's GC content distribution and duplication rate histogram are not written. The first is computed over a 679-read subsample whose selection rule is undocumented, the second uses a definition that is not a read-start count. Emitting tables under those names with different numbers would be worse than leaving them out. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + CHANGELOG.md | 5 +- docs/astro.config.mjs | 1 + docs/src/content/docs/dna/qualimap.mdx | 69 ++ src/citations.rs | 3 + src/config.rs | 36 + src/dna/mod.rs | 2 + src/dna/qualimap.rs | 648 ++++++++++++++++ src/dna/qualimap_output.rs | 703 ++++++++++++++++++ src/main.rs | 51 +- tests/create_dna_test_data.sh | 27 +- tests/data/dna/test.dna.bam | Bin 193636 -> 193634 bytes tests/data/dna/test.dna.bam.bai | Bin 96 -> 96 bytes tests/dna_integration_test.rs | 385 ++++++++++ tests/expected/dna/VERSIONS.txt | 1 + .../expected/dna/qualimap/genome_results.txt | 129 ++++ .../coverage_across_reference.txt | 398 ++++++++++ .../coverage_histogram.txt | 590 +++++++++++++++ .../duplication_rate_histogram.txt | 51 ++ .../genome_fraction_coverage.txt | 52 ++ .../homopolymer_indels.txt | 7 + .../insert_size_across_reference.txt | 398 ++++++++++ .../insert_size_histogram.txt | 171 +++++ .../mapped_reads_clipping_profile.txt | 144 ++++ .../mapped_reads_gc-content_distribution.txt | 101 +++ .../mapped_reads_nucleotide_content.txt | 144 ++++ .../mapping_quality_across_reference.txt | 398 ++++++++++ .../mapping_quality_histogram.txt | 3 + 28 files changed, 4513 insertions(+), 6 deletions(-) create mode 100644 docs/src/content/docs/dna/qualimap.mdx create mode 100644 src/dna/qualimap.rs create mode 100644 src/dna/qualimap_output.rs create mode 100644 tests/expected/dna/qualimap/genome_results.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_across_reference.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_histogram.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/duplication_rate_histogram.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/genome_fraction_coverage.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/homopolymer_indels.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_across_reference.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_histogram.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_clipping_profile.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_gc-content_distribution.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_nucleotide_content.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_across_reference.txt create mode 100644 tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_histogram.txt diff --git a/AGENTS.md b/AGENTS.md index 9efe8343..3856f70d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,8 @@ src/ hs_metrics.rs — Picard CollectHsMetrics reimplementation (targeted mode) insert_size.rs — Picard CollectInsertSizeMetrics reimplementation intervals.rs — BED interval parsing and merging for targeted mode + qualimap.rs — Qualimap bamqc accumulation (windows, coverage, composition) + qualimap_output.rs — genome_results.txt, the raw data tables and the HTML report wgs_metrics.rs — Picard CollectWgsMetrics reimplementation mosdepth/ mod.rs — Per-contig summarisation feeding the mosdepth outputs diff --git a/CHANGELOG.md b/CHANGELOG.md index 472def88..6566d048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ alignment with one worker per contig, plus Picard-compatible CollectWgsMetrics, CollectInsertSizeMetrics and CollectGcBiasMetrics. Passing `--targets` switches on targeted mode and Picard-compatible - CollectHsMetrics. Validated for exact parity against mosdepth 0.3.14, - samtools 1.24 and Picard 3.4.0. + CollectHsMetrics. Qualimap-compatible `bamqc` output rounds it out, with + `genome_results.txt`, the raw data tables and an HTML summary. Validated + against mosdepth 0.3.14, samtools 1.24, Picard 3.4.0 and Qualimap 2.3. ### Changed diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 93a77e98..ca7e6f08 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -78,6 +78,7 @@ export default defineConfig({ { label: "Overview", slug: "dna/overview" }, { label: "mosdepth", slug: "dna/mosdepth" }, { label: "Picard metrics", slug: "dna/picard" }, + { label: "Qualimap bamqc", slug: "dna/qualimap" }, ], }, { diff --git a/docs/src/content/docs/dna/qualimap.mdx b/docs/src/content/docs/dna/qualimap.mdx new file mode 100644 index 00000000..5d350b15 --- /dev/null +++ b/docs/src/content/docs/dna/qualimap.mdx @@ -0,0 +1,69 @@ +--- +title: Qualimap bamqc +description: The bamqc outputs RustQC produces, how Qualimap's coverage differs from every other tool here, and which figures are not reproduced. +--- + +import { Aside } from "@astrojs/starlight/components"; + + + +RustQC writes `qualimap/genome_results.txt`, the +`raw_data_qualimapReport/` tables and an HTML summary. + + + +## Two figures that surprise people + +**Mean mapping quality reads about 2.4, not about 60.** It is the mean of the +per-window means, and a window with no reads contributes zero. On a targeted or +low-coverage library, most windows are empty, so the figure is closer to the +fraction of the genome covered than to the quality of the alignments. The +per-position histogram, which only counts covered positions, is the one to read +for that. + +**Base composition is reported in reference orientation.** Reverse-strand reads +are reverse-complemented before counting, so the A and T columns are not the +counts of A and T in the sequencer's output. + +## What matches Qualimap and what does not + +`genome_results.txt` matches on every line but four, and three of the raw +tables match byte for byte. The residuals, each with its cause: + +| Figure | Difference | +| --- | --- | +| `mean mapping quality` | fourth decimal; 393 of 397 windows match exactly | +| `std coverageData` | fourth decimal, same cause | +| `homopolymer indels` | differs outright, see below | +| coverage histogram and what derives from it | about five reference positions of 40001 sit one deeper | +| `genome_fraction_coverage` | last two digits of the double, Qualimap accumulates per window | +| `insert_size_histogram` | one extra row: Qualimap trims the largest insert from the plotted table while still counting it in the statistics | + +Qualimap classifies an indel as a homopolymer indel against a reference context +RustQC does not reconstruct. It reports two polyC indels on the test data, and +no rule derived from the read alone produces them, because the deleted bases +are not in the read. RustQC uses a run-of-four rule instead, so this one figure +will differ. + +Qualimap's GC content distribution and duplication rate histogram are not +written at all. The first is computed over a 679-read subsample whose selection +rule is not documented; the second uses a definition that does not match a +read-start-position count. Emitting tables under those names with different +numbers would be worse than leaving them out. + +## The HTML report + +RustQC writes its own summary page rather than a copy of Qualimap's, which +ships a bundle of images, CSS and JavaScript. It carries the same numbers as +`genome_results.txt`; the raw tables remain the machine-readable source. diff --git a/src/citations.rs b/src/citations.rs index 6c968f2b..0fb4c376 100644 --- a/src/citations.rs +++ b/src/citations.rs @@ -148,6 +148,9 @@ pub fn write_dna_citations( if config.samtools.enabled { write_citation(&mut w, &SAMTOOLS_DNA)?; } + if config.qualimap.enabled { + write_citation(&mut w, &QUALIMAP)?; + } if config.preseq.enabled { write_citation(&mut w, &PRESEQ)?; } diff --git a/src/config.rs b/src/config.rs index 365e1b6b..0045116d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -988,6 +988,10 @@ pub struct DnaConfig { #[serde(default)] pub hs_metrics: HsMetricsConfig, + /// Qualimap bamqc configuration. + #[serde(default)] + pub qualimap: BamqcConfig, + /// preseq lc_extrap library complexity extrapolation configuration. /// /// Reuses the same type as the `rna` pipeline; the implementation is shared. @@ -1032,6 +1036,38 @@ impl Default for WgsMetricsConfig { } } +/// Configuration for the Qualimap-compatible bamqc report. +/// +/// Named apart from the `rna` pipeline's [`QualimapConfig`], which configures +/// a different Qualimap analysis entirely: gene body coverage rather than +/// bamqc. +/// +/// Example: +/// ```yaml +/// qualimap: +/// enabled: true +/// num_windows: 400 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct BamqcConfig { + /// Whether to produce the bamqc outputs. Defaults to true. + pub enabled: bool, + /// Target number of windows the reference is split into. The realised + /// count is usually a little lower, because the window width is rounded up + /// first. + pub num_windows: usize, +} + +impl Default for BamqcConfig { + fn default() -> Self { + Self { + enabled: true, + num_windows: 400, + } + } +} + /// Configuration for the Picard-compatible GC bias metrics. /// /// Requires a reference FASTA: the analysis bins reference windows by GC. diff --git a/src/dna/mod.rs b/src/dna/mod.rs index bf2c9729..d924de1c 100644 --- a/src/dna/mod.rs +++ b/src/dna/mod.rs @@ -10,4 +10,6 @@ pub mod hs_metrics; pub mod insert_size; pub mod intervals; pub mod mosdepth; +pub mod qualimap; +pub mod qualimap_output; pub mod wgs_metrics; diff --git a/src/dna/qualimap.rs b/src/dna/qualimap.rs new file mode 100644 index 00000000..ae2e5905 --- /dev/null +++ b/src/dna/qualimap.rs @@ -0,0 +1,648 @@ +//! Qualimap `bamqc` reimplementation. +//! +//! # Upstream semantics +//! +//! Derived by reproducing Qualimap 2.3's own output on the project fixture +//! until each figure matched. Several rules are surprising and none of them +//! are guessable, so they are recorded here. +//! +//! **Windows.** The reference is split into `ceil(len / ceil(len / 400))` +//! windows, which is 397 windows of 101 bases on the 40001 base fixture, not +//! the round 400 the option name suggests. +//! +//! **Coverage.** Every primary mapped record contributes, with no duplicate, +//! mapping quality or base quality filtering and **no mate-overlap +//! correction**. Deletions count as covered. That is why Qualimap reports 16.77 +//! mean coverage where mosdepth reports 6.20 on the same file: they are +//! measuring different things, and neither is wrong. +//! +//! **Mapping quality.** The global figure is the mean of the per-window means, +//! where a window with no reads contributes zero. That is why it reads 2.4178 +//! rather than about 60. The per-position histogram truncates the mean rather +//! than rounding it. +//! +//! **Base composition.** Bases are counted in reference orientation, so +//! reverse-strand reads are reverse-complemented, but the clipped span that +//! selects which positions count is taken in *sequencing* orientation. Mixing +//! the two orientations is what Qualimap does; matching it means doing the +//! same. +//! +//! **Mismatches** are the `NM` tag less inserted bases only. Deleted bases are +//! not subtracted, which is what puts the fixture at 1350 rather than 1340. + +use rust_htslib::bam; +use rust_htslib::bam::record::{Aux, Cigar}; +use std::collections::BTreeMap; + +use crate::common::bam_flags::*; + +/// Qualimap's default target number of windows. +pub const DEFAULT_NUM_WINDOWS: usize = 400; + +/// Highest coverage level reported in the genome fraction table. +const MAX_FRACTION_LEVEL: u32 = 51; + +/// Per-contig accumulation for one alignment file. +#[derive(Debug)] +pub struct QualimapAccum { + contig: String, + length: u64, + window_size: u64, + /// Coverage per reference base, counting `M`, `=`, `X` and `D`. + coverage: Vec, + /// Sum of mapping quality over the reads covering each base. + mapq_sum: Vec, + /// Per-window sum of insert sizes and the number of reads contributing. + insert_window_sum: Vec, + insert_window_count: Vec, + counters: QualimapCounters, +} + +/// Read-level counters, summed across contigs. +#[derive(Debug, Clone, Default)] +pub struct QualimapCounters { + /// Records seen, secondary alignments excluded and counted separately. + pub reads: u64, + /// Secondary alignments. + pub secondary: u64, + /// Mapped records. + pub mapped: u64, + /// Duplicate-flagged records. + pub duplicates: u64, + /// Mapped first-in-pair records with a mapped mate. + pub paired_first: u64, + /// Mapped second-in-pair records with a mapped mate. + pub paired_second: u64, + /// Mapped paired records whose mate is also mapped. + pub paired_both: u64, + /// Mapped paired records whose mate is not mapped. + pub singletons: u64, + /// Reference-consuming aligned bases, `M`, `=` and `X`. + pub sequenced_bases: u64, + /// Those plus deleted bases. + pub mapped_bases: u64, + /// Sum of the `NM` tag over mapped records. + pub edit_distance: u64, + /// Inserted bases. + pub insertions: u64, + /// Deleted bases. + pub deletions: u64, + /// Records carrying at least one insertion. + pub reads_with_insertion: u64, + /// Records carrying at least one deletion. + pub reads_with_deletion: u64, + /// Base composition in reference orientation, indexed by [`base_index`]. + pub base_counts: [u64; 5], + /// Insert size histogram over positive `TLEN` values. + pub insert_sizes: BTreeMap, + /// Per read position base composition, in reference orientation. + pub nucleotide_by_position: Vec<[u64; 5]>, + /// Per read position count of clipped bases. + pub clipping_by_position: Vec, + /// Total clipped bases, the denominator of the clipping profile. + pub clipped_bases: u64, + /// Homopolymer indel counts, indexed by [`base_index`], plus non-polymer. + pub homopolymer_indels: [u64; 5], + /// Indels not adjacent to a homopolymer run. + pub non_polymer_indels: u64, +} + +/// Index of a base in the fixed `A, C, G, T, N` ordering. +fn base_index(base: u8) -> usize { + match base.to_ascii_uppercase() { + b'A' => 0, + b'C' => 1, + b'G' => 2, + b'T' => 3, + _ => 4, + } +} + +/// The complement of a base, leaving anything unrecognised alone. +fn complement(base: u8) -> u8 { + match base.to_ascii_uppercase() { + b'A' => b'T', + b'C' => b'G', + b'G' => b'C', + b'T' => b'A', + other => other, + } +} + +impl QualimapCounters { + /// Add another contig's counters. + pub fn merge(&mut self, other: &QualimapCounters) { + self.reads += other.reads; + self.secondary += other.secondary; + self.mapped += other.mapped; + self.duplicates += other.duplicates; + self.paired_first += other.paired_first; + self.paired_second += other.paired_second; + self.paired_both += other.paired_both; + self.singletons += other.singletons; + self.sequenced_bases += other.sequenced_bases; + self.mapped_bases += other.mapped_bases; + self.edit_distance += other.edit_distance; + self.insertions += other.insertions; + self.deletions += other.deletions; + self.reads_with_insertion += other.reads_with_insertion; + self.reads_with_deletion += other.reads_with_deletion; + self.clipped_bases += other.clipped_bases; + self.non_polymer_indels += other.non_polymer_indels; + for (target, source) in self.base_counts.iter_mut().zip(&other.base_counts) { + *target += source; + } + for (target, source) in self + .homopolymer_indels + .iter_mut() + .zip(&other.homopolymer_indels) + { + *target += source; + } + for (size, count) in &other.insert_sizes { + *self.insert_sizes.entry(*size).or_insert(0) += count; + } + if self.nucleotide_by_position.len() < other.nucleotide_by_position.len() { + self.nucleotide_by_position + .resize(other.nucleotide_by_position.len(), [0; 5]); + } + for (position, counts) in other.nucleotide_by_position.iter().enumerate() { + for (target, source) in self.nucleotide_by_position[position].iter_mut().zip(counts) { + *target += source; + } + } + if self.clipping_by_position.len() < other.clipping_by_position.len() { + self.clipping_by_position + .resize(other.clipping_by_position.len(), 0); + } + for (position, count) in other.clipping_by_position.iter().enumerate() { + self.clipping_by_position[position] += count; + } + } + + /// Mismatches, which Qualimap takes as `NM` less inserted bases only. + pub fn mismatches(&self) -> u64 { + self.edit_distance.saturating_sub(self.insertions) + } + + /// Mismatches, insertions and deletions over sequenced bases. + pub fn general_error_rate(&self) -> f64 { + if self.sequenced_bases == 0 { + return 0.0; + } + (self.mismatches() + self.insertions + self.deletions) as f64 / self.sequenced_bases as f64 + } + + /// Fraction of indels adjacent to a homopolymer run. + pub fn homopolymer_fraction(&self) -> f64 { + let poly: u64 = self.homopolymer_indels.iter().sum(); + let total = poly + self.non_polymer_indels; + if total == 0 { + 0.0 + } else { + poly as f64 / total as f64 + } + } + + /// Mean, population standard deviation and median insert size. + pub fn insert_size_stats(&self) -> (f64, f64, u64) { + let n: u64 = self.insert_sizes.values().sum(); + if n == 0 { + return (0.0, 0.0, 0); + } + let mean = self + .insert_sizes + .iter() + .map(|(size, count)| *size as f64 * *count as f64) + .sum::() + / n as f64; + let variance = self + .insert_sizes + .iter() + .map(|(size, count)| { + let diff = *size as f64 - mean; + diff * diff * *count as f64 + }) + .sum::() + / n as f64; + let mut seen = 0u64; + let mut median = 0u64; + for (size, count) in &self.insert_sizes { + seen += count; + if seen > n / 2 { + median = *size; + break; + } + } + (mean, variance.sqrt(), median) + } +} + +impl QualimapAccum { + /// Prepare for one contig, splitting it into Qualimap's window grid. + pub fn new(contig: &str, length: u64, num_windows: usize) -> Self { + let window_size = length.div_ceil(num_windows as u64).max(1); + let windows = length.div_ceil(window_size) as usize; + Self { + contig: contig.to_string(), + length, + window_size, + coverage: vec![0; length as usize], + mapq_sum: vec![0; length as usize], + insert_window_sum: vec![0; windows], + insert_window_count: vec![0; windows], + counters: QualimapCounters::default(), + } + } + + /// Number of windows this contig is split into. + pub fn window_count(&self) -> usize { + self.insert_window_sum.len() + } + + /// Width of each window; the last one may be shorter. + pub fn window_size(&self) -> u64 { + self.window_size + } + + /// Offer one record. + pub fn process_read(&mut self, record: &bam::Record) { + let flags = record.flags(); + if flags & BAM_FSECONDARY != 0 { + self.counters.secondary += 1; + return; + } + self.counters.reads += 1; + if flags & BAM_FUNMAP != 0 { + return; + } + self.counters.mapped += 1; + if flags & BAM_FDUP != 0 { + self.counters.duplicates += 1; + } + + if flags & BAM_FPAIRED != 0 { + if flags & BAM_FMUNMAP != 0 { + self.counters.singletons += 1; + } else { + self.counters.paired_both += 1; + if flags & BAM_FREAD1 != 0 { + self.counters.paired_first += 1; + } + if flags & BAM_FREAD2 != 0 { + self.counters.paired_second += 1; + } + } + } + + let mapq = u64::from(record.mapq()); + let sequence = record.seq().as_bytes(); + let reverse = flags & BAM_FREVERSE != 0; + + // Bases in reference orientation: reverse-complemented for a + // reverse-strand read. + let oriented: Vec = if reverse { + sequence.iter().rev().map(|b| complement(*b)).collect() + } else { + sequence.clone() + }; + + let cigar = record.cigar(); + let ops: Vec = cigar.iter().copied().collect(); + + // The clipped span is taken in sequencing orientation, unlike the + // bases. That asymmetry is Qualimap's, and reproducing it is the only + // way the composition figures agree. + let leading_clip = match ops.first() { + Some(Cigar::SoftClip(n)) | Some(Cigar::HardClip(n)) => *n as usize, + _ => 0, + }; + let trailing_clip = match ops.last() { + Some(Cigar::SoftClip(n)) | Some(Cigar::HardClip(n)) => *n as usize, + _ => 0, + }; + + let read_len = sequence.len(); + if self.counters.nucleotide_by_position.len() < read_len { + self.counters + .nucleotide_by_position + .resize(read_len, [0; 5]); + self.counters.clipping_by_position.resize(read_len, 0); + } + for position in 0..leading_clip.min(read_len) { + self.counters.clipping_by_position[position] += 1; + self.counters.clipped_bases += 1; + } + for offset in 0..trailing_clip.min(read_len) { + let position = read_len - 1 - offset; + self.counters.clipping_by_position[position] += 1; + self.counters.clipped_bases += 1; + } + for position in leading_clip..read_len.saturating_sub(trailing_clip) { + let base = oriented.get(position).copied().unwrap_or(b'N'); + self.counters.nucleotide_by_position[position][base_index(base)] += 1; + } + + if let Ok(Aux::U8(nm)) = record.aux(b"NM") { + self.counters.edit_distance += u64::from(nm); + } else if let Ok(Aux::U16(nm)) = record.aux(b"NM") { + self.counters.edit_distance += u64::from(nm); + } else if let Ok(Aux::U32(nm)) = record.aux(b"NM") { + self.counters.edit_distance += u64::from(nm); + } else if let Ok(Aux::I32(nm)) = record.aux(b"NM") { + self.counters.edit_distance += nm.max(0) as u64; + } + + let mut reference_position = record.pos(); + let mut query_position = 0usize; + let mut had_insertion = false; + let mut had_deletion = false; + + for op in &ops { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + let n = *n as usize; + for k in 0..n { + let position = reference_position + k as i64; + if position >= 0 && (position as usize) < self.coverage.len() { + self.coverage[position as usize] += 1; + self.mapq_sum[position as usize] += mapq; + } + let base = oriented.get(query_position + k).copied().unwrap_or(b'N'); + self.counters.base_counts[base_index(base)] += 1; + } + self.counters.sequenced_bases += n as u64; + self.counters.mapped_bases += n as u64; + reference_position += n as i64; + query_position += n; + } + Cigar::Del(n) => { + let n = *n as usize; + for k in 0..n { + let position = reference_position + k as i64; + if position >= 0 && (position as usize) < self.coverage.len() { + self.coverage[position as usize] += 1; + self.mapq_sum[position as usize] += mapq; + } + } + self.counters.mapped_bases += n as u64; + self.counters.deletions += n as u64; + had_deletion = true; + self.classify_indel(&oriented, query_position); + reference_position += n as i64; + } + Cigar::Ins(n) => { + self.counters.insertions += u64::from(*n); + had_insertion = true; + self.classify_indel(&oriented, query_position); + query_position += *n as usize; + } + Cigar::RefSkip(n) => reference_position += i64::from(*n), + Cigar::SoftClip(n) => query_position += *n as usize, + Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + if had_insertion { + self.counters.reads_with_insertion += 1; + } + if had_deletion { + self.counters.reads_with_deletion += 1; + } + + let insert_size = record.insert_size(); + if insert_size > 0 { + *self + .counters + .insert_sizes + .entry(insert_size as u64) + .or_insert(0) += 1; + let window = (record.pos().max(0) as u64 / self.window_size) as usize; + if window < self.insert_window_sum.len() { + self.insert_window_sum[window] += insert_size; + self.insert_window_count[window] += 1; + } + } + } + + /// Charge an indel to a homopolymer bucket when the bases either side of + /// it repeat, and to the non-polymer bucket otherwise. + fn classify_indel(&mut self, oriented: &[u8], query_position: usize) { + const RUN: usize = 4; + let start = query_position.saturating_sub(RUN); + let window = &oriented[start..query_position.min(oriented.len())]; + if window.len() == RUN && window.iter().all(|b| *b == window[0]) { + self.counters.homopolymer_indels[base_index(window[0])] += 1; + } else { + self.counters.non_polymer_indels += 1; + } + } + + /// Consume the accumulator into its per-contig result. + pub fn into_result(self) -> ContigQualimap { + let window_size = self.window_size; + let windows = self.insert_window_sum.len(); + let mut window_coverage = Vec::with_capacity(windows); + let mut window_coverage_sd = Vec::with_capacity(windows); + let mut window_mapq = Vec::with_capacity(windows); + let mut window_insert = Vec::with_capacity(windows); + let mut midpoints = Vec::with_capacity(windows); + + for window in 0..windows { + let start = window as u64 * window_size; + let end = ((window as u64 + 1) * window_size).min(self.length); + let span = &self.coverage[start as usize..end as usize]; + let mapq_span = &self.mapq_sum[start as usize..end as usize]; + + let mean = span.iter().map(|c| f64::from(*c)).sum::() / span.len() as f64; + let variance = span + .iter() + .map(|c| { + let diff = f64::from(*c) - mean; + diff * diff + }) + .sum::() + / span.len() as f64; + let covered: u64 = span.iter().map(|c| u64::from(*c)).sum(); + let mapq_total: u64 = mapq_span.iter().sum(); + + window_coverage.push(mean); + window_coverage_sd.push(variance.sqrt()); + window_mapq.push(if covered == 0 { + 0.0 + } else { + mapq_total as f64 / covered as f64 + }); + window_insert.push(if self.insert_window_count[window] == 0 { + 0.0 + } else { + self.insert_window_sum[window] as f64 / self.insert_window_count[window] as f64 + }); + midpoints.push((start + end + 1) as f64 / 2.0); + } + + let mut coverage_histogram: BTreeMap = BTreeMap::new(); + let mut mapq_histogram: BTreeMap = BTreeMap::new(); + for (position, depth) in self.coverage.iter().enumerate() { + *coverage_histogram.entry(*depth).or_insert(0) += 1; + if *depth > 0 { + // Truncated, not rounded: this is what Qualimap does. + let mean = self.mapq_sum[position] / u64::from(*depth); + *mapq_histogram.entry(mean as u32).or_insert(0) += 1; + } + } + + ContigQualimap { + name: self.contig, + length: self.length, + coverage: self.coverage, + window_size, + midpoints, + window_coverage, + window_coverage_sd, + window_mapq, + window_insert, + coverage_histogram, + mapq_histogram, + counters: self.counters, + } + } +} + +/// One contig's Qualimap result. +#[derive(Debug, Clone)] +pub struct ContigQualimap { + /// Contig name. + pub name: String, + /// Contig length. + pub length: u64, + /// Per-base coverage. + pub coverage: Vec, + /// Window width. + pub window_size: u64, + /// Window midpoints, as Qualimap reports positions. + pub midpoints: Vec, + /// Mean coverage per window. + pub window_coverage: Vec, + /// Coverage standard deviation per window. + pub window_coverage_sd: Vec, + /// Mean mapping quality per window, zero where uncovered. + pub window_mapq: Vec, + /// Mean insert size per window. + pub window_insert: Vec, + /// Bases at each exact coverage. + pub coverage_histogram: BTreeMap, + /// Covered bases at each truncated mean mapping quality. + pub mapq_histogram: BTreeMap, + /// Read-level counters gathered on this contig. + pub counters: QualimapCounters, +} + +impl ContigQualimap { + /// Mean coverage over the contig. + pub fn mean_coverage(&self) -> f64 { + if self.length == 0 { + 0.0 + } else { + self.coverage.iter().map(|c| f64::from(*c)).sum::() / self.length as f64 + } + } + + /// Population standard deviation of per-base coverage. + pub fn coverage_sd(&self) -> f64 { + if self.length == 0 { + return 0.0; + } + let mean = self.mean_coverage(); + let variance = self + .coverage + .iter() + .map(|c| { + let diff = f64::from(*c) - mean; + diff * diff + }) + .sum::() + / self.length as f64; + variance.sqrt() + } + + /// Mean of the per-window mapping qualities, uncovered windows included. + pub fn mean_mapping_quality(&self) -> f64 { + if self.window_mapq.is_empty() { + 0.0 + } else { + self.window_mapq.iter().sum::() / self.window_mapq.len() as f64 + } + } + + /// Percentage of the contig at or above each coverage level. + pub fn genome_fraction(&self) -> Vec<(u32, f64)> { + (1..=MAX_FRACTION_LEVEL) + .map(|level| { + let at_or_above = self.coverage.iter().filter(|c| **c >= level).count(); + (level, 100.0 * at_or_above as f64 / self.length as f64) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_grid_matches_qualimaps_arithmetic() { + // 40001 bases into 400 windows: 101 bases each, and 397 of them. + let accum = QualimapAccum::new("chr22", 40001, DEFAULT_NUM_WINDOWS); + assert_eq!(accum.window_size(), 101); + assert_eq!(accum.window_count(), 397); + } + + #[test] + fn a_short_contig_still_gets_one_window() { + let accum = QualimapAccum::new("small", 10, DEFAULT_NUM_WINDOWS); + assert_eq!(accum.window_size(), 1); + assert_eq!(accum.window_count(), 10); + } + + #[test] + fn mismatches_subtract_insertions_but_not_deletions() { + let mut counters = QualimapCounters { + edit_distance: 1352, + insertions: 2, + deletions: 10, + ..Default::default() + }; + assert_eq!(counters.mismatches(), 1350, "deletions are not subtracted"); + counters.deletions = 0; + assert_eq!(counters.mismatches(), 1350); + } + + #[test] + fn insert_size_statistics_use_the_population_denominator() { + let mut counters = QualimapCounters::default(); + for size in [1u64, 2, 3] { + counters.insert_sizes.insert(size, 1); + } + let (mean, sd, median) = counters.insert_size_stats(); + assert!((mean - 2.0).abs() < 1e-12); + // Population variance of 1, 2, 3 is 2/3. + assert!((sd - (2.0f64 / 3.0).sqrt()).abs() < 1e-12, "got {sd}"); + assert_eq!(median, 2); + } + + #[test] + fn base_indexing_folds_anything_unknown_into_n() { + assert_eq!(base_index(b'A'), 0); + assert_eq!(base_index(b'c'), 1); + assert_eq!(base_index(b'N'), 4); + assert_eq!(base_index(b'R'), 4, "ambiguity codes are counted as N"); + } + + #[test] + fn complement_leaves_unknown_bases_alone() { + assert_eq!(complement(b'A'), b'T'); + assert_eq!(complement(b'g'), b'C'); + assert_eq!(complement(b'N'), b'N'); + assert_eq!(complement(b'R'), b'R'); + } +} diff --git a/src/dna/qualimap_output.rs b/src/dna/qualimap_output.rs new file mode 100644 index 00000000..31e09978 --- /dev/null +++ b/src/dna/qualimap_output.rs @@ -0,0 +1,703 @@ +//! Writers for the Qualimap `bamqc` outputs. +//! +//! The formats are reproduced from Qualimap 2.3's own output. Two details are +//! easy to miss: integers carry thousands separators, and the "Mismatches and +//! indels" section is indented by four spaces where every other section uses +//! five. +//! +//! # Figures that do not match exactly +//! +//! - `mean mapping quality` differs in the fourth decimal, 2.4179 against +//! 2.4178 on the project fixture. It is the mean of the per-window means; +//! 393 of the 397 windows match exactly and the four that do not differ by +//! at most 0.053, which is consistent with Qualimap accumulating them +//! differently at window boundaries. +//! - `std coverageData` differs in the fourth decimal, 154.9340 against +//! 154.9323, for the same reason. +//! - `homopolymer indels` is computed here as an indel flanked by a run of +//! four identical bases. Qualimap's own definition was not recovered: no +//! combination of run length from two to five, read orientation or direction +//! reproduces its split of 7 homopolymer against 5 other indels, so this +//! figure differs. +//! - The coverage histogram differs in 10 bins of roughly 590, always by one +//! base and always between adjacent bins, so about five reference positions +//! out of 40001 sit one deeper here than in Qualimap. That carries into the +//! `coverageData >= NX` lines, which agree to within 0.003 percentage +//! points. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; + +use super::qualimap::ContigQualimap; + +/// Format an integer with thousands separators, as Qualimap does. +fn thousands(value: u64) -> String { + let digits = value.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// Format a percentage rounded to `places` decimals, trailing zeros removed. +fn trimmed(value: f64, places: usize) -> String { + let text = format!("{value:.places$}"); + if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + text + } +} + +/// Percentage of `part` in `whole`, guarding against an empty denominator. +fn pct(part: u64, whole: u64) -> f64 { + if whole == 0 { + 0.0 + } else { + // Divide before multiplying, as Qualimap does: the other order moves + // the last two digits of the printed double. + part as f64 / whole as f64 * 100.0 + } +} + +/// Format a double the way Java's `Double.toString` does, which is what +/// Qualimap's tables carry: the shortest representation that round-trips, but +/// always with at least one digit after the point, so `0` is written `0.0`. +fn java_double(value: f64) -> String { + let text = format!("{value}"); + if text.contains('.') || text.contains('e') || text.contains("NaN") || text.contains("inf") { + text + } else { + format!("{text}.0") + } +} + +/// Write `genome_results.txt`. +pub fn write_genome_results( + contigs: &[ContigQualimap], + bam_path: &str, + outfile: &Path, +) -> Result<()> { + let mut out = std::fs::File::create(outfile) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create genome results: {}", outfile.display()))?; + + let total_length: u64 = contigs.iter().map(|c| c.length).sum(); + let mut counters = super::qualimap::QualimapCounters::default(); + for contig in contigs { + counters.merge(&contig.counters); + } + let windows: usize = contigs.iter().map(|c| c.midpoints.len()).sum(); + + writeln!(out, "BamQC report")?; + writeln!(out, "-----------------------------------")?; + writeln!(out)?; + writeln!(out, ">>>>>>> Input")?; + writeln!(out)?; + writeln!(out, " bam file = {bam_path}")?; + writeln!(out, " outfile = {}", outfile.display())?; + writeln!(out)?; + writeln!(out)?; + + writeln!(out, ">>>>>>> Reference")?; + writeln!(out)?; + writeln!(out, " number of bases = {} bp", thousands(total_length))?; + writeln!(out, " number of contigs = {}", contigs.len())?; + writeln!(out)?; + writeln!(out)?; + + writeln!(out, ">>>>>>> Globals")?; + writeln!(out)?; + writeln!(out, " number of windows = {windows}")?; + writeln!(out)?; + writeln!(out, " number of reads = {}", thousands(counters.reads))?; + writeln!( + out, + " number of mapped reads = {} ({}%)", + thousands(counters.mapped), + trimmed(pct(counters.mapped, counters.reads), 2) + )?; + writeln!( + out, + " number of secondary alignments = {}", + thousands(counters.secondary) + )?; + writeln!(out)?; + writeln!( + out, + " number of mapped paired reads (first in pair) = {}", + thousands(counters.paired_first) + )?; + writeln!( + out, + " number of mapped paired reads (second in pair) = {}", + thousands(counters.paired_second) + )?; + writeln!( + out, + " number of mapped paired reads (both in pair) = {}", + thousands(counters.paired_both) + )?; + writeln!( + out, + " number of mapped paired reads (singletons) = {}", + thousands(counters.singletons) + )?; + writeln!(out)?; + writeln!( + out, + " number of mapped bases = {} bp", + thousands(counters.mapped_bases) + )?; + writeln!( + out, + " number of sequenced bases = {} bp", + thousands(counters.sequenced_bases) + )?; + // Qualimap reports this only when run with a reference; without one it is + // zero, which is what RustQC always is here. + writeln!(out, " number of aligned bases = 0 bp")?; + writeln!( + out, + " number of duplicated reads (flagged) = {}", + thousands(counters.duplicates) + )?; + writeln!(out)?; + writeln!(out)?; + + let (insert_mean, insert_sd, insert_median) = counters.insert_size_stats(); + writeln!(out, ">>>>>>> Insert size")?; + writeln!(out)?; + writeln!(out, " mean insert size = {insert_mean:.4}")?; + writeln!(out, " std insert size = {insert_sd:.4}")?; + writeln!(out, " median insert size = {insert_median}")?; + writeln!(out)?; + writeln!(out)?; + + let mean_mapq = if contigs.is_empty() { + 0.0 + } else { + contigs + .iter() + .map(|c| c.mean_mapping_quality()) + .sum::() + / contigs.len() as f64 + }; + writeln!(out, ">>>>>>> Mapping quality")?; + writeln!(out)?; + writeln!(out, " mean mapping quality = {mean_mapq:.4}")?; + writeln!(out)?; + writeln!(out)?; + + let bases: u64 = counters.base_counts.iter().sum(); + writeln!(out, ">>>>>>> ACTG content")?; + writeln!(out)?; + for (label, index) in [("A", 0), ("C", 1), ("T", 3), ("G", 2), ("N", 4)] { + writeln!( + out, + " number of {label}'s = {} bp ({}%)", + thousands(counters.base_counts[index]), + trimmed(pct(counters.base_counts[index], bases), 2) + )?; + } + writeln!(out)?; + let gc = counters.base_counts[1] + counters.base_counts[2]; + writeln!(out, " GC percentage = {}%", trimmed(pct(gc, bases), 2))?; + writeln!(out)?; + writeln!(out)?; + + // Note the four-space indent: this section is the odd one out. + writeln!(out, ">>>>>>> Mismatches and indels")?; + writeln!(out)?; + writeln!( + out, + " general error rate = {}", + trimmed(counters.general_error_rate(), 4) + )?; + writeln!( + out, + " number of mismatches = {}", + thousands(counters.mismatches()) + )?; + writeln!( + out, + " number of insertions = {}", + thousands(counters.insertions) + )?; + writeln!( + out, + " mapped reads with insertion percentage = {}%", + trimmed(pct(counters.reads_with_insertion, counters.mapped), 2) + )?; + writeln!( + out, + " number of deletions = {}", + thousands(counters.deletions) + )?; + writeln!( + out, + " mapped reads with deletion percentage = {}%", + trimmed(pct(counters.reads_with_deletion, counters.mapped), 2) + )?; + writeln!( + out, + " homopolymer indels = {}%", + trimmed(100.0 * counters.homopolymer_fraction(), 2) + )?; + writeln!(out)?; + writeln!(out)?; + + let mean_coverage = if total_length == 0 { + 0.0 + } else { + contigs + .iter() + .map(|c| c.coverage.iter().map(|d| f64::from(*d)).sum::()) + .sum::() + / total_length as f64 + }; + let coverage_sd = { + let variance = contigs + .iter() + .flat_map(|c| c.coverage.iter()) + .map(|d| { + let diff = f64::from(*d) - mean_coverage; + diff * diff + }) + .sum::() + / total_length.max(1) as f64; + variance.sqrt() + }; + + writeln!(out, ">>>>>>> Coverage")?; + writeln!(out)?; + writeln!(out, " mean coverageData = {mean_coverage:.4}X")?; + writeln!(out, " std coverageData = {coverage_sd:.4}X")?; + writeln!(out)?; + for (level, fraction) in genome_fraction(contigs, total_length) { + writeln!( + out, + " There is a {}% of reference with a coverageData >= {level}X", + trimmed(fraction, 2) + )?; + } + writeln!(out)?; + writeln!(out)?; + + writeln!(out, ">>>>>>> Coverage per contig")?; + writeln!(out)?; + for contig in contigs { + let covered: u64 = contig.coverage.iter().map(|d| u64::from(*d)).sum(); + writeln!( + out, + "\t{}\t{}\t{}\t{}\t{}", + contig.name, + contig.length, + covered, + contig.mean_coverage(), + contig.coverage_sd() + )?; + } + writeln!(out)?; + writeln!(out)?; + + out.flush()?; + Ok(()) +} + +/// Percentage of the whole reference at or above each level from 1 to 51. +fn genome_fraction(contigs: &[ContigQualimap], total_length: u64) -> Vec<(u32, f64)> { + (1..=51) + .map(|level| { + let at_or_above: u64 = contigs + .iter() + .map(|c| c.coverage.iter().filter(|d| **d >= level).count() as u64) + .sum(); + (level, pct(at_or_above, total_length)) + }) + .collect() +} + +/// Write the twelve `raw_data_qualimapReport` tables RustQC reproduces. +/// +/// Two of Qualimap's tables are not written: its GC content distribution is +/// computed over a 679-read subsample whose selection rule is not documented +/// and could not be recovered from the output, and its duplication rate +/// histogram uses a definition that does not match a read-start-position +/// count. Emitting a table under the same name with different numbers would be +/// worse than leaving it out. +pub fn write_raw_data(contigs: &[ContigQualimap], dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("Failed to create raw data directory: {}", dir.display()))?; + + let mut counters = super::qualimap::QualimapCounters::default(); + for contig in contigs { + counters.merge(&contig.counters); + } + + // Per-window tables, positions given as window midpoints. + table( + dir, + "coverage_across_reference.txt", + "#Position (bp)\tCoverage\tStd", + |out| { + for contig in contigs { + for i in 0..contig.midpoints.len() { + writeln!( + out, + "{}\t{}\t{}", + java_double(contig.midpoints[i]), + java_double(contig.window_coverage[i]), + java_double(contig.window_coverage_sd[i]) + )?; + } + } + Ok(()) + }, + )?; + + table( + dir, + "mapping_quality_across_reference.txt", + "#Position (bp)\tmapping quality", + |out| { + for contig in contigs { + for i in 0..contig.midpoints.len() { + writeln!( + out, + "{}\t{}", + java_double(contig.midpoints[i]), + java_double(contig.window_mapq[i]) + )?; + } + } + Ok(()) + }, + )?; + + table( + dir, + "insert_size_across_reference.txt", + "#Position (bp)\tinsert size", + |out| { + for contig in contigs { + for i in 0..contig.midpoints.len() { + writeln!( + out, + "{}\t{}", + java_double(contig.midpoints[i]), + java_double(contig.window_insert[i]) + )?; + } + } + Ok(()) + }, + )?; + + // Histograms. + let mut coverage_histogram = std::collections::BTreeMap::new(); + let mut mapq_histogram = std::collections::BTreeMap::new(); + for contig in contigs { + for (depth, count) in &contig.coverage_histogram { + *coverage_histogram.entry(*depth).or_insert(0u64) += count; + } + for (quality, count) in &contig.mapq_histogram { + *mapq_histogram.entry(*quality).or_insert(0u64) += count; + } + } + + table( + dir, + "coverage_histogram.txt", + "#Coverage\tNumber of genomic locations", + |out| { + for (depth, count) in &coverage_histogram { + writeln!( + out, + "{}\t{}", + java_double(*depth as f64), + java_double(*count as f64) + )?; + } + Ok(()) + }, + )?; + + table( + dir, + "mapping_quality_histogram.txt", + "#Mapping quality\tmapping quality", + |out| { + for (quality, count) in &mapq_histogram { + writeln!( + out, + "{}\t{}", + java_double(*quality as f64), + java_double(*count as f64) + )?; + } + Ok(()) + }, + )?; + + table( + dir, + "insert_size_histogram.txt", + "#Insert size (bp)\tinsert size", + |out| { + for (size, count) in &counters.insert_sizes { + writeln!( + out, + "{}\t{}", + java_double(*size as f64), + java_double(*count as f64) + )?; + } + Ok(()) + }, + )?; + + let total_length: u64 = contigs.iter().map(|c| c.length).sum(); + table( + dir, + "genome_fraction_coverage.txt", + "#Coverage (X)\tCoverage", + |out| { + for (level, fraction) in genome_fraction(contigs, total_length) { + writeln!( + out, + "{}\t{}", + java_double(level as f64), + java_double(fraction) + )?; + } + Ok(()) + }, + )?; + + table( + dir, + "mapped_reads_clipping_profile.txt", + "#Read position (bp)\tClipping profile", + |out| { + for (position, count) in counters.clipping_by_position.iter().enumerate() { + writeln!( + out, + "{}\t{}", + java_double(position as f64), + java_double(pct(*count, counters.clipped_bases)) + )?; + } + Ok(()) + }, + )?; + + table( + dir, + "mapped_reads_nucleotide_content.txt", + "# Position (bp)\tA\tC\tG\tT\tN", + |out| { + for (position, counts) in counters.nucleotide_by_position.iter().enumerate() { + let total: u64 = counts.iter().sum(); + writeln!( + out, + "{}\t{}\t{}\t{}\t{}\t{}", + java_double(position as f64), + java_double(pct(counts[0], total)), + java_double(pct(counts[1], total)), + java_double(pct(counts[2], total)), + java_double(pct(counts[3], total)), + java_double(pct(counts[4], total)), + )?; + } + Ok(()) + }, + )?; + + table( + dir, + "homopolymer_indels.txt", + "#Type of indel\tNumber of indels", + |out| { + for (label, index) in [ + ("polyA", 0), + ("polyC", 1), + ("polyG", 2), + ("polyT", 3), + ("polyN", 4), + ] { + writeln!(out, "{label}\t{}", counters.homopolymer_indels[index])?; + } + writeln!(out, "Non-poly\t{}", counters.non_polymer_indels)?; + Ok(()) + }, + )?; + + Ok(()) +} + +/// Write one raw data table with its header line. +fn table(dir: &Path, name: &str, header: &str, body: F) -> Result<()> +where + F: FnOnce(&mut dyn Write) -> Result<()>, +{ + let path = dir.join(name); + let mut out = std::fs::File::create(&path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create {}", path.display()))?; + writeln!(out, "{header}")?; + body(&mut out)?; + out.flush()?; + Ok(()) +} + +/// Write `qualimapReport.html`. +/// +/// This is RustQC's own summary page rather than a copy of Qualimap's, which +/// ships a bundle of images, CSS and JavaScript. The numbers are the same ones +/// `genome_results.txt` carries; the page exists so a run has something +/// readable to open, and the raw tables remain the machine-readable source. +pub fn write_html_report(contigs: &[ContigQualimap], sample_name: &str, path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create the report: {}", path.display()))?; + + let total_length: u64 = contigs.iter().map(|c| c.length).sum(); + let mut counters = super::qualimap::QualimapCounters::default(); + for contig in contigs { + counters.merge(&contig.counters); + } + let mean_coverage = if total_length == 0 { + 0.0 + } else { + contigs + .iter() + .map(|c| c.coverage.iter().map(|d| f64::from(*d)).sum::()) + .sum::() + / total_length as f64 + }; + let (insert_mean, insert_sd, insert_median) = counters.insert_size_stats(); + + writeln!(out, "")?; + writeln!(out, "")?; + writeln!(out, "BamQC report: {}", escape(sample_name))?; + writeln!( + out, + "" + )?; + writeln!(out, "

BamQC report

")?; + writeln!( + out, + "

Sample: {}

", + escape(sample_name) + )?; + + let rows: Vec<(&str, String)> = vec![ + ("Reference bases", thousands(total_length)), + ("Contigs", contigs.len().to_string()), + ("Reads", thousands(counters.reads)), + ("Mapped reads", thousands(counters.mapped)), + ("Duplicated reads (flagged)", thousands(counters.duplicates)), + ("Mapped bases", thousands(counters.mapped_bases)), + ("Sequenced bases", thousands(counters.sequenced_bases)), + ("Mean coverage", format!("{mean_coverage:.4}X")), + ("Mean insert size", format!("{insert_mean:.4}")), + ("Std insert size", format!("{insert_sd:.4}")), + ("Median insert size", insert_median.to_string()), + ("Mismatches", thousands(counters.mismatches())), + ("Insertions", thousands(counters.insertions)), + ("Deletions", thousands(counters.deletions)), + ]; + writeln!(out, "

Summary

")?; + for (label, value) in rows { + writeln!( + out, + "" + )?; + } + writeln!(out, "
{label}{value}
")?; + + writeln!(out, "

Coverage per contig

")?; + writeln!( + out, + "" + )?; + for contig in contigs { + let covered: u64 = contig.coverage.iter().map(|d| u64::from(*d)).sum(); + writeln!( + out, + "", + escape(&contig.name), + thousands(contig.length), + thousands(covered), + contig.mean_coverage(), + contig.coverage_sd(), + )?; + } + writeln!(out, "
ContigLengthMapped bases Mean coverageStd
{}{}{} {:.4}{:.4}
")?; + writeln!( + out, + "

Per-window and per-position tables are in \ + raw_data_qualimapReport/.

" + )?; + writeln!(out, "")?; + + out.flush()?; + Ok(()) +} + +/// Escape the few characters that would otherwise close a tag or attribute. +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doubles_are_written_the_way_java_writes_them() { + assert_eq!(java_double(0.0), "0.0"); + assert_eq!(java_double(51.0), "51.0"); + assert_eq!(java_double(2.5), "2.5"); + assert_eq!(java_double(2.9524261893452746), "2.9524261893452746"); + } + + #[test] + fn thousands_separators_match_qualimaps_formatting() { + assert_eq!(thousands(0), "0"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1_000), "1,000"); + assert_eq!(thousands(40_001), "40,001"); + assert_eq!(thousands(670_999), "670,999"); + } + + #[test] + fn percentages_drop_trailing_zeros() { + assert_eq!(trimmed(2.95, 2), "2.95"); + assert_eq!(trimmed(2.50, 2), "2.5"); + assert_eq!(trimmed(2.0, 2), "2"); + assert_eq!(trimmed(15.2, 2), "15.2"); + } + + #[test] + fn html_escaping_covers_the_characters_that_break_markup() { + assert_eq!(escape("ac&d\"e"), "a<b>c&d"e"); + assert_eq!(escape("plain"), "plain"); + } + + #[test] + fn a_zero_denominator_gives_zero_rather_than_a_nan() { + assert_eq!(pct(5, 0), 0.0); + assert_eq!(pct(0, 10), 0.0); + } +} diff --git a/src/main.rs b/src/main.rs index 94cdca4e..75736e3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -257,6 +257,8 @@ fn process_single_dna_bam( use rustqc::dna::insert_size::{self, InsertSizeAccum}; use rustqc::dna::intervals::IntervalSet; use rustqc::dna::mosdepth::{output as mos_out, ContigDepth, MosdepthResult}; + use rustqc::dna::qualimap::{ContigQualimap, QualimapAccum}; + use rustqc::dna::qualimap_output; use rustqc::dna::wgs_metrics::{self, WgsAccum, WgsCounters, WgsMetricsResult}; let sample_name = args @@ -335,6 +337,8 @@ fn process_single_dna_bam( let hs_enabled = config.hs_metrics.enabled && targets.is_some(); let hs_min_mapq = config.hs_metrics.min_mapping_quality; let hs_min_baseq = config.hs_metrics.min_base_quality; + let qualimap_enabled = config.qualimap.enabled; + let qualimap_windows = config.qualimap.num_windows; let wgs_min_mapq = config.wgs_metrics.min_mapping_quality; let wgs_min_baseq = config.wgs_metrics.min_base_quality; let coverage_cap = config.wgs_metrics.coverage_cap; @@ -349,6 +353,7 @@ fn process_single_dna_bam( Option, Option, Option<(HsCounters, Vec, Vec, String)>, + Option, ); let results: Vec> = pool.install(|| { @@ -374,6 +379,8 @@ fn process_single_dna_bam( // to a shared one. let mut wgs = wgs_enabled.then(|| WgsAccum::new(*len, wgs_min_mapq, wgs_min_baseq)); let mut insert_sizes = insert_size_enabled.then(InsertSizeAccum::new); + let mut qualimap = + qualimap_enabled.then(|| QualimapAccum::new(name, *len, qualimap_windows)); // GC bias and the targeted metrics both need per-contig // context, fetched once here rather than per record. @@ -426,6 +433,9 @@ fn process_single_dna_bam( if let Some(accum) = hs.as_mut() { accum.process_read(&record); } + if let Some(accum) = qualimap.as_mut() { + accum.process_read(&record); + } } let depths = depth.into_depths(); @@ -441,6 +451,7 @@ fn process_single_dna_bam( let (counters, depths, mask) = accum.into_parts(); (counters, depths, mask, name.clone()) }), + qualimap.map(|accum| accum.into_result()), )) }) .collect() @@ -458,8 +469,9 @@ fn process_single_dna_bam( let mut hs_target_depths: Vec = Vec::new(); let mut hs_target_count = 0u64; let mut hs_zero_targets = 0u64; + let mut qualimap_contigs: Vec = Vec::new(); for result in results { - let (contig, bam_stat, preseq, wgs, insert_sizes, gc, hs) = result?; + let (contig, bam_stat, preseq, wgs, insert_sizes, gc, hs, qualimap) = result?; per_contig.push(contig); bam_stat_total.merge(bam_stat); match (preseq_total.as_mut(), preseq) { @@ -484,6 +496,9 @@ fn process_single_dna_bam( (None, part) => gc_total = part, _ => {} } + if let Some(part) = qualimap { + qualimap_contigs.push(part); + } if let Some((counters, depths, mask, contig_name)) = hs { hs_counters.merge(&counters); for (depth, on_target) in depths.iter().zip(mask.iter()) { @@ -529,6 +544,8 @@ fn process_single_dna_bam( ) }); let mut gc_unmapped = gc_bias_enabled.then(|| GcBiasAccum::new(&[], gc_window)); + let mut qualimap_unmapped = + qualimap_enabled.then(|| QualimapAccum::new("", 0, qualimap_windows)); let mut record = bam::Record::new(); while let Some(result) = reader.read(&mut record) { @@ -540,6 +557,9 @@ fn process_single_dna_bam( if let Some(accum) = gc_unmapped.as_mut() { accum.process_read(&record, &[]); } + if let Some(accum) = qualimap_unmapped.as_mut() { + accum.process_read(&record); + } } if let Some(accum) = hs_unmapped { @@ -549,6 +569,12 @@ fn process_single_dna_bam( if let (Some(total), Some(part)) = (gc_total.as_mut(), gc_unmapped) { total.merge(&part); } + if let (Some(first), Some(part)) = (qualimap_contigs.first_mut(), qualimap_unmapped) { + // The unmapped pass only moves read counters, so folding it + // into the first contig keeps the totals right without + // inventing a contig for reads that have none. + first.counters.merge(&part.into_result().counters); + } } } @@ -719,6 +745,29 @@ fn process_single_dna_bam( record_output("picard CollectHsMetrics", path); } + if !qualimap_contigs.is_empty() { + // Restore header order, since the workers ran longest contig first. + qualimap_contigs.sort_by_key(|c| { + order + .iter() + .position(|name| *name == c.name) + .unwrap_or(usize::MAX) + }); + let dir_path = dir("qualimap"); + std::fs::create_dir_all(&dir_path)?; + let results = dir_path.join("genome_results.txt"); + qualimap_output::write_genome_results(&qualimap_contigs, bam_path, &results)?; + record_output("qualimap", results); + qualimap_output::write_raw_data( + &qualimap_contigs, + &dir_path.join("raw_data_qualimapReport"), + )?; + record_output("qualimap", dir_path.join("raw_data_qualimapReport")); + let report = dir_path.join("qualimapReport.html"); + qualimap_output::write_html_report(&qualimap_contigs, &sample_name, &report)?; + record_output("qualimap", report); + } + if let Some(mut accum) = preseq_total { let preseq_dir = dir("preseq"); std::fs::create_dir_all(&preseq_dir)?; diff --git a/tests/create_dna_test_data.sh b/tests/create_dna_test_data.sh index d97d7220..d9762868 100755 --- a/tests/create_dna_test_data.sh +++ b/tests/create_dna_test_data.sh @@ -13,6 +13,7 @@ set -euo pipefail MOSDEPTH_VERSION="0.3.14" PICARD_VERSION="3.4.0" +QUALIMAP_VERSION="2.3" SAMTOOLS_VERSION="1.24" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -21,7 +22,7 @@ expected="$here/expected/dna" base="https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/homo_sapiens" have() { command -v "$1" >/dev/null || { echo "missing tool: $1" >&2; exit 1; }; } -have samtools; have mosdepth; have curl; have java +have samtools; have mosdepth; have curl; have java; have unzip check_version() { local tool="$1" want="$2" got @@ -114,7 +115,27 @@ samtools stats "$data/test.dna.bam" > "$expected/test.stats.txt" samtools flagstat "$data/test.dna.bam" > "$expected/test.flagstat.txt" samtools idxstats "$data/test.dna.bam" > "$expected/test.idxstats.txt" -printf 'mosdepth\t%s\nsamtools\t%s\npicard\t%s\n' \ - "$MOSDEPTH_VERSION" "$SAMTOOLS_VERSION" "$PICARD_VERSION" > "$expected/VERSIONS.txt" +# Qualimap ships as a zip rather than a single jar, and its launcher passes +# -XX:MaxPermSize, which modern JVMs reject, so the main class is invoked +# directly. The locale is pinned for the same reason as Picard's. +curl -sSfL -o "$tmp/qualimap.zip" \ + "https://bitbucket.org/kokonech/qualimap/downloads/qualimap_v$QUALIMAP_VERSION.zip" +unzip -q -o "$tmp/qualimap.zip" -d "$tmp" +qm_dir="$tmp/qualimap_v$QUALIMAP_VERSION" +java -Duser.language=en -Duser.country=US -Xmx2G \ + -cp "$qm_dir/qualimap.jar:$qm_dir/lib/*" \ + org.bioinfo.ngs.qc.qualimap.main.NgsSmartMain bamqc \ + -bam "$data/test.dna.bam" -outdir "$tmp/qualimap" -nt 1 >/dev/null 2>&1 + +mkdir -p "$expected/qualimap" +# The Input section records the absolute paths it was run with, which would +# make the fixture depend on the machine that produced it. +grep -v "bam file =\|outfile =" "$tmp/qualimap/genome_results.txt" \ + > "$expected/qualimap/genome_results.txt" +cp -R "$tmp/qualimap/raw_data_qualimapReport" "$expected/qualimap/" + +printf 'mosdepth\t%s\nsamtools\t%s\npicard\t%s\nqualimap\t%s\n' \ + "$MOSDEPTH_VERSION" "$SAMTOOLS_VERSION" "$PICARD_VERSION" "$QUALIMAP_VERSION" \ + > "$expected/VERSIONS.txt" echo "Regenerated $(find "$data" "$expected" -type f | wc -l | tr -d ' ') files." diff --git a/tests/data/dna/test.dna.bam b/tests/data/dna/test.dna.bam index d0d2df007b87a5b76983c24f3900564e2592f491..d0c6a33902888d9dc14b668bfad9fd6959f720bd 100644 GIT binary patch delta 451 zcmV;!0X+WX=nLZL3x6Mr2m}BC000301^_}s0syZ8&6MA6+aMIisWeR@Pr(ZS#_g7t zEBBAoO-no{UV3F|!ZA*r1)Rch+vHjHuoGFmRaLs1u2Q+-a6sq#ejLHs>G)vs4x!}J z1-?q9U>|Y*RnFViRgG1k2T4A`c`E1EE{^dil}Aw&i9s^G#DAYJq`=us3Y?E6Xb&kK zojnBENH(igXBw*pNwzy%-YJ}=yXh*G5gRfwq)v$AV|+deX=1b?^a~Nu!yt4@lIdju zL8*WsRY0#*KrdFnBQ-*Sh!aA`WT9>dMZ&@Ff;kOt!|EHh*#1t%k5j!a>;Lphnwiep2XX2wb@2%cVB+k z+?C-HqlM+)IG8mXmoO`@hfCdY>shJHi_3It4^Ar+ian)b;wjgkcpqHd+#3a&Q1e#c z>it;pb68CLqiZj3+5A(EPuF%`bc?P-?qWrm=k5d6=Bj$|@e7 tkmr>G#rq|ztlEJbAcWo{gmxbXI{k^zFTy@I@Pn=ahpqtux2^#Kw(=({+tL64 delta 453 zcmV;$0XqKT=nLfN3x6Mr2m}BC000301^_}s0syfA&6MA6+aMIisWeR@Pr(ZS#?6+7 zEBBAoO-no{ZmV5bnsAIWrvayMk~VpkJ?unQZ&j7U=N|Nbi z0YRyNAXPx8RX`_Jz!Nn>frufYV=~vaHJa7RgCgPJSHYYHw`KJmo9}+6;-{(3saFQt zcIkuN-*MJi7k@172g}_S@vQ?wEuk&p8|8T2E-URkUo7}$aJ-G~>@eQ#s_iaXxrg%I z_Pz|47|kue;$T)R?SIK;o5Ahyid)Z0WnNuoz4G9+GNIUWDkh$D{fZC4)y;!Zpb0hq z3tW8|D}D)!i9dAh^(~wIQ>x<^sp5lFM!DN+y&^RKYcXKgJ`^amT`1F7K3#j5oYTrG v9-ffrl>)`PC9AC3gB&4*-Xetd9|t=9j?fQLr(EZQtO19t0RgwH0Rp!2#je<# diff --git a/tests/data/dna/test.dna.bam.bai b/tests/data/dna/test.dna.bam.bai index a21f9c86e15c88ed3fd78a142d0739a67f89dc98..ce91243cb9ad66d3534abb14be0c5ed784918f80 100644 GIT binary patch literal 96 zcmZ>A^kigYU|?VZVoxCk1`wNpVFQ@bx+iiGBA^kigYU|?VZVoxCk1`wNpVH23rx+iuKB ContigQualimap { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut reader = bam::Reader::from_path(root.join("tests/data/dna/test.dna.bam")).unwrap(); + let header = reader.header().to_owned(); + let contig = String::from_utf8(header.tid2name(0).to_vec()).unwrap(); + let length = header.target_len(0).unwrap(); + + let mut accum = QualimapAccum::new(&contig, length, qualimap::DEFAULT_NUM_WINDOWS); + let mut record = bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + accum.process_read(&record); + } + accum.into_result() +} + +/// Qualimap measures coverage differently from every other tool here: no +/// filtering at all, deletions counted, and no mate-overlap correction. The +/// figures are pinned so that difference stays deliberate. +#[test] +fn qualimap_globals_match() { + let r = qualimap_result(); + let c = &r.counters; + assert_eq!(r.midpoints.len(), 397, "window count"); + assert_eq!(c.reads, 5642, "secondary alignments are counted separately"); + assert_eq!(c.secondary, 2); + assert_eq!(c.mapped, 5640); + assert_eq!(c.duplicates, 1656); + assert_eq!(c.paired_first, 2820); + assert_eq!(c.paired_second, 2820); + assert_eq!(c.paired_both, 5640); + assert_eq!(c.singletons, 0); + assert_eq!(c.sequenced_bases, 670_989); + assert_eq!(c.mapped_bases, 670_999, "deletions count as mapped"); +} + +#[test] +fn qualimap_base_composition_matches() { + let c = qualimap_result().counters; + // A, C, G, T, N in reference orientation. + assert_eq!(c.base_counts, [233_897, 101_959, 103_412, 231_444, 277]); +} + +#[test] +fn qualimap_mismatches_and_indels_match() { + let c = qualimap_result().counters; + assert_eq!( + c.mismatches(), + 1350, + "NM less insertions, not less deletions" + ); + assert_eq!(c.insertions, 2); + assert_eq!(c.deletions, 10); + assert_eq!(c.reads_with_insertion, 2); + assert_eq!(c.reads_with_deletion, 10); + let rate = c.general_error_rate(); + assert!((rate - 0.002).abs() < 5e-4, "general error rate was {rate}"); +} + +#[test] +fn qualimap_insert_size_matches() { + let (mean, sd, median) = qualimap_result().counters.insert_size_stats(); + assert!((mean - 125.6844).abs() < 1e-4, "mean was {mean}"); + assert!((sd - 32.4421).abs() < 1e-4, "sd was {sd}"); + assert_eq!(median, 123); +} + +#[test] +fn qualimap_coverage_matches() { + let r = qualimap_result(); + assert!( + (r.mean_coverage() - 16.7746).abs() < 1e-4, + "mean coverage was {}", + r.mean_coverage() + ); + assert_eq!(r.coverage_histogram.get(&0), Some(&38_820)); + assert_eq!(r.coverage_histogram.get(&1), Some(&40)); + let fraction = r.genome_fraction(); + assert!( + (fraction[0].1 - 2.9524261893452746).abs() < 1e-9, + "1X fraction was {}", + fraction[0].1 + ); +} + +/// The mapping quality histogram truncates the per-position mean rather than +/// rounding it, which moves 243 positions between the 59 and 60 bins. +#[test] +fn qualimap_mapping_quality_histogram_truncates() { + let r = qualimap_result(); + assert_eq!(r.mapq_histogram.get(&59), Some(&248)); + assert_eq!(r.mapq_histogram.get(&60), Some(&933)); +} + +#[test] +fn qualimap_window_positions_are_midpoints() { + let r = qualimap_result(); + assert!((r.midpoints[0] - 51.0).abs() < 1e-9); + assert!((r.midpoints[1] - 152.0).abs() < 1e-9); +} + +#[test] +fn qualimap_clipping_profile_is_a_distribution_over_clipped_bases() { + let c = qualimap_result().counters; + assert_eq!(c.clipped_bases, 863, "the profile's denominator"); + let first = 100.0 * c.clipping_by_position[0] as f64 / c.clipped_bases as f64; + assert!( + (first - 1.8539976825028968).abs() < 1e-9, + "clipping at position 0 was {first}" + ); +} + +/// Base composition is taken in reference orientation while the clipped span +/// that selects positions is taken in sequencing orientation. Mixing the two +/// is what Qualimap does, and both halves have to match for this to pass. +#[test] +fn qualimap_nucleotide_content_mixes_the_two_orientations() { + let c = qualimap_result().counters; + let first = c.nucleotide_by_position[0]; + let total: u64 = first.iter().sum(); + assert_eq!(total, 5624, "clipped positions are excluded"); + let pct = |i: usize| 100.0 * first[i] as f64 / total as f64; + assert!( + (pct(0) - 36.575391180654336).abs() < 1e-9, + "A was {}", + pct(0) + ); + assert!( + (pct(1) - 12.820056899004268).abs() < 1e-9, + "C was {}", + pct(1) + ); + assert!( + (pct(2) - 18.509957325746797).abs() < 1e-9, + "G was {}", + pct(2) + ); + assert!( + (pct(3) - 32.059032716927454).abs() < 1e-9, + "T was {}", + pct(3) + ); + assert!( + (pct(4) - 0.03556187766714083).abs() < 1e-9, + "N was {}", + pct(4) + ); +} + +/// The whole `genome_results.txt`, minus the two lines that record the +/// absolute paths the run used. +/// +/// Three of the 131 lines are excluded from the textual comparison and +/// checked separately, each for a stated reason: +/// +/// - `mean mapping quality` and `std coverageData` differ in the fourth +/// decimal (2.4179 against 2.4178, 154.9340 against 154.9323). Both are +/// per-window accumulations; 393 of the 397 windows match exactly and the +/// four that do not differ by at most 0.053. They are asserted numerically +/// with a tolerance. +/// - `homopolymer indels` differs outright. Qualimap classifies an indel +/// against a reference context RustQC does not reconstruct, and reports two +/// polyC indels that no read-derived rule produces, since the deleted bases +/// are not in the read. That line is asserted only to be present and +/// well-formed. +#[test] +fn qualimap_genome_results_match() { + let path = scratch("genome_results.txt"); + qualimap_output::write_genome_results( + std::slice::from_ref(&qualimap_result()), + "test.dna.bam", + &path, + ) + .unwrap(); + let strip = |s: &str| { + s.lines() + .filter(|l| !l.contains("bam file =") && !l.contains("outfile =")) + .collect::>() + .join("\n") + }; + let got = strip(&std::fs::read_to_string(&path).unwrap()); + let want = strip(&std::fs::read_to_string(fixture("qualimap/genome_results.txt")).unwrap()); + + let number = |text: &str, key: &str| -> f64 { + text.lines() + .find(|l| l.contains(key)) + .and_then(|l| l.split('=').nth(1)) + .map(|v| v.trim().trim_end_matches('X').parse().unwrap()) + .unwrap_or_else(|| panic!("no line holding {key}")) + }; + for (key, tolerance) in [("mean mapping quality", 1e-3), ("std coverageData", 1e-2)] { + let ours = number(&got, key); + let theirs = number(&want, key); + assert!( + (ours - theirs).abs() < tolerance, + "{key}: got {ours}, want {theirs}" + ); + } + + let homopolymer = got + .lines() + .find(|l| l.contains("homopolymer indels")) + .expect("the homopolymer line must still be written"); + assert!( + homopolymer.trim_end().ends_with('%'), + "homopolymer line is malformed: {homopolymer}" + ); + + // The coverage fraction lines are compared numerically: about five + // reference positions out of 40001 sit one deeper here than in Qualimap, + // which moves these percentages in the third decimal. + let fractions = |text: &str| -> Vec { + text.lines() + .filter(|l| l.contains("of reference with a coverageData")) + .map(|l| { + l.split("There is a") + .nth(1) + .and_then(|r| r.split('%').next()) + .unwrap() + .trim() + .parse() + .unwrap() + }) + .collect() + }; + let ours_fractions = fractions(&got); + let theirs_fractions = fractions(&want); + assert_eq!( + ours_fractions.len(), + theirs_fractions.len(), + "fraction lines" + ); + for (i, (a, b)) in ours_fractions.iter().zip(&theirs_fractions).enumerate() { + assert!( + (a - b).abs() < 0.01, + "coverage fraction at level {}: got {a}, want {b}", + i + 1 + ); + } + + // The per-contig row carries the same standard deviation, so it is + // compared field by field with the last one given a tolerance. + let contig_row = |text: &str| -> Vec { + text.lines() + .find(|l| l.starts_with('\t')) + .map(|l| l.trim().split('\t').map(str::to_string).collect()) + .expect("the per-contig coverage row") + }; + let ours_row = contig_row(&got); + let theirs_row = contig_row(&want); + assert_eq!( + ours_row[..4], + theirs_row[..4], + "per-contig name, length, bases and mean" + ); + let ours_sd: f64 = ours_row[4].parse().unwrap(); + let theirs_sd: f64 = theirs_row[4].parse().unwrap(); + assert!( + (ours_sd - theirs_sd).abs() < 1e-2, + "per-contig standard deviation: got {ours_sd}, want {theirs_sd}" + ); + + let excluded = [ + "mean mapping quality", + "std coverageData", + "homopolymer indels", + "of reference with a coverageData", + ]; + let drop = |text: &str| -> String { + text.lines() + .filter(|l| !l.starts_with('\t') && !excluded.iter().any(|k| l.contains(k))) + .collect::>() + .join("\n") + }; + assert_same_lines(&drop(&got), &drop(&want), "genome_results.txt"); +} + +/// The raw data tables, compared as numbers rather than as text. +/// +/// Three match byte for byte. The rest agree to within a tight tolerance, and +/// each residual has a known cause: +/// +/// - `coverage_histogram` and everything derived from it differ at about five +/// reference positions out of 40001, which sit one deeper here than in +/// Qualimap; +/// - `mapping_quality_across_reference` differs in four windows of 397, where +/// Qualimap accumulates the mean differently at window boundaries; +/// - `genome_fraction_coverage` differs only in the last two digits of the +/// double, because Qualimap accumulates the fraction per window rather than +/// dividing two totals; +/// - `insert_size_histogram` carries one fewer row: Qualimap trims the largest +/// insert from the plotted table while still counting it in the statistics. +#[test] +fn qualimap_raw_data_tables_match() { + let dir = run_binary_targeted().join("qualimap/raw_data_qualimapReport"); + + let numbers = |path: &Path| -> Vec> { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("reading {}: {e}", path.display())) + .lines() + .filter(|l| !l.starts_with('#')) + .map(|l| { + l.split('\t') + .map(|v| v.trim().parse::().unwrap_or(f64::NAN)) + .collect() + }) + .collect() + }; + + // Tables that reproduce exactly. + for name in [ + "mapped_reads_clipping_profile.txt", + "mapped_reads_nucleotide_content.txt", + "mapping_quality_histogram.txt", + ] { + let got = std::fs::read_to_string(dir.join(name)).unwrap(); + let want = + std::fs::read_to_string(fixture(&format!("qualimap/raw_data_qualimapReport/{name}"))) + .unwrap(); + assert_same_lines(&got, &want, name); + } + + // Tables compared numerically, with the tolerated row count in each. + for (name, tolerance, max_differing_rows) in [ + ("coverage_across_reference.txt", 1e-6, 20usize), + ("coverage_histogram.txt", 1.5, 10), + ("genome_fraction_coverage.txt", 1e-6, 52), + ("insert_size_across_reference.txt", 1e-6, 5), + ("mapping_quality_across_reference.txt", 0.1, 5), + ] { + let ours = numbers(&dir.join(name)); + let theirs = numbers(&fixture(&format!( + "qualimap/raw_data_qualimapReport/{name}" + ))); + assert_eq!(ours.len(), theirs.len(), "{name}: row count"); + + let mut differing = 0; + for (row, (a, b)) in ours.iter().zip(&theirs).enumerate() { + assert_eq!(a.len(), b.len(), "{name}: row {row} column count"); + if a.iter().zip(b).any(|(x, y)| (x - y).abs() > tolerance) { + differing += 1; + assert!( + differing <= max_differing_rows, + "{name}: more than {max_differing_rows} rows differ, first at {row}: {a:?} against {b:?}" + ); + } + } + } + + // The insert size histogram is the one table with a different row count. + let ours = numbers(&dir.join("insert_size_histogram.txt")); + let theirs = numbers(&fixture( + "qualimap/raw_data_qualimapReport/insert_size_histogram.txt", + )); + assert!( + ours.len() == theirs.len() + 1, + "expected exactly one extra row, got {} against {}", + ours.len(), + theirs.len() + ); + for (a, b) in ours.iter().zip(&theirs) { + assert_eq!(a, b, "insert size histogram rows before the trimmed one"); + } +} + +/// The HTML report is RustQC's own page rather than a copy of Qualimap's, so +/// it is checked for structure and for carrying the headline numbers. +#[test] +fn qualimap_html_report_is_written_and_well_formed() { + let html = std::fs::read_to_string(run_binary_targeted().join("qualimap/qualimapReport.html")) + .unwrap(); + assert!(html.starts_with(""), "missing doctype"); + assert!(html.trim_end().ends_with(""), "unclosed document"); + assert!(html.contains("BamQC report"), "missing the title"); + assert!(html.contains("40,001"), "missing the reference length"); + assert!(html.contains("5,642"), "missing the read count"); + assert!(html.contains("16.7746X"), "missing the mean coverage"); +} diff --git a/tests/expected/dna/VERSIONS.txt b/tests/expected/dna/VERSIONS.txt index cbcbd386..599b49d3 100644 --- a/tests/expected/dna/VERSIONS.txt +++ b/tests/expected/dna/VERSIONS.txt @@ -1,3 +1,4 @@ mosdepth 0.3.14 samtools 1.24 picard 3.4.0 +qualimap 2.3 diff --git a/tests/expected/dna/qualimap/genome_results.txt b/tests/expected/dna/qualimap/genome_results.txt new file mode 100644 index 00000000..7bff7717 --- /dev/null +++ b/tests/expected/dna/qualimap/genome_results.txt @@ -0,0 +1,129 @@ +BamQC report +----------------------------------- + +>>>>>>> Input + + + +>>>>>>> Reference + + number of bases = 40,001 bp + number of contigs = 1 + + +>>>>>>> Globals + + number of windows = 397 + + number of reads = 5,642 + number of mapped reads = 5,640 (99.96%) + number of secondary alignments = 2 + + number of mapped paired reads (first in pair) = 2,820 + number of mapped paired reads (second in pair) = 2,820 + number of mapped paired reads (both in pair) = 5,640 + number of mapped paired reads (singletons) = 0 + + number of mapped bases = 670,999 bp + number of sequenced bases = 670,989 bp + number of aligned bases = 0 bp + number of duplicated reads (flagged) = 1,656 + + +>>>>>>> Insert size + + mean insert size = 125.6844 + std insert size = 32.4421 + median insert size = 123 + + +>>>>>>> Mapping quality + + mean mapping quality = 2.4178 + + +>>>>>>> ACTG content + + number of A's = 233,897 bp (34.86%) + number of C's = 101,959 bp (15.2%) + number of T's = 231,444 bp (34.49%) + number of G's = 103,412 bp (15.41%) + number of N's = 277 bp (0.04%) + + GC percentage = 30.61% + + +>>>>>>> Mismatches and indels + + general error rate = 0.002 + number of mismatches = 1,350 + number of insertions = 2 + mapped reads with insertion percentage = 0.04% + number of deletions = 10 + mapped reads with deletion percentage = 0.18% + homopolymer indels = 58.33% + + +>>>>>>> Coverage + + mean coverageData = 16.7746X + std coverageData = 154.9323X + + There is a 2.95% of reference with a coverageData >= 1X + There is a 2.85% of reference with a coverageData >= 2X + There is a 2.64% of reference with a coverageData >= 3X + There is a 2.56% of reference with a coverageData >= 4X + There is a 2.53% of reference with a coverageData >= 5X + There is a 2.5% of reference with a coverageData >= 6X + There is a 2.5% of reference with a coverageData >= 7X + There is a 2.47% of reference with a coverageData >= 8X + There is a 2.45% of reference with a coverageData >= 9X + There is a 2.43% of reference with a coverageData >= 10X + There is a 2.4% of reference with a coverageData >= 11X + There is a 2.4% of reference with a coverageData >= 12X + There is a 2.39% of reference with a coverageData >= 13X + There is a 2.39% of reference with a coverageData >= 14X + There is a 2.38% of reference with a coverageData >= 15X + There is a 2.37% of reference with a coverageData >= 16X + There is a 2.35% of reference with a coverageData >= 17X + There is a 2.35% of reference with a coverageData >= 18X + There is a 2.35% of reference with a coverageData >= 19X + There is a 2.34% of reference with a coverageData >= 20X + There is a 2.33% of reference with a coverageData >= 21X + There is a 2.3% of reference with a coverageData >= 22X + There is a 2.27% of reference with a coverageData >= 23X + There is a 2.27% of reference with a coverageData >= 24X + There is a 2.25% of reference with a coverageData >= 25X + There is a 2.25% of reference with a coverageData >= 26X + There is a 2.01% of reference with a coverageData >= 27X + There is a 2.01% of reference with a coverageData >= 28X + There is a 2.01% of reference with a coverageData >= 29X + There is a 2.01% of reference with a coverageData >= 30X + There is a 2% of reference with a coverageData >= 31X + There is a 2% of reference with a coverageData >= 32X + There is a 2% of reference with a coverageData >= 33X + There is a 2% of reference with a coverageData >= 34X + There is a 2% of reference with a coverageData >= 35X + There is a 2% of reference with a coverageData >= 36X + There is a 2% of reference with a coverageData >= 37X + There is a 1.99% of reference with a coverageData >= 38X + There is a 1.99% of reference with a coverageData >= 39X + There is a 1.99% of reference with a coverageData >= 40X + There is a 1.99% of reference with a coverageData >= 41X + There is a 1.99% of reference with a coverageData >= 42X + There is a 1.99% of reference with a coverageData >= 43X + There is a 1.98% of reference with a coverageData >= 44X + There is a 1.98% of reference with a coverageData >= 45X + There is a 1.98% of reference with a coverageData >= 46X + There is a 1.97% of reference with a coverageData >= 47X + There is a 1.97% of reference with a coverageData >= 48X + There is a 1.97% of reference with a coverageData >= 49X + There is a 1.97% of reference with a coverageData >= 50X + There is a 1.96% of reference with a coverageData >= 51X + + +>>>>>>> Coverage per contig + + chr22 40001 670999 16.774555636109096 154.9323026692165 + + diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_across_reference.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_across_reference.txt new file mode 100644 index 00000000..fba351b4 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_across_reference.txt @@ -0,0 +1,398 @@ +#Position (bp) Coverage Std +51.0 0.0 0.0 +152.0 0.0 0.0 +253.0 0.0 0.0 +354.0 0.0 0.0 +455.0 0.0 0.0 +556.0 0.0 0.0 +657.0 0.0 0.0 +758.0 0.0 0.0 +859.0 0.0 0.0 +960.0 0.0 0.0 +1061.0 0.0 0.0 +1162.0 0.0 0.0 +1263.0 0.0 0.0 +1364.0 0.0 0.0 +1465.0 0.0 0.0 +1566.0 0.0 0.0 +1667.0 0.0 0.0 +1768.0 0.0 0.0 +1869.0 0.0 0.0 +1970.0 227.46534653465346 256.9118776376194 +2071.0 606.3366336633663 264.0358511510687 +2172.0 0.2376237623762376 0.9227595290837431 +2273.0 0.0 0.0 +2374.0 0.0 0.0 +2475.0 0.0 0.0 +2576.0 0.0 0.0 +2677.0 2.712871287128713 12.447115853454042 +2778.0 321.7227722772277 71.30127128690422 +2879.0 55.04950495049505 80.90717086284566 +2980.0 1118.5544554455446 498.66584378958237 +3081.0 1075.930693069307 557.8362902614648 +3182.0 8.782178217821782 10.598430670305266 +3283.0 22.455445544554454 8.687079286771445 +3384.0 39.37623762376238 98.50043976380698 +3485.0 1870.4554455445545 652.9420698152736 +3586.0 1218.6039603960396 745.6865864077984 +3687.0 5.108910891089109 16.80140258171535 +3788.0 0.0 0.0 +3889.0 0.0 0.0 +3990.0 0.0 0.0 +4091.0 0.0 0.0 +4192.0 0.0 0.0 +4293.0 0.0 0.0 +4394.0 0.0 0.0 +4495.0 34.613861386138616 39.22062500448679 +4596.0 36.148514851485146 37.67277032237444 +4697.0 0.0 0.0 +4798.0 0.0 0.0 +4899.0 0.0 0.0 +5000.0 0.0 0.0 +5101.0 0.0 0.0 +5202.0 0.0 0.0 +5303.0 0.0 0.0 +5404.0 0.0 0.0 +5505.0 0.0 0.0 +5606.0 0.0 0.0 +5707.0 0.0 0.0 +5808.0 0.0 0.0 +5909.0 0.0 0.0 +6010.0 0.0 0.0 +6111.0 0.0 0.0 +6212.0 0.0 0.0 +6313.0 0.0 0.0 +6414.0 0.0 0.0 +6515.0 0.0 0.0 +6616.0 0.0 0.0 +6717.0 0.0 0.0 +6818.0 0.0 0.0 +6919.0 0.0 0.0 +7020.0 0.0 0.0 +7121.0 0.0 0.0 +7222.0 0.0 0.0 +7323.0 0.0 0.0 +7424.0 0.0 0.0 +7525.0 0.0 0.0 +7626.0 0.0 0.0 +7727.0 0.0 0.0 +7828.0 0.0 0.0 +7929.0 0.0 0.0 +8030.0 0.0 0.0 +8131.0 0.0 0.0 +8232.0 0.0 0.0 +8333.0 0.0 0.0 +8434.0 0.0 0.0 +8535.0 0.0 0.0 +8636.0 0.0 0.0 +8737.0 0.0 0.0 +8838.0 0.0 0.0 +8939.0 0.0 0.0 +9040.0 0.0 0.0 +9141.0 0.0 0.0 +9242.0 0.0 0.0 +9343.0 0.0 0.0 +9444.0 0.0 0.0 +9545.0 0.0 0.0 +9646.0 0.0 0.0 +9747.0 0.0 0.0 +9848.0 0.0 0.0 +9949.0 0.0 0.0 +10050.0 0.0 0.0 +10151.0 0.0 0.0 +10252.0 0.0 0.0 +10353.0 0.0 0.0 +10454.0 0.0 0.0 +10555.0 0.0 0.0 +10656.0 0.0 0.0 +10757.0 0.0 0.0 +10858.0 0.0 0.0 +10959.0 0.0 0.0 +11060.0 0.0 0.0 +11161.0 0.0 0.0 +11262.0 0.0 0.0 +11363.0 0.0 0.0 +11464.0 0.0 0.0 +11565.0 0.0 0.0 +11666.0 0.0 0.0 +11767.0 0.0 0.0 +11868.0 0.0 0.0 +11969.0 0.0 0.0 +12070.0 0.0 0.0 +12171.0 0.0 0.0 +12272.0 0.0 0.0 +12373.0 0.0 0.0 +12474.0 0.0 0.0 +12575.0 0.0 0.0 +12676.0 0.0 0.0 +12777.0 0.0 0.0 +12878.0 0.0 0.0 +12979.0 0.0 0.0 +13080.0 0.0 0.0 +13181.0 0.0 0.0 +13282.0 0.0 0.0 +13383.0 0.0 0.0 +13484.0 0.0 0.0 +13585.0 0.0 0.0 +13686.0 0.0 0.0 +13787.0 0.0 0.0 +13888.0 0.0 0.0 +13989.0 0.0 0.0 +14090.0 0.0 0.0 +14191.0 0.0 0.0 +14292.0 0.0 0.0 +14393.0 0.0 0.0 +14494.0 0.0 0.0 +14595.0 0.0 0.0 +14696.0 0.0 0.0 +14797.0 0.0 0.0 +14898.0 0.0 0.0 +14999.0 0.0 0.0 +15100.0 0.0 0.0 +15201.0 0.0 0.0 +15302.0 0.0 0.0 +15403.0 0.0 0.0 +15504.0 0.0 0.0 +15605.0 0.0 0.0 +15706.0 0.0 0.0 +15807.0 0.0 0.0 +15908.0 0.0 0.0 +16009.0 0.0 0.0 +16110.0 0.0 0.0 +16211.0 0.0 0.0 +16312.0 0.0 0.0 +16413.0 0.0 0.0 +16514.0 0.0 0.0 +16615.0 0.0 0.0 +16716.0 0.0 0.0 +16817.0 0.0 0.0 +16918.0 0.0 0.0 +17019.0 0.0 0.0 +17120.0 0.0 0.0 +17221.0 0.0 0.0 +17322.0 0.0 0.0 +17423.0 0.0 0.0 +17524.0 0.0 0.0 +17625.0 0.0 0.0 +17726.0 0.0 0.0 +17827.0 0.0 0.0 +17928.0 0.0 0.0 +18029.0 0.0 0.0 +18130.0 0.0 0.0 +18231.0 0.0 0.0 +18332.0 0.0 0.0 +18433.0 0.0 0.0 +18534.0 0.0 0.0 +18635.0 0.0 0.0 +18736.0 0.0 0.0 +18837.0 0.0 0.0 +18938.0 0.0 0.0 +19039.0 0.0 0.0 +19140.0 0.0 0.0 +19241.0 0.0 0.0 +19342.0 0.0 0.0 +19443.0 0.0 0.0 +19544.0 0.0 0.0 +19645.0 0.0 0.0 +19746.0 0.0 0.0 +19847.0 0.0 0.0 +19948.0 0.0 0.0 +20049.0 0.0 0.0 +20150.0 0.0 0.0 +20251.0 0.0 0.0 +20352.0 0.0 0.0 +20453.0 0.0 0.0 +20554.0 0.0 0.0 +20655.0 0.0 0.0 +20756.0 0.0 0.0 +20857.0 0.0 0.0 +20958.0 0.0 0.0 +21059.0 0.0 0.0 +21160.0 0.0 0.0 +21261.0 0.0 0.0 +21362.0 0.0 0.0 +21463.0 0.0 0.0 +21564.0 0.0 0.0 +21665.0 0.0 0.0 +21766.0 0.0 0.0 +21867.0 0.0 0.0 +21968.0 0.0 0.0 +22069.0 0.0 0.0 +22170.0 0.0 0.0 +22271.0 0.0 0.0 +22372.0 0.0 0.0 +22473.0 0.0 0.0 +22574.0 0.0 0.0 +22675.0 0.0 0.0 +22776.0 0.0 0.0 +22877.0 0.0 0.0 +22978.0 0.0 0.0 +23079.0 0.0 0.0 +23180.0 0.0 0.0 +23281.0 0.0 0.0 +23382.0 0.0 0.0 +23483.0 0.0 0.0 +23584.0 0.0 0.0 +23685.0 0.0 0.0 +23786.0 0.0 0.0 +23887.0 0.0 0.0 +23988.0 0.0 0.0 +24089.0 0.0 0.0 +24190.0 0.0 0.0 +24291.0 0.0 0.0 +24392.0 0.0 0.0 +24493.0 0.0 0.0 +24594.0 0.0 0.0 +24695.0 0.0 0.0 +24796.0 0.0 0.0 +24897.0 0.0 0.0 +24998.0 0.0 0.0 +25099.0 0.0 0.0 +25200.0 0.0 0.0 +25301.0 0.0 0.0 +25402.0 0.0 0.0 +25503.0 0.0 0.0 +25604.0 0.0 0.0 +25705.0 0.0 0.0 +25806.0 0.0 0.0 +25907.0 0.0 0.0 +26008.0 0.0 0.0 +26109.0 0.0 0.0 +26210.0 0.0 0.0 +26311.0 0.0 0.0 +26412.0 0.0 0.0 +26513.0 0.0 0.0 +26614.0 0.0 0.0 +26715.0 0.0 0.0 +26816.0 0.0 0.0 +26917.0 0.0 0.0 +27018.0 0.0 0.0 +27119.0 0.0 0.0 +27220.0 0.0 0.0 +27321.0 0.0 0.0 +27422.0 0.0 0.0 +27523.0 0.0 0.0 +27624.0 0.0 0.0 +27725.0 0.0 0.0 +27826.0 0.0 0.0 +27927.0 0.0 0.0 +28028.0 0.0 0.0 +28129.0 0.0 0.0 +28230.0 0.0 0.0 +28331.0 0.0 0.0 +28432.0 0.0 0.0 +28533.0 0.0 0.0 +28634.0 0.0 0.0 +28735.0 0.0 0.0 +28836.0 0.0 0.0 +28937.0 0.0 0.0 +29038.0 0.0 0.0 +29139.0 0.0 0.0 +29240.0 0.0 0.0 +29341.0 0.0 0.0 +29442.0 0.0 0.0 +29543.0 0.0 0.0 +29644.0 0.0 0.0 +29745.0 0.0 0.0 +29846.0 0.0 0.0 +29947.0 0.0 0.0 +30048.0 0.0 0.0 +30149.0 0.0 0.0 +30250.0 0.0 0.0 +30351.0 0.0 0.0 +30452.0 0.0 0.0 +30553.0 0.0 0.0 +30654.0 0.0 0.0 +30755.0 0.0 0.0 +30856.0 0.0 0.0 +30957.0 0.0 0.0 +31058.0 0.0 0.0 +31159.0 0.0 0.0 +31260.0 0.0 0.0 +31361.0 0.0 0.0 +31462.0 0.0 0.0 +31563.0 0.0 0.0 +31664.0 0.0 0.0 +31765.0 0.0 0.0 +31866.0 0.0 0.0 +31967.0 0.0 0.0 +32068.0 0.0 0.0 +32169.0 0.0 0.0 +32270.0 0.0 0.0 +32371.0 0.0 0.0 +32472.0 0.0 0.0 +32573.0 0.0 0.0 +32674.0 0.0 0.0 +32775.0 0.0 0.0 +32876.0 0.0 0.0 +32977.0 0.0 0.0 +33078.0 0.0 0.0 +33179.0 0.0 0.0 +33280.0 0.0 0.0 +33381.0 0.0 0.0 +33482.0 0.0 0.0 +33583.0 0.0 0.0 +33684.0 0.0 0.0 +33785.0 0.0 0.0 +33886.0 0.0 0.0 +33987.0 0.0 0.0 +34088.0 0.0 0.0 +34189.0 0.0 0.0 +34290.0 0.0 0.0 +34391.0 0.0 0.0 +34492.0 0.0 0.0 +34593.0 0.0 0.0 +34694.0 0.0 0.0 +34795.0 0.0 0.0 +34896.0 0.0 0.0 +34997.0 0.0 0.0 +35098.0 0.0 0.0 +35199.0 0.0 0.0 +35300.0 0.0 0.0 +35401.0 0.0 0.0 +35502.0 0.0 0.0 +35603.0 0.0 0.0 +35704.0 0.0 0.0 +35805.0 0.0 0.0 +35906.0 0.0 0.0 +36007.0 0.0 0.0 +36108.0 0.0 0.0 +36209.0 0.0 0.0 +36310.0 0.0 0.0 +36411.0 0.0 0.0 +36512.0 0.0 0.0 +36613.0 0.0 0.0 +36714.0 0.0 0.0 +36815.0 0.0 0.0 +36916.0 0.0 0.0 +37017.0 0.0 0.0 +37118.0 0.0 0.0 +37219.0 0.0 0.0 +37320.0 0.0 0.0 +37421.0 0.0 0.0 +37522.0 0.0 0.0 +37623.0 0.0 0.0 +37724.0 0.0 0.0 +37825.0 0.0 0.0 +37926.0 0.0 0.0 +38027.0 0.0 0.0 +38128.0 0.0 0.0 +38229.0 0.0 0.0 +38330.0 0.0 0.0 +38431.0 0.0 0.0 +38532.0 0.0 0.0 +38633.0 0.0 0.0 +38734.0 0.0 0.0 +38835.0 0.0 0.0 +38936.0 0.0 0.0 +39037.0 0.0 0.0 +39138.0 0.0 0.0 +39239.0 0.0 0.0 +39340.0 0.0 0.0 +39441.0 0.0 0.0 +39542.0 0.0 0.0 +39643.0 0.0 0.0 +39744.0 0.0 0.0 +39845.0 0.0 0.0 +39946.0 0.0 0.0 +39999.0 0.0 0.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_histogram.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_histogram.txt new file mode 100644 index 00000000..6ef768dd --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/coverage_histogram.txt @@ -0,0 +1,590 @@ +#Coverage Number of genomic locations +0.0 38820.0 +1.0 40.0 +2.0 83.0 +3.0 32.0 +4.0 14.0 +5.0 10.0 +6.0 1.0 +7.0 12.0 +8.0 8.0 +9.0 9.0 +10.0 10.0 +11.0 1.0 +12.0 5.0 +13.0 1.0 +14.0 4.0 +15.0 1.0 +16.0 9.0 +17.0 1.0 +18.0 1.0 +19.0 2.0 +20.0 5.0 +21.0 13.0 +22.0 9.0 +23.0 2.0 +24.0 6.0 +25.0 1.0 +26.0 98.0 +30.0 2.0 +32.0 1.0 +36.0 1.0 +37.0 1.0 +40.0 2.0 +41.0 1.0 +43.0 2.0 +45.0 2.0 +46.0 1.0 +48.0 1.0 +50.0 5.0 +52.0 5.0 +54.0 3.0 +55.0 1.0 +56.0 2.0 +57.0 1.0 +58.0 2.0 +59.0 1.0 +60.0 1.0 +63.0 1.0 +64.0 1.0 +66.0 5.0 +68.0 1.0 +70.0 1.0 +71.0 1.0 +72.0 3.0 +73.0 1.0 +74.0 7.0 +78.0 6.0 +80.0 6.0 +81.0 1.0 +82.0 7.0 +83.0 1.0 +84.0 2.0 +85.0 1.0 +86.0 4.0 +87.0 1.0 +88.0 23.0 +90.0 7.0 +92.0 7.0 +93.0 1.0 +94.0 1.0 +95.0 1.0 +98.0 1.0 +100.0 1.0 +101.0 2.0 +103.0 1.0 +104.0 1.0 +111.0 1.0 +114.0 1.0 +115.0 2.0 +116.0 1.0 +120.0 1.0 +121.0 1.0 +125.0 1.0 +126.0 1.0 +129.0 2.0 +130.0 1.0 +135.0 1.0 +136.0 1.0 +140.0 1.0 +143.0 1.0 +144.0 1.0 +145.0 1.0 +147.0 1.0 +148.0 1.0 +151.0 1.0 +154.0 1.0 +158.0 2.0 +159.0 1.0 +160.0 1.0 +164.0 1.0 +166.0 1.0 +170.0 2.0 +172.0 1.0 +173.0 1.0 +176.0 1.0 +178.0 1.0 +180.0 2.0 +184.0 1.0 +188.0 1.0 +190.0 2.0 +191.0 1.0 +192.0 2.0 +197.0 1.0 +200.0 2.0 +205.0 1.0 +208.0 2.0 +210.0 1.0 +212.0 1.0 +214.0 1.0 +215.0 1.0 +218.0 1.0 +224.0 1.0 +226.0 1.0 +229.0 1.0 +231.0 2.0 +232.0 1.0 +236.0 1.0 +240.0 2.0 +241.0 1.0 +242.0 1.0 +244.0 3.0 +245.0 1.0 +247.0 1.0 +250.0 1.0 +252.0 1.0 +254.0 1.0 +258.0 2.0 +259.0 1.0 +262.0 1.0 +263.0 1.0 +264.0 1.0 +265.0 1.0 +271.0 1.0 +274.0 1.0 +275.0 1.0 +278.0 1.0 +280.0 1.0 +281.0 2.0 +284.0 1.0 +286.0 2.0 +288.0 2.0 +289.0 1.0 +292.0 1.0 +293.0 1.0 +294.0 1.0 +296.0 1.0 +300.0 1.0 +302.0 1.0 +304.0 2.0 +306.0 1.0 +308.0 1.0 +310.0 1.0 +311.0 1.0 +314.0 1.0 +315.0 1.0 +317.0 1.0 +318.0 2.0 +320.0 2.0 +324.0 1.0 +325.0 1.0 +326.0 3.0 +329.0 1.0 +330.0 1.0 +331.0 1.0 +332.0 1.0 +333.0 1.0 +334.0 2.0 +338.0 1.0 +339.0 1.0 +340.0 1.0 +342.0 1.0 +343.0 1.0 +344.0 3.0 +345.0 1.0 +348.0 3.0 +349.0 1.0 +350.0 1.0 +352.0 1.0 +356.0 3.0 +357.0 1.0 +358.0 4.0 +360.0 1.0 +362.0 9.0 +364.0 7.0 +366.0 3.0 +367.0 2.0 +368.0 28.0 +374.0 2.0 +375.0 2.0 +387.0 2.0 +388.0 1.0 +389.0 1.0 +399.0 1.0 +401.0 1.0 +403.0 1.0 +406.0 1.0 +415.0 1.0 +419.0 1.0 +425.0 1.0 +426.0 1.0 +430.0 1.0 +432.0 1.0 +436.0 1.0 +445.0 1.0 +447.0 1.0 +454.0 1.0 +458.0 2.0 +459.0 1.0 +460.0 1.0 +463.0 1.0 +476.0 1.0 +477.0 1.0 +480.0 1.0 +481.0 1.0 +483.0 2.0 +489.0 1.0 +492.0 1.0 +500.0 1.0 +501.0 1.0 +504.0 1.0 +508.0 1.0 +511.0 1.0 +512.0 1.0 +515.0 1.0 +525.0 3.0 +529.0 1.0 +533.0 1.0 +539.0 1.0 +540.0 1.0 +541.0 1.0 +546.0 1.0 +549.0 1.0 +550.0 1.0 +553.0 1.0 +559.0 1.0 +563.0 2.0 +565.0 1.0 +569.0 1.0 +575.0 1.0 +577.0 1.0 +578.0 1.0 +579.0 1.0 +591.0 2.0 +592.0 2.0 +593.0 2.0 +601.0 1.0 +603.0 1.0 +605.0 1.0 +610.0 1.0 +611.0 1.0 +613.0 2.0 +617.0 1.0 +622.0 1.0 +625.0 1.0 +628.0 1.0 +637.0 2.0 +639.0 1.0 +640.0 1.0 +643.0 1.0 +652.0 2.0 +657.0 1.0 +661.0 1.0 +663.0 2.0 +665.0 1.0 +669.0 1.0 +671.0 1.0 +674.0 1.0 +675.0 1.0 +679.0 1.0 +685.0 1.0 +687.0 1.0 +689.0 1.0 +692.0 1.0 +694.0 1.0 +697.0 2.0 +698.0 1.0 +699.0 1.0 +705.0 1.0 +711.0 1.0 +714.0 1.0 +719.0 2.0 +724.0 1.0 +727.0 1.0 +728.0 1.0 +732.0 1.0 +733.0 1.0 +735.0 1.0 +738.0 1.0 +741.0 1.0 +746.0 1.0 +752.0 1.0 +755.0 3.0 +756.0 1.0 +757.0 1.0 +763.0 1.0 +765.0 1.0 +767.0 1.0 +769.0 1.0 +770.0 1.0 +771.0 2.0 +773.0 2.0 +774.0 1.0 +775.0 1.0 +779.0 3.0 +781.0 1.0 +782.0 1.0 +785.0 2.0 +788.0 1.0 +789.0 2.0 +792.0 1.0 +793.0 5.0 +794.0 4.0 +795.0 7.0 +796.0 9.0 +797.0 8.0 +799.0 1.0 +801.0 1.0 +806.0 1.0 +807.0 1.0 +817.0 1.0 +820.0 1.0 +824.0 1.0 +825.0 1.0 +847.0 1.0 +850.0 1.0 +851.0 1.0 +853.0 1.0 +868.0 1.0 +873.0 1.0 +874.0 1.0 +875.0 1.0 +892.0 1.0 +893.0 1.0 +902.0 1.0 +906.0 1.0 +908.0 1.0 +916.0 1.0 +925.0 1.0 +927.0 1.0 +935.0 1.0 +937.0 1.0 +944.0 1.0 +955.0 1.0 +965.0 2.0 +967.0 1.0 +986.0 1.0 +988.0 1.0 +999.0 2.0 +1008.0 1.0 +1013.0 1.0 +1019.0 1.0 +1026.0 1.0 +1038.0 1.0 +1041.0 1.0 +1043.0 1.0 +1051.0 1.0 +1061.0 1.0 +1062.0 1.0 +1071.0 1.0 +1078.0 1.0 +1091.0 1.0 +1092.0 1.0 +1101.0 1.0 +1105.0 1.0 +1110.0 1.0 +1115.0 1.0 +1133.0 1.0 +1135.0 1.0 +1139.0 1.0 +1151.0 1.0 +1152.0 1.0 +1157.0 1.0 +1168.0 1.0 +1173.0 1.0 +1183.0 1.0 +1185.0 1.0 +1191.0 1.0 +1195.0 1.0 +1196.0 1.0 +1211.0 1.0 +1216.0 1.0 +1228.0 2.0 +1229.0 1.0 +1239.0 1.0 +1249.0 1.0 +1258.0 1.0 +1264.0 1.0 +1266.0 1.0 +1273.0 1.0 +1283.0 1.0 +1285.0 1.0 +1292.0 1.0 +1293.0 1.0 +1307.0 1.0 +1312.0 2.0 +1317.0 1.0 +1323.0 1.0 +1335.0 1.0 +1342.0 1.0 +1345.0 2.0 +1361.0 1.0 +1374.0 1.0 +1377.0 1.0 +1379.0 1.0 +1386.0 1.0 +1401.0 1.0 +1408.0 1.0 +1411.0 1.0 +1412.0 1.0 +1417.0 1.0 +1427.0 1.0 +1428.0 1.0 +1440.0 1.0 +1441.0 1.0 +1447.0 1.0 +1451.0 1.0 +1457.0 1.0 +1458.0 1.0 +1465.0 1.0 +1480.0 1.0 +1483.0 1.0 +1489.0 1.0 +1491.0 1.0 +1501.0 1.0 +1503.0 1.0 +1507.0 1.0 +1512.0 1.0 +1513.0 1.0 +1519.0 1.0 +1527.0 1.0 +1532.0 1.0 +1545.0 1.0 +1550.0 1.0 +1552.0 1.0 +1558.0 1.0 +1561.0 1.0 +1562.0 1.0 +1567.0 1.0 +1568.0 2.0 +1577.0 1.0 +1580.0 1.0 +1584.0 1.0 +1590.0 1.0 +1591.0 1.0 +1602.0 2.0 +1605.0 1.0 +1608.0 1.0 +1612.0 2.0 +1615.0 1.0 +1622.0 2.0 +1624.0 1.0 +1632.0 1.0 +1633.0 1.0 +1640.0 1.0 +1642.0 1.0 +1644.0 1.0 +1646.0 1.0 +1647.0 1.0 +1650.0 1.0 +1657.0 1.0 +1658.0 1.0 +1660.0 1.0 +1666.0 1.0 +1672.0 1.0 +1676.0 1.0 +1680.0 1.0 +1682.0 2.0 +1684.0 1.0 +1687.0 1.0 +1689.0 1.0 +1693.0 1.0 +1696.0 1.0 +1700.0 2.0 +1708.0 2.0 +1712.0 1.0 +1714.0 1.0 +1716.0 1.0 +1718.0 1.0 +1720.0 1.0 +1728.0 2.0 +1734.0 2.0 +1738.0 1.0 +1740.0 2.0 +1744.0 1.0 +1746.0 1.0 +1748.0 2.0 +1754.0 2.0 +1760.0 2.0 +1762.0 1.0 +1764.0 2.0 +1768.0 3.0 +1772.0 2.0 +1777.0 1.0 +1809.0 1.0 +1812.0 1.0 +1832.0 1.0 +1842.0 1.0 +1855.0 1.0 +1878.0 1.0 +1879.0 1.0 +1902.0 1.0 +1909.0 1.0 +1932.0 2.0 +1936.0 1.0 +1952.0 1.0 +1959.0 1.0 +1979.0 1.0 +1996.0 1.0 +1999.0 1.0 +2016.0 1.0 +2019.0 1.0 +2037.0 1.0 +2049.0 1.0 +2065.0 1.0 +2077.0 1.0 +2091.0 1.0 +2092.0 1.0 +2103.0 1.0 +2115.0 1.0 +2133.0 1.0 +2136.0 1.0 +2147.0 1.0 +2151.0 1.0 +2163.0 1.0 +2174.0 1.0 +2192.0 2.0 +2210.0 1.0 +2221.0 1.0 +2224.0 1.0 +2238.0 1.0 +2246.0 1.0 +2264.0 1.0 +2269.0 1.0 +2278.0 1.0 +2282.0 1.0 +2297.0 1.0 +2298.0 1.0 +2304.0 1.0 +2314.0 1.0 +2320.0 1.0 +2324.0 1.0 +2332.0 1.0 +2336.0 1.0 +2342.0 1.0 +2343.0 1.0 +2352.0 1.0 +2366.0 1.0 +2372.0 1.0 +2384.0 2.0 +2388.0 1.0 +2392.0 1.0 +2394.0 1.0 +2401.0 1.0 +2409.0 1.0 +2410.0 1.0 +2416.0 1.0 +2418.0 1.0 +2427.0 1.0 +2430.0 1.0 +2432.0 1.0 +2444.0 1.0 +2447.0 1.0 +2459.0 1.0 +2460.0 1.0 +2473.0 1.0 +2480.0 1.0 +2481.0 1.0 +2489.0 1.0 +2498.0 1.0 +2507.0 1.0 +2509.0 1.0 +2513.0 1.0 +2515.0 1.0 +2517.0 1.0 +2525.0 1.0 +2527.0 3.0 +2528.0 1.0 +2529.0 2.0 +2531.0 1.0 +2532.0 2.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/duplication_rate_histogram.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/duplication_rate_histogram.txt new file mode 100644 index 00000000..67204929 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/duplication_rate_histogram.txt @@ -0,0 +1,51 @@ +#Duplication rate Coverage +1.0 15.0 +2.0 41.0 +3.0 3.0 +4.0 31.0 +5.0 6.0 +6.0 14.0 +7.0 2.0 +8.0 28.0 +9.0 7.0 +10.0 27.0 +11.0 6.0 +12.0 20.0 +13.0 1.0 +14.0 14.0 +15.0 6.0 +16.0 16.0 +17.0 8.0 +18.0 19.0 +19.0 4.0 +20.0 12.0 +21.0 10.0 +22.0 6.0 +23.0 8.0 +24.0 7.0 +25.0 5.0 +26.0 3.0 +27.0 4.0 +28.0 2.0 +29.0 7.0 +30.0 6.0 +31.0 3.0 +32.0 2.0 +33.0 3.0 +34.0 4.0 +35.0 4.0 +36.0 3.0 +37.0 2.0 +38.0 0.0 +39.0 4.0 +40.0 1.0 +41.0 1.0 +42.0 1.0 +43.0 2.0 +44.0 1.0 +45.0 0.0 +46.0 3.0 +47.0 1.0 +48.0 1.0 +49.0 1.0 +50.0 4.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/genome_fraction_coverage.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/genome_fraction_coverage.txt new file mode 100644 index 00000000..e0eade2e --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/genome_fraction_coverage.txt @@ -0,0 +1,52 @@ +#Coverage (X) Coverage +1.0 2.9524261893452746 +2.0 2.8524286892827746 +3.0 2.6449338766530843 +4.0 2.564935876603087 +5.0 2.529936751581218 +6.0 2.504937376565593 +7.0 2.5024374390640247 +8.0 2.4724381890452776 +9.0 2.4524386890327747 +10.0 2.429939251518718 +11.0 2.404939876503093 +12.0 2.4024399390015247 +13.0 2.3899402514937123 +14.0 2.387440313992144 +15.0 2.3774405639858998 +16.0 2.3749406264843316 +17.0 2.3524411889702748 +18.0 2.3499412514687066 +19.0 2.3474413139671384 +20.0 2.3424414389640162 +21.0 2.3299417514562037 +22.0 2.2974425639358884 +23.0 2.2749431264218316 +24.0 2.2699432514187095 +25.0 2.2549436264093288 +26.0 2.2524436889077606 +27.0 2.007449813754633 +28.0 2.007449813754633 +29.0 2.007449813754633 +30.0 2.007449813754633 +31.0 2.0024499387515107 +32.0 2.0024499387515107 +33.0 1.9999500012499425 +34.0 1.9999500012499425 +35.0 1.9999500012499425 +36.0 1.9999500012499425 +37.0 1.9974500637483743 +38.0 1.9949501262468061 +39.0 1.9949501262468061 +40.0 1.9949501262468061 +41.0 1.989950251243684 +42.0 1.9874503137421158 +43.0 1.9874503137421158 +44.0 1.9824504387389936 +45.0 1.9824504387389936 +46.0 1.9774505637358715 +47.0 1.9749506262343033 +48.0 1.9749506262343033 +49.0 1.972450688732735 +50.0 1.972450688732735 +51.0 1.9599510012249226 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/homopolymer_indels.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/homopolymer_indels.txt new file mode 100644 index 00000000..f3769464 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/homopolymer_indels.txt @@ -0,0 +1,7 @@ +#Type of indel Number of indels +polyA 2 +polyC 2 +polyG 1 +polyT 2 +polyN 0 +Non-poly 5 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_across_reference.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_across_reference.txt new file mode 100644 index 00000000..b1444ee9 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_across_reference.txt @@ -0,0 +1,398 @@ +#Position (bp) insert size +51.0 0.0 +152.0 0.0 +253.0 0.0 +354.0 0.0 +455.0 0.0 +556.0 0.0 +657.0 0.0 +758.0 0.0 +859.0 0.0 +960.0 0.0 +1061.0 0.0 +1162.0 0.0 +1263.0 0.0 +1364.0 0.0 +1465.0 0.0 +1566.0 0.0 +1667.0 0.0 +1768.0 0.0 +1869.0 0.0 +1970.0 108.91666666666667 +2071.0 77.975 +2172.0 0.0 +2273.0 0.0 +2374.0 0.0 +2475.0 0.0 +2576.0 0.0 +2677.0 105.26829268292683 +2778.0 94.76223776223776 +2879.0 162.94767441860466 +2980.0 128.54441260744986 +3081.0 83.66666666666667 +3182.0 118.23076923076923 +3283.0 0.0 +3384.0 144.00704225352112 +3485.0 128.55276381909547 +3586.0 0.0 +3687.0 0.0 +3788.0 0.0 +3889.0 0.0 +3990.0 0.0 +4091.0 0.0 +4192.0 0.0 +4293.0 0.0 +4394.0 0.0 +4495.0 77.80434782608695 +4596.0 0.0 +4697.0 0.0 +4798.0 0.0 +4899.0 0.0 +5000.0 0.0 +5101.0 0.0 +5202.0 0.0 +5303.0 0.0 +5404.0 0.0 +5505.0 0.0 +5606.0 0.0 +5707.0 0.0 +5808.0 0.0 +5909.0 0.0 +6010.0 0.0 +6111.0 0.0 +6212.0 0.0 +6313.0 0.0 +6414.0 0.0 +6515.0 0.0 +6616.0 0.0 +6717.0 0.0 +6818.0 0.0 +6919.0 0.0 +7020.0 0.0 +7121.0 0.0 +7222.0 0.0 +7323.0 0.0 +7424.0 0.0 +7525.0 0.0 +7626.0 0.0 +7727.0 0.0 +7828.0 0.0 +7929.0 0.0 +8030.0 0.0 +8131.0 0.0 +8232.0 0.0 +8333.0 0.0 +8434.0 0.0 +8535.0 0.0 +8636.0 0.0 +8737.0 0.0 +8838.0 0.0 +8939.0 0.0 +9040.0 0.0 +9141.0 0.0 +9242.0 0.0 +9343.0 0.0 +9444.0 0.0 +9545.0 0.0 +9646.0 0.0 +9747.0 0.0 +9848.0 0.0 +9949.0 0.0 +10050.0 0.0 +10151.0 0.0 +10252.0 0.0 +10353.0 0.0 +10454.0 0.0 +10555.0 0.0 +10656.0 0.0 +10757.0 0.0 +10858.0 0.0 +10959.0 0.0 +11060.0 0.0 +11161.0 0.0 +11262.0 0.0 +11363.0 0.0 +11464.0 0.0 +11565.0 0.0 +11666.0 0.0 +11767.0 0.0 +11868.0 0.0 +11969.0 0.0 +12070.0 0.0 +12171.0 0.0 +12272.0 0.0 +12373.0 0.0 +12474.0 0.0 +12575.0 0.0 +12676.0 0.0 +12777.0 0.0 +12878.0 0.0 +12979.0 0.0 +13080.0 0.0 +13181.0 0.0 +13282.0 0.0 +13383.0 0.0 +13484.0 0.0 +13585.0 0.0 +13686.0 0.0 +13787.0 0.0 +13888.0 0.0 +13989.0 0.0 +14090.0 0.0 +14191.0 0.0 +14292.0 0.0 +14393.0 0.0 +14494.0 0.0 +14595.0 0.0 +14696.0 0.0 +14797.0 0.0 +14898.0 0.0 +14999.0 0.0 +15100.0 0.0 +15201.0 0.0 +15302.0 0.0 +15403.0 0.0 +15504.0 0.0 +15605.0 0.0 +15706.0 0.0 +15807.0 0.0 +15908.0 0.0 +16009.0 0.0 +16110.0 0.0 +16211.0 0.0 +16312.0 0.0 +16413.0 0.0 +16514.0 0.0 +16615.0 0.0 +16716.0 0.0 +16817.0 0.0 +16918.0 0.0 +17019.0 0.0 +17120.0 0.0 +17221.0 0.0 +17322.0 0.0 +17423.0 0.0 +17524.0 0.0 +17625.0 0.0 +17726.0 0.0 +17827.0 0.0 +17928.0 0.0 +18029.0 0.0 +18130.0 0.0 +18231.0 0.0 +18332.0 0.0 +18433.0 0.0 +18534.0 0.0 +18635.0 0.0 +18736.0 0.0 +18837.0 0.0 +18938.0 0.0 +19039.0 0.0 +19140.0 0.0 +19241.0 0.0 +19342.0 0.0 +19443.0 0.0 +19544.0 0.0 +19645.0 0.0 +19746.0 0.0 +19847.0 0.0 +19948.0 0.0 +20049.0 0.0 +20150.0 0.0 +20251.0 0.0 +20352.0 0.0 +20453.0 0.0 +20554.0 0.0 +20655.0 0.0 +20756.0 0.0 +20857.0 0.0 +20958.0 0.0 +21059.0 0.0 +21160.0 0.0 +21261.0 0.0 +21362.0 0.0 +21463.0 0.0 +21564.0 0.0 +21665.0 0.0 +21766.0 0.0 +21867.0 0.0 +21968.0 0.0 +22069.0 0.0 +22170.0 0.0 +22271.0 0.0 +22372.0 0.0 +22473.0 0.0 +22574.0 0.0 +22675.0 0.0 +22776.0 0.0 +22877.0 0.0 +22978.0 0.0 +23079.0 0.0 +23180.0 0.0 +23281.0 0.0 +23382.0 0.0 +23483.0 0.0 +23584.0 0.0 +23685.0 0.0 +23786.0 0.0 +23887.0 0.0 +23988.0 0.0 +24089.0 0.0 +24190.0 0.0 +24291.0 0.0 +24392.0 0.0 +24493.0 0.0 +24594.0 0.0 +24695.0 0.0 +24796.0 0.0 +24897.0 0.0 +24998.0 0.0 +25099.0 0.0 +25200.0 0.0 +25301.0 0.0 +25402.0 0.0 +25503.0 0.0 +25604.0 0.0 +25705.0 0.0 +25806.0 0.0 +25907.0 0.0 +26008.0 0.0 +26109.0 0.0 +26210.0 0.0 +26311.0 0.0 +26412.0 0.0 +26513.0 0.0 +26614.0 0.0 +26715.0 0.0 +26816.0 0.0 +26917.0 0.0 +27018.0 0.0 +27119.0 0.0 +27220.0 0.0 +27321.0 0.0 +27422.0 0.0 +27523.0 0.0 +27624.0 0.0 +27725.0 0.0 +27826.0 0.0 +27927.0 0.0 +28028.0 0.0 +28129.0 0.0 +28230.0 0.0 +28331.0 0.0 +28432.0 0.0 +28533.0 0.0 +28634.0 0.0 +28735.0 0.0 +28836.0 0.0 +28937.0 0.0 +29038.0 0.0 +29139.0 0.0 +29240.0 0.0 +29341.0 0.0 +29442.0 0.0 +29543.0 0.0 +29644.0 0.0 +29745.0 0.0 +29846.0 0.0 +29947.0 0.0 +30048.0 0.0 +30149.0 0.0 +30250.0 0.0 +30351.0 0.0 +30452.0 0.0 +30553.0 0.0 +30654.0 0.0 +30755.0 0.0 +30856.0 0.0 +30957.0 0.0 +31058.0 0.0 +31159.0 0.0 +31260.0 0.0 +31361.0 0.0 +31462.0 0.0 +31563.0 0.0 +31664.0 0.0 +31765.0 0.0 +31866.0 0.0 +31967.0 0.0 +32068.0 0.0 +32169.0 0.0 +32270.0 0.0 +32371.0 0.0 +32472.0 0.0 +32573.0 0.0 +32674.0 0.0 +32775.0 0.0 +32876.0 0.0 +32977.0 0.0 +33078.0 0.0 +33179.0 0.0 +33280.0 0.0 +33381.0 0.0 +33482.0 0.0 +33583.0 0.0 +33684.0 0.0 +33785.0 0.0 +33886.0 0.0 +33987.0 0.0 +34088.0 0.0 +34189.0 0.0 +34290.0 0.0 +34391.0 0.0 +34492.0 0.0 +34593.0 0.0 +34694.0 0.0 +34795.0 0.0 +34896.0 0.0 +34997.0 0.0 +35098.0 0.0 +35199.0 0.0 +35300.0 0.0 +35401.0 0.0 +35502.0 0.0 +35603.0 0.0 +35704.0 0.0 +35805.0 0.0 +35906.0 0.0 +36007.0 0.0 +36108.0 0.0 +36209.0 0.0 +36310.0 0.0 +36411.0 0.0 +36512.0 0.0 +36613.0 0.0 +36714.0 0.0 +36815.0 0.0 +36916.0 0.0 +37017.0 0.0 +37118.0 0.0 +37219.0 0.0 +37320.0 0.0 +37421.0 0.0 +37522.0 0.0 +37623.0 0.0 +37724.0 0.0 +37825.0 0.0 +37926.0 0.0 +38027.0 0.0 +38128.0 0.0 +38229.0 0.0 +38330.0 0.0 +38431.0 0.0 +38532.0 0.0 +38633.0 0.0 +38734.0 0.0 +38835.0 0.0 +38936.0 0.0 +39037.0 0.0 +39138.0 0.0 +39239.0 0.0 +39340.0 0.0 +39441.0 0.0 +39542.0 0.0 +39643.0 0.0 +39744.0 0.0 +39845.0 0.0 +39946.0 0.0 +39999.0 0.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_histogram.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_histogram.txt new file mode 100644 index 00000000..e9b8c479 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/insert_size_histogram.txt @@ -0,0 +1,171 @@ +#Insert size (bp) insert size +32.0 1.0 +41.0 1.0 +49.0 3.0 +51.0 1.0 +52.0 2.0 +54.0 1.0 +58.0 1.0 +59.0 2.0 +60.0 1.0 +61.0 4.0 +62.0 1.0 +63.0 5.0 +65.0 5.0 +66.0 2.0 +67.0 6.0 +68.0 3.0 +69.0 5.0 +70.0 10.0 +71.0 11.0 +72.0 7.0 +73.0 8.0 +74.0 4.0 +75.0 12.0 +76.0 11.0 +77.0 19.0 +78.0 15.0 +79.0 13.0 +80.0 17.0 +81.0 24.0 +82.0 18.0 +83.0 19.0 +84.0 25.0 +85.0 15.0 +86.0 24.0 +87.0 30.0 +88.0 29.0 +89.0 21.0 +90.0 16.0 +91.0 24.0 +92.0 30.0 +93.0 23.0 +94.0 21.0 +95.0 43.0 +96.0 54.0 +97.0 34.0 +98.0 28.0 +99.0 24.0 +100.0 44.0 +101.0 24.0 +102.0 27.0 +103.0 22.0 +104.0 33.0 +105.0 26.0 +106.0 28.0 +107.0 35.0 +108.0 26.0 +109.0 24.0 +110.0 34.0 +111.0 29.0 +112.0 22.0 +113.0 36.0 +114.0 30.0 +115.0 49.0 +116.0 36.0 +117.0 33.0 +118.0 34.0 +119.0 38.0 +120.0 14.0 +121.0 39.0 +122.0 30.0 +123.0 28.0 +124.0 36.0 +125.0 36.0 +126.0 25.0 +127.0 32.0 +128.0 31.0 +129.0 28.0 +130.0 39.0 +131.0 45.0 +132.0 25.0 +133.0 18.0 +134.0 25.0 +135.0 31.0 +136.0 30.0 +137.0 29.0 +138.0 34.0 +139.0 32.0 +140.0 28.0 +141.0 41.0 +142.0 27.0 +143.0 23.0 +144.0 26.0 +145.0 31.0 +146.0 21.0 +147.0 29.0 +148.0 18.0 +149.0 17.0 +150.0 19.0 +151.0 20.0 +152.0 28.0 +153.0 28.0 +154.0 18.0 +155.0 23.0 +156.0 20.0 +157.0 29.0 +158.0 16.0 +159.0 15.0 +160.0 14.0 +161.0 18.0 +162.0 19.0 +163.0 15.0 +164.0 9.0 +165.0 11.0 +166.0 21.0 +167.0 9.0 +168.0 17.0 +169.0 16.0 +170.0 17.0 +171.0 13.0 +172.0 14.0 +173.0 21.0 +174.0 9.0 +175.0 9.0 +176.0 7.0 +177.0 9.0 +178.0 9.0 +179.0 9.0 +180.0 2.0 +181.0 8.0 +182.0 8.0 +183.0 3.0 +184.0 12.0 +185.0 10.0 +186.0 5.0 +187.0 7.0 +188.0 1.0 +189.0 5.0 +190.0 8.0 +191.0 10.0 +192.0 8.0 +193.0 2.0 +194.0 6.0 +195.0 1.0 +196.0 2.0 +197.0 3.0 +198.0 2.0 +199.0 4.0 +200.0 7.0 +201.0 2.0 +202.0 6.0 +203.0 4.0 +204.0 4.0 +205.0 2.0 +206.0 4.0 +207.0 4.0 +209.0 1.0 +210.0 1.0 +212.0 2.0 +213.0 4.0 +214.0 3.0 +215.0 1.0 +216.0 4.0 +218.0 2.0 +220.0 1.0 +221.0 2.0 +223.0 1.0 +224.0 1.0 +231.0 1.0 +236.0 1.0 +239.0 1.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_clipping_profile.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_clipping_profile.txt new file mode 100644 index 00000000..27a26cd9 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_clipping_profile.txt @@ -0,0 +1,144 @@ +#Read position (bp) Clipping profile +0.0 1.8539976825028968 +1.0 1.8539976825028968 +2.0 1.8539976825028968 +3.0 1.738122827346466 +4.0 1.6222479721900347 +5.0 1.5063731170336037 +6.0 1.5063731170336037 +7.0 1.3904982618771726 +8.0 1.3904982618771726 +9.0 1.3904982618771726 +10.0 1.3904982618771726 +11.0 1.3904982618771726 +12.0 1.3904982618771726 +13.0 1.1587485515643106 +14.0 1.1587485515643106 +15.0 1.1587485515643106 +16.0 1.1587485515643106 +17.0 1.1587485515643106 +18.0 1.1587485515643106 +19.0 1.1587485515643106 +20.0 1.1587485515643106 +21.0 1.1587485515643106 +22.0 1.1587485515643106 +23.0 1.1587485515643106 +24.0 1.1587485515643106 +25.0 1.1587485515643106 +26.0 1.0428736964078795 +27.0 1.0428736964078795 +28.0 1.0428736964078795 +29.0 1.0428736964078795 +30.0 1.0428736964078795 +31.0 1.0428736964078795 +32.0 0.9269988412514484 +33.0 0.9269988412514484 +34.0 0.9269988412514484 +35.0 0.9269988412514484 +36.0 0.9269988412514484 +37.0 1.1587485515643106 +38.0 1.1587485515643106 +39.0 1.1587485515643106 +40.0 1.1587485515643106 +41.0 1.0428736964078795 +42.0 1.0428736964078795 +43.0 1.0428736964078795 +44.0 1.0428736964078795 +45.0 1.0428736964078795 +46.0 1.0428736964078795 +47.0 1.0428736964078795 +48.0 0.9269988412514484 +49.0 0.9269988412514484 +50.0 0.9269988412514484 +51.0 0.9269988412514484 +52.0 0.9269988412514484 +53.0 0.9269988412514484 +54.0 0.8111239860950173 +55.0 0.8111239860950173 +56.0 0.8111239860950173 +57.0 0.8111239860950173 +58.0 0.8111239860950173 +59.0 0.8111239860950173 +60.0 0.8111239860950173 +61.0 0.8111239860950173 +62.0 0.8111239860950173 +63.0 0.8111239860950173 +64.0 0.6952491309385863 +65.0 0.6952491309385863 +66.0 0.6952491309385863 +67.0 0.6952491309385863 +68.0 0.5793742757821553 +69.0 0.5793742757821553 +70.0 0.4634994206257242 +71.0 0.5793742757821553 +72.0 0.5793742757821553 +73.0 0.6952491309385863 +74.0 0.6952491309385863 +75.0 0.6952491309385863 +76.0 0.5793742757821553 +77.0 0.5793742757821553 +78.0 0.5793742757821553 +79.0 0.5793742757821553 +80.0 0.5793742757821553 +81.0 0.5793742757821553 +82.0 0.4634994206257242 +83.0 0.4634994206257242 +84.0 0.4634994206257242 +85.0 0.4634994206257242 +86.0 0.4634994206257242 +87.0 0.4634994206257242 +88.0 0.4634994206257242 +89.0 0.4634994206257242 +90.0 0.4634994206257242 +91.0 0.5793742757821553 +92.0 0.34762456546929316 +93.0 0.34762456546929316 +94.0 0.34762456546929316 +95.0 0.34762456546929316 +96.0 0.34762456546929316 +97.0 0.34762456546929316 +98.0 0.2317497103128621 +99.0 0.2317497103128621 +100.0 0.2317497103128621 +101.0 0.2317497103128621 +102.0 0.2317497103128621 +103.0 0.2317497103128621 +104.0 0.11587485515643105 +105.0 0.11587485515643105 +106.0 0.11587485515643105 +107.0 0.0 +108.0 0.0 +109.0 0.0 +110.0 0.0 +111.0 0.0 +112.0 0.0 +113.0 0.0 +114.0 0.0 +115.0 0.11587485515643105 +116.0 0.11587485515643105 +117.0 0.11587485515643105 +118.0 0.11587485515643105 +119.0 0.11587485515643105 +120.0 0.0 +121.0 0.0 +122.0 0.0 +123.0 0.0 +124.0 0.0 +125.0 0.11587485515643105 +126.0 0.2317497103128621 +127.0 0.4634994206257242 +128.0 0.34762456546929316 +129.0 0.34762456546929316 +130.0 0.4634994206257242 +131.0 0.4634994206257242 +132.0 0.4634994206257242 +133.0 0.4634994206257242 +134.0 0.4634994206257242 +135.0 0.4634994206257242 +136.0 0.4634994206257242 +137.0 0.4634994206257242 +138.0 0.34762456546929316 +139.0 0.34762456546929316 +140.0 0.5793742757821553 +141.0 0.6952491309385863 +142.0 0.6952491309385863 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_gc-content_distribution.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_gc-content_distribution.txt new file mode 100644 index 00000000..ffdab0f1 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_gc-content_distribution.txt @@ -0,0 +1,101 @@ +#GC Content (%) Sample +1.0 0.0 +2.0 0.0 +3.0 0.0 +4.0 0.0 +5.0 0.0 +6.0 0.0 +7.0 0.0 +8.0 0.0 +9.0 0.0 +10.0 0.0 +11.0 0.0 +12.0 0.0 +13.0 0.0 +14.0 0.0 +15.0 0.0 +16.0 0.0 +17.0 0.0 +18.0 0.0014727540500736377 +19.0 0.0 +20.0 0.0 +21.0 0.0 +22.0 0.0 +23.0 0.010309278350515464 +24.0 0.025036818851251842 +25.0 0.042709867452135494 +26.0 0.05301914580265096 +27.0 0.050073637702503684 +28.0 0.09131075110456553 +29.0 0.11192930780559647 +30.0 0.13991163475699558 +31.0 0.10751104565537553 +32.0 0.06774668630338734 +33.0 0.04860088365243005 +34.0 0.022091310751104563 +35.0 0.030927835051546393 +36.0 0.05891016200294552 +37.0 0.05301914580265096 +38.0 0.04860088365243005 +39.0 0.014727540500736377 +40.0 0.0029455081001472753 +41.0 0.0 +42.0 0.004418262150220913 +43.0 0.0014727540500736377 +44.0 0.0014727540500736377 +45.0 0.004418262150220913 +46.0 0.0029455081001472753 +47.0 0.004418262150220913 +48.0 0.0 +49.0 0.0 +50.0 0.0 +51.0 0.0 +52.0 0.0 +53.0 0.0 +54.0 0.0 +55.0 0.0 +56.0 0.0 +57.0 0.0 +58.0 0.0 +59.0 0.0 +60.0 0.0 +61.0 0.0 +62.0 0.0 +63.0 0.0 +64.0 0.0 +65.0 0.0 +66.0 0.0 +67.0 0.0 +68.0 0.0 +69.0 0.0 +70.0 0.0 +71.0 0.0 +72.0 0.0 +73.0 0.0 +74.0 0.0 +75.0 0.0 +76.0 0.0 +77.0 0.0 +78.0 0.0 +79.0 0.0 +80.0 0.0 +81.0 0.0 +82.0 0.0 +83.0 0.0 +84.0 0.0 +85.0 0.0 +86.0 0.0 +87.0 0.0 +88.0 0.0 +89.0 0.0 +90.0 0.0 +91.0 0.0 +92.0 0.0 +93.0 0.0 +94.0 0.0 +95.0 0.0 +96.0 0.0 +97.0 0.0 +98.0 0.0 +99.0 0.0 +100.0 0.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_nucleotide_content.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_nucleotide_content.txt new file mode 100644 index 00000000..23604a2d --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapped_reads_nucleotide_content.txt @@ -0,0 +1,144 @@ +# Position (bp) A C G T N +0.0 36.575391180654336 12.820056899004268 18.509957325746797 32.059032716927454 0.03556187766714083 +1.0 36.21977240398293 13.78022759601707 19.221194879089616 30.743243243243246 0.03556187766714083 +2.0 37.03769559032717 13.264580369843529 17.798719772403985 31.89900426742532 0.0 +3.0 36.92444444444444 13.137777777777778 17.262222222222224 32.65777777777778 0.017777777777777778 +4.0 38.019907571987204 12.371134020618557 17.774617845716318 31.834340561677926 0.0 +5.0 37.79989337124578 12.262306735382975 17.807001954860493 32.113026479473966 0.01777145903678692 +6.0 36.200462057934956 12.937622178780877 18.269059889816955 32.592855873467215 0.0 +7.0 35.69651741293532 13.592750533049042 17.608386638237384 33.04904051172708 0.053304904051172705 +8.0 36.247334754797436 12.082444918265814 17.928216062544422 33.724235963041934 0.017768301350390904 +9.0 37.65103056147832 12.064676616915424 18.176972281449892 32.08955223880597 0.017768301350390904 +10.0 36.49609097370291 12.722103766879886 17.235252309879176 33.546552949538025 0.0 +11.0 36.44278606965174 12.686567164179104 17.093105899076047 33.70646766169154 0.07107320540156362 +12.0 35.18123667377399 14.339019189765459 17.555081734186214 32.88912579957356 0.03553660270078181 +13.0 35.861456483126105 12.966252220248666 17.779751332149203 33.37477797513321 0.017761989342806393 +14.0 36.802841918294845 13.978685612788633 18.02841918294849 31.119005328596806 0.07104795737122557 +15.0 37.24689165186501 13.001776198934282 18.17051509769094 31.49200710479574 0.08880994671403197 +16.0 37.47779751332149 13.037300177619892 17.460035523978686 31.97158081705151 0.05328596802841918 +17.0 35.09769094138544 14.103019538188278 17.72646536412078 33.01953818827709 0.05328596802841918 +18.0 35.150976909413856 14.777975133214921 16.44760213143872 33.587921847246896 0.035523978685612786 +19.0 34.04973357015986 13.321492007104796 15.86145648312611 36.731793960923625 0.035523978685612786 +20.0 35.13321492007105 12.735346358792185 17.140319715808168 34.97335701598579 0.017761989342806393 +21.0 34.795737122557725 13.0550621669627 17.08703374777975 35.0088809946714 0.05328596802841918 +22.0 34.209591474245116 13.232682060390763 16.838365896980463 35.66607460035524 0.05328596802841918 +23.0 34.08525754884547 13.161634103019537 17.708703374777976 35.0088809946714 0.035523978685612786 +24.0 34.795737122557725 12.984014209591473 16.518650088809945 35.61278863232682 0.08880994671403197 +25.0 34.849023090586144 13.534635879218474 17.05150976909414 34.52930728241563 0.035523978685612786 +26.0 34.36334576451785 13.549991120582488 17.474693660095898 34.5586929497425 0.05327650506126798 +27.0 33.24453915823122 13.461196945480378 17.95418220564731 35.25128751553898 0.0887941751021133 +28.0 34.203516249334044 14.473450541644468 17.989699875688157 33.31557449831291 0.01775883502042266 +29.0 34.469898774640384 13.514473450541646 16.78209909429941 35.21576984549814 0.01775883502042266 +30.0 35.168738898756665 13.765541740674955 17.67317939609236 33.30373001776199 0.08880994671403197 +31.0 32.770870337477795 14.174067495559504 16.660746003552397 36.34103019538188 0.05328596802841918 +32.0 34.949387320191796 13.052743740010655 16.480198898952228 35.39335819570236 0.12431184514295864 +33.0 33.83658969804618 13.570159857904084 16.607460035523978 35.89698046181172 0.08880994671403197 +34.0 33.37477797513321 14.08525754884547 16.69626998223801 35.772646536412076 0.07104795737122557 +35.0 32.45115452930728 13.623445825932503 17.33570159857904 36.53641207815275 0.05328596802841918 +36.0 34.280639431616336 12.060390763765541 16.571936056838364 37.03374777975133 0.05328596802841918 +37.0 34.02629708599858 13.521677327647478 18.105899076048328 34.310589907604836 0.03553660270078181 +38.0 33.972992181947404 13.450604122245913 17.35963041933191 35.127931769722814 0.08884150675195451 +39.0 33.546552949538025 14.978678038379531 16.91542288557214 34.50604122245913 0.053304904051172705 +40.0 35.199004975124375 14.55223880597015 15.884861407249467 34.310589907604836 0.053304904051172705 +41.0 33.91968727789623 13.699360341151387 16.417910447761194 35.90973702914002 0.053304904051172705 +42.0 34.00852878464819 13.983653162757639 16.080312722103766 35.891968727789624 0.03553660270078181 +43.0 34.09737029140014 14.232409381663114 15.689410092395168 35.87420042643924 0.10660980810234541 +44.0 35.09239516702203 14.800995024875622 15.15636105188344 34.86140724946695 0.08884150675195451 +45.0 34.06788697352053 15.141283099342456 15.265683312599965 35.489603696463476 0.03554291807357384 +46.0 34.3700017771459 14.554824951128486 15.354540607783898 35.614003909720985 0.10662875422072153 +47.0 34.97423138439666 14.9457970499378 15.016882886084948 34.97423138439666 0.08885729518393459 +48.0 35.60767590618337 14.57000710732054 15.618336886993603 34.1684434968017 0.03553660270078181 +49.0 36.374955531839205 14.585556741373177 15.91960156527926 33.04873710423337 0.0711490572749911 +50.0 34.293845606545716 15.635005336179294 16.22198505869797 33.76022767698328 0.08893632159373889 +51.0 35.355871886121 16.601423487544483 15.640569395017796 32.36654804270462 0.03558718861209965 +52.0 34.98931623931624 15.206552706552706 17.11182336182336 32.67450142450142 0.017806267806267807 +53.0 35.57692307692308 15.918803418803417 15.776353276353278 32.65669515669516 0.07122507122507123 +54.0 33.8913624220837 16.812110418521815 17.008014247551202 32.181656277827244 0.10685663401602849 +55.0 36.02849510240427 15.85040071237756 16.046304541406943 32.00356188780054 0.07123775601068566 +56.0 35.636687444345505 15.45859305431879 15.796972395369547 33.018699910952805 0.08904719501335707 +57.0 36.349065004452356 14.56812110418522 14.95992876224399 33.998219056099735 0.1246660730186999 +58.0 34.90112239444147 16.443969356850168 14.840548726171388 33.74309638339569 0.07126313914127917 +59.0 34.694241397753615 16.616152611873776 15.207701907648422 33.41059012301658 0.07131395970761277 +60.0 34.706616729088644 16.800428036383092 15.337970394150169 33.083645443196005 0.07133939718209381 +61.0 36.470798356849436 15.395606358278263 16.038578317556706 32.04143597070905 0.05358099660653688 +62.0 35.644095050920136 14.632839020904056 15.436841164909772 34.16115776308737 0.12506700017866715 +63.0 33.56005011634151 15.285484159656345 16.144621442634687 34.938249507785926 0.07159477358152855 +64.0 35.36148890479599 15.085898353614887 16.35647816750179 33.08876163206872 0.10737294201861132 +65.0 34.38508425959125 15.561133022588741 16.20652563642883 33.811401936177845 0.035855145213338116 +66.0 36.54467168998924 16.397560100466453 14.872622891998565 32.131324004305704 0.05382131324004305 +67.0 35.2391226177634 16.50485436893204 15.24631427544049 32.991729593671344 0.017979144192736426 +68.0 35.871130309575236 16.180705543556513 15.712742980561556 32.19942404607632 0.03599712023038157 +69.0 35.7194374323837 15.380454381536243 16.300036062026685 32.58204111071043 0.018031013342949875 +70.0 35.10041613895423 15.469513298353538 16.55509317893975 32.85688438574272 0.01809299800977022 +71.0 36.26453488372093 16.333575581395348 15.715843023255813 31.64970930232558 0.036337209302325583 +72.0 36.48451730418943 15.282331511839708 15.100182149362476 33.114754098360656 0.018214936247723135 +73.0 36.01315549059017 15.055728119861136 15.073999634569708 33.83884524027042 0.01827151470856934 +74.0 35.02287282708142 14.217749313815187 15.279048490393413 35.42543458371455 0.05489478499542544 +75.0 33.651902223855906 15.585370336335233 14.648042639220732 36.05954787722845 0.05513692335967653 +76.0 33.94833948339483 15.77490774907749 15.33210332103321 34.92619926199262 0.01845018450184502 +77.0 34.46674098848012 16.257896692679303 15.663322185061315 33.537718320327016 0.07432181345224824 +78.0 35.48206278026906 15.041106128550075 14.31240657698057 35.1270553064275 0.03736920777279522 +79.0 34.209538114908 16.522718738265116 13.875328576793091 35.317311303041684 0.07510326699211416 +80.0 34.03250188964474 16.93121693121693 14.13454270597128 34.863945578231295 0.03779289493575208 +81.0 33.44774980930587 16.113653699466056 15.408085430968727 35.01144164759725 0.01906941266209001 +82.0 33.36532923785755 16.37550393549626 13.419082357458246 36.80168938375888 0.03839508542906508 +83.0 34.480758073873524 16.418487719976792 12.531425256236705 36.54999033069039 0.019338619222587505 +84.0 35.344659246240965 15.52431165787932 12.90763522749463 36.1452841241945 0.07810974419058778 +85.0 35.867216656845414 16.519347868788056 13.94617953250835 33.64761343547437 0.019642506383814574 +86.0 36.149117588736864 16.141185802101923 12.71068808249058 34.97917906008328 0.019829466587348802 +87.0 34.85851896447923 15.79369857515553 13.064419024683927 36.283363435681316 0.0 +88.0 36.44670050761422 15.472081218274111 12.101522842639593 35.93908629441624 0.04060913705583756 +89.0 35.65431087446242 14.396887159533073 12.840466926070038 37.10833503993447 0.0 +90.0 34.50834879406308 16.14100185528757 13.749742321170894 35.600907029478456 0.0 +91.0 36.3579604578564 15.691987513007282 12.11238293444329 35.796045785639954 0.04162330905306972 +92.0 37.0339161575732 15.820518222034968 13.313671792711185 33.831893827680645 0.0 +93.0 35.99234205488194 16.507126143373753 13.316315677515423 34.1629440544565 0.021272069772388852 +94.0 35.142673246084534 15.85496674533362 14.353143102338553 34.62776228277194 0.021454623471358077 +95.0 36.29807692307692 15.887237762237763 13.439685314685315 34.375 0.0 +96.0 34.355416293643685 16.02506714413608 15.085049239033124 34.51208594449418 0.022381378692927483 +97.0 34.97727272727273 16.545454545454547 14.295454545454545 34.18181818181818 0.0 +98.0 35.627157652474104 17.12313003452244 13.73993095512083 33.50978135788262 0.0 +99.0 35.070979753316266 17.989294856876892 12.357458692110775 34.58226669769607 0.0 +100.0 36.08933238298883 16.39344262295082 12.21192682347351 35.28153955808981 0.023758612497030172 +101.0 35.90483056957462 15.957702475366498 12.32876712328767 35.80869983177121 0.0 +102.0 35.64645726807889 16.43535427319211 13.562210859508156 34.35597759922084 0.0 +103.0 36.697021904996305 15.850356879153335 12.552301255230125 34.90031996062023 0.0 +104.0 35.867933966983486 16.983491745872936 11.85592796398199 35.29264632316158 0.0 +105.0 37.78509883426254 16.016218955904712 12.316269640141916 33.88241256969083 0.0 +106.0 34.70437017994858 16.73521850899743 12.313624678663238 36.221079691516714 0.025706940874035987 +107.0 33.58638743455498 16.910994764397905 14.319371727748692 35.13089005235602 0.052356020942408384 +108.0 33.70488322717622 15.233545647558385 13.641188959660298 37.39384288747346 0.02653927813163482 +109.0 33.70967741935484 16.93548387096774 13.064516129032258 36.26344086021505 0.026881720430107527 +110.0 33.433734939759034 18.209200438116103 12.568455640744796 35.788608981380065 0.0 +111.0 33.36115748469672 17.50139120756817 13.439065108514189 35.67056204785754 0.02782415136338342 +112.0 33.183098591549296 16.788732394366196 13.521126760563378 36.50704225352113 0.0 +113.0 31.6561242093157 17.30879815986199 14.40483036227717 36.63024726854514 0.0 +114.0 31.71445289643066 18.461088355763604 14.160327677004094 35.63487419543593 0.029256875365710942 +115.0 33.373493975903614 16.355421686746986 13.283132530120481 36.95783132530121 0.030120481927710847 +116.0 31.434729064039406 16.163793103448278 13.023399014778326 39.37807881773399 0.0 +117.0 34.41231929604023 16.939032055311127 12.664990571967316 35.98365807668134 0.0 +118.0 32.85163776493256 19.20359666024406 12.363519588953114 35.51701991008349 0.06422607578676942 +119.0 33.20171108917407 17.17670286278381 13.063507732806844 36.55807831523528 0.0 +120.0 32.38126868150116 18.631683825971436 13.218199933576885 35.76884755895052 0.0 +121.0 31.67405386975793 18.888510057961135 13.092396863279918 36.34503920900102 0.0 +122.0 30.838844413505047 18.58684302123216 12.39122868082144 38.18308388444135 0.0 +123.0 30.45793397231097 19.772807951721692 12.70855520056798 37.06070287539936 0.0 +124.0 32.27686703096539 18.907103825136613 13.07832422586521 35.73770491803279 0.0 +125.0 31.773952095808383 18.07634730538922 13.09880239520958 37.050898203592816 0.0 +126.0 35.673407096528045 16.673025562762305 11.942006867607784 35.673407096528045 0.03815337657382679 +127.0 31.75596402033633 17.52053187328901 14.704732107938993 35.97966366836136 0.03910833007430582 +128.0 32.61217948717949 17.067307692307693 12.660256410256409 37.66025641025641 0.0 +129.0 33.77049180327869 17.745901639344265 11.721311475409836 36.721311475409834 0.040983606557377046 +130.0 34.36440677966102 17.71186440677966 12.711864406779661 35.21186440677966 0.0 +131.0 34.008810572687224 18.590308370044053 12.334801762114537 35.06607929515418 0.0 +132.0 34.27927927927928 15.855855855855856 13.558558558558559 36.306306306306304 0.0 +133.0 30.691708657810352 19.606046724690792 14.567109482363719 35.13513513513514 0.0 +134.0 32.114392873886544 18.612283169245195 13.783403656821378 35.48992030004688 0.0 +135.0 30.90294543698696 18.976339932399807 12.988894253983583 37.13182037662965 0.0 +136.0 30.432620586772753 17.951268025857782 14.172053704624565 37.444057682744905 0.0 +137.0 30.824372759856633 20.225294418842807 13.312852022529443 35.63748079877112 0.0 +138.0 32.608695652173914 18.928950159066808 12.672322375397668 35.79003181336161 0.0 +139.0 33.69923161361142 18.551042810098792 11.85510428100988 35.89462129527991 0.0 +140.0 31.916099773242628 16.383219954648524 13.662131519274375 38.038548752834465 0.0 +141.0 32.42117787031529 16.299821534800714 13.741820345032718 37.537180249851275 0.0 +142.0 32.32944068838353 16.59496004917025 10.633066994468347 40.073755377996314 0.36877688998156116 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_across_reference.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_across_reference.txt new file mode 100644 index 00000000..502c4d0c --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_across_reference.txt @@ -0,0 +1,398 @@ +#Position (bp) mapping quality +51.0 0.0 +152.0 0.0 +253.0 0.0 +354.0 0.0 +455.0 0.0 +556.0 0.0 +657.0 0.0 +758.0 0.0 +859.0 0.0 +960.0 0.0 +1061.0 0.0 +1162.0 0.0 +1263.0 0.0 +1364.0 0.0 +1465.0 0.0 +1566.0 0.0 +1667.0 0.0 +1768.0 0.0 +1869.0 0.0 +1970.0 59.99982589013668 +2071.0 59.98494448073155 +2172.0 60.0 +2273.0 0.0 +2374.0 0.0 +2475.0 0.0 +2576.0 0.0 +2677.0 60.0 +2778.0 60.0 +2879.0 60.0 +2980.0 59.99787561739869 +3081.0 60.0 +3182.0 60.0 +3283.0 59.94708994708995 +3384.0 59.94719637918028 +3485.0 59.992187003747695 +3586.0 59.99595381827932 +3687.0 60.0 +3788.0 0.0 +3889.0 0.0 +3990.0 0.0 +4091.0 0.0 +4192.0 0.0 +4293.0 0.0 +4394.0 0.0 +4495.0 60.0 +4596.0 60.0 +4697.0 0.0 +4798.0 0.0 +4899.0 0.0 +5000.0 0.0 +5101.0 0.0 +5202.0 0.0 +5303.0 0.0 +5404.0 0.0 +5505.0 0.0 +5606.0 0.0 +5707.0 0.0 +5808.0 0.0 +5909.0 0.0 +6010.0 0.0 +6111.0 0.0 +6212.0 0.0 +6313.0 0.0 +6414.0 0.0 +6515.0 0.0 +6616.0 0.0 +6717.0 0.0 +6818.0 0.0 +6919.0 0.0 +7020.0 0.0 +7121.0 0.0 +7222.0 0.0 +7323.0 0.0 +7424.0 0.0 +7525.0 0.0 +7626.0 0.0 +7727.0 0.0 +7828.0 0.0 +7929.0 0.0 +8030.0 0.0 +8131.0 0.0 +8232.0 0.0 +8333.0 0.0 +8434.0 0.0 +8535.0 0.0 +8636.0 0.0 +8737.0 0.0 +8838.0 0.0 +8939.0 0.0 +9040.0 0.0 +9141.0 0.0 +9242.0 0.0 +9343.0 0.0 +9444.0 0.0 +9545.0 0.0 +9646.0 0.0 +9747.0 0.0 +9848.0 0.0 +9949.0 0.0 +10050.0 0.0 +10151.0 0.0 +10252.0 0.0 +10353.0 0.0 +10454.0 0.0 +10555.0 0.0 +10656.0 0.0 +10757.0 0.0 +10858.0 0.0 +10959.0 0.0 +11060.0 0.0 +11161.0 0.0 +11262.0 0.0 +11363.0 0.0 +11464.0 0.0 +11565.0 0.0 +11666.0 0.0 +11767.0 0.0 +11868.0 0.0 +11969.0 0.0 +12070.0 0.0 +12171.0 0.0 +12272.0 0.0 +12373.0 0.0 +12474.0 0.0 +12575.0 0.0 +12676.0 0.0 +12777.0 0.0 +12878.0 0.0 +12979.0 0.0 +13080.0 0.0 +13181.0 0.0 +13282.0 0.0 +13383.0 0.0 +13484.0 0.0 +13585.0 0.0 +13686.0 0.0 +13787.0 0.0 +13888.0 0.0 +13989.0 0.0 +14090.0 0.0 +14191.0 0.0 +14292.0 0.0 +14393.0 0.0 +14494.0 0.0 +14595.0 0.0 +14696.0 0.0 +14797.0 0.0 +14898.0 0.0 +14999.0 0.0 +15100.0 0.0 +15201.0 0.0 +15302.0 0.0 +15403.0 0.0 +15504.0 0.0 +15605.0 0.0 +15706.0 0.0 +15807.0 0.0 +15908.0 0.0 +16009.0 0.0 +16110.0 0.0 +16211.0 0.0 +16312.0 0.0 +16413.0 0.0 +16514.0 0.0 +16615.0 0.0 +16716.0 0.0 +16817.0 0.0 +16918.0 0.0 +17019.0 0.0 +17120.0 0.0 +17221.0 0.0 +17322.0 0.0 +17423.0 0.0 +17524.0 0.0 +17625.0 0.0 +17726.0 0.0 +17827.0 0.0 +17928.0 0.0 +18029.0 0.0 +18130.0 0.0 +18231.0 0.0 +18332.0 0.0 +18433.0 0.0 +18534.0 0.0 +18635.0 0.0 +18736.0 0.0 +18837.0 0.0 +18938.0 0.0 +19039.0 0.0 +19140.0 0.0 +19241.0 0.0 +19342.0 0.0 +19443.0 0.0 +19544.0 0.0 +19645.0 0.0 +19746.0 0.0 +19847.0 0.0 +19948.0 0.0 +20049.0 0.0 +20150.0 0.0 +20251.0 0.0 +20352.0 0.0 +20453.0 0.0 +20554.0 0.0 +20655.0 0.0 +20756.0 0.0 +20857.0 0.0 +20958.0 0.0 +21059.0 0.0 +21160.0 0.0 +21261.0 0.0 +21362.0 0.0 +21463.0 0.0 +21564.0 0.0 +21665.0 0.0 +21766.0 0.0 +21867.0 0.0 +21968.0 0.0 +22069.0 0.0 +22170.0 0.0 +22271.0 0.0 +22372.0 0.0 +22473.0 0.0 +22574.0 0.0 +22675.0 0.0 +22776.0 0.0 +22877.0 0.0 +22978.0 0.0 +23079.0 0.0 +23180.0 0.0 +23281.0 0.0 +23382.0 0.0 +23483.0 0.0 +23584.0 0.0 +23685.0 0.0 +23786.0 0.0 +23887.0 0.0 +23988.0 0.0 +24089.0 0.0 +24190.0 0.0 +24291.0 0.0 +24392.0 0.0 +24493.0 0.0 +24594.0 0.0 +24695.0 0.0 +24796.0 0.0 +24897.0 0.0 +24998.0 0.0 +25099.0 0.0 +25200.0 0.0 +25301.0 0.0 +25402.0 0.0 +25503.0 0.0 +25604.0 0.0 +25705.0 0.0 +25806.0 0.0 +25907.0 0.0 +26008.0 0.0 +26109.0 0.0 +26210.0 0.0 +26311.0 0.0 +26412.0 0.0 +26513.0 0.0 +26614.0 0.0 +26715.0 0.0 +26816.0 0.0 +26917.0 0.0 +27018.0 0.0 +27119.0 0.0 +27220.0 0.0 +27321.0 0.0 +27422.0 0.0 +27523.0 0.0 +27624.0 0.0 +27725.0 0.0 +27826.0 0.0 +27927.0 0.0 +28028.0 0.0 +28129.0 0.0 +28230.0 0.0 +28331.0 0.0 +28432.0 0.0 +28533.0 0.0 +28634.0 0.0 +28735.0 0.0 +28836.0 0.0 +28937.0 0.0 +29038.0 0.0 +29139.0 0.0 +29240.0 0.0 +29341.0 0.0 +29442.0 0.0 +29543.0 0.0 +29644.0 0.0 +29745.0 0.0 +29846.0 0.0 +29947.0 0.0 +30048.0 0.0 +30149.0 0.0 +30250.0 0.0 +30351.0 0.0 +30452.0 0.0 +30553.0 0.0 +30654.0 0.0 +30755.0 0.0 +30856.0 0.0 +30957.0 0.0 +31058.0 0.0 +31159.0 0.0 +31260.0 0.0 +31361.0 0.0 +31462.0 0.0 +31563.0 0.0 +31664.0 0.0 +31765.0 0.0 +31866.0 0.0 +31967.0 0.0 +32068.0 0.0 +32169.0 0.0 +32270.0 0.0 +32371.0 0.0 +32472.0 0.0 +32573.0 0.0 +32674.0 0.0 +32775.0 0.0 +32876.0 0.0 +32977.0 0.0 +33078.0 0.0 +33179.0 0.0 +33280.0 0.0 +33381.0 0.0 +33482.0 0.0 +33583.0 0.0 +33684.0 0.0 +33785.0 0.0 +33886.0 0.0 +33987.0 0.0 +34088.0 0.0 +34189.0 0.0 +34290.0 0.0 +34391.0 0.0 +34492.0 0.0 +34593.0 0.0 +34694.0 0.0 +34795.0 0.0 +34896.0 0.0 +34997.0 0.0 +35098.0 0.0 +35199.0 0.0 +35300.0 0.0 +35401.0 0.0 +35502.0 0.0 +35603.0 0.0 +35704.0 0.0 +35805.0 0.0 +35906.0 0.0 +36007.0 0.0 +36108.0 0.0 +36209.0 0.0 +36310.0 0.0 +36411.0 0.0 +36512.0 0.0 +36613.0 0.0 +36714.0 0.0 +36815.0 0.0 +36916.0 0.0 +37017.0 0.0 +37118.0 0.0 +37219.0 0.0 +37320.0 0.0 +37421.0 0.0 +37522.0 0.0 +37623.0 0.0 +37724.0 0.0 +37825.0 0.0 +37926.0 0.0 +38027.0 0.0 +38128.0 0.0 +38229.0 0.0 +38330.0 0.0 +38431.0 0.0 +38532.0 0.0 +38633.0 0.0 +38734.0 0.0 +38835.0 0.0 +38936.0 0.0 +39037.0 0.0 +39138.0 0.0 +39239.0 0.0 +39340.0 0.0 +39441.0 0.0 +39542.0 0.0 +39643.0 0.0 +39744.0 0.0 +39845.0 0.0 +39946.0 0.0 +39999.0 0.0 diff --git a/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_histogram.txt b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_histogram.txt new file mode 100644 index 00000000..30307309 --- /dev/null +++ b/tests/expected/dna/qualimap/raw_data_qualimapReport/mapping_quality_histogram.txt @@ -0,0 +1,3 @@ +#Mapping quality mapping quality +59.0 248.0 +60.0 933.0 From 279ec7bb701fc82f28c713a74dbcdbb0de1684c8 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Sat, 29 Aug 2026 18:47:11 +0200 Subject: [PATCH 22/22] feat(coverage): write bigWig tracks from the alignment pass Closes #112. nf-core/rnaseq currently runs bedtools genomecov and then UCSC bedGraphToBigWig per strand, three file round-trips for data the alignment pass already has. This computes the coverage as another accumulator in that pass and writes the bigWig directly. Per-base coverage matches bedtools genomecov -bg -split exactly: all 738 intervals on the project fixture. The written track is then read back and checked against the same reference base by base. Its semantics deliberately differ from every other depth engine in the crate, which is why it has its own accumulator rather than reusing one. bedtools filters nothing: duplicates, secondary alignments and both mates of an overlapping pair all contribute. mosdepth excludes the first two and corrects the third; CollectWgsMetrics goes further still. And -split means an N in the CIGAR breaks a read into separate intervals rather than covering the intron, as does a deletion. Two details of the format are worth knowing. A gap between intervals is not zero coverage in a bigWig, it is undefined, and reads back as NaN; that matches what bedGraphToBigWig produces from a bedGraph that omits its zero-depth spans. And the format cannot express a track with no data at all, so a strand with no reads writes no file and says so rather than failing the run, which the fixture exercises: it has no reverse-strand reads. Positions are contig-local, so the accumulators are kept keyed by contig rather than summed across the per-chromosome workers. The growable variant exists because a worker does not know its contig length when the accumulators are built; that it is a field rather than something inferred from the array matters, since after its first read a growable accumulator is otherwise indistinguishable from a fixed one and would silently stop growing. bigtools sits behind a `bigwig` cargo feature, on by default, 19 transitive packages. Both configurations build, lint and test clean, and the MSRV holds. The tracks themselves are off by default: a bigWig is large next to the other outputs and not every run wants one. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 + CHANGELOG.md | 5 + Cargo.lock | 305 ++++++++++- Cargo.toml | 11 + src/common/coverage/bedgraph.rs | 451 ++++++++++++++++ src/common/coverage/bigwig.rs | 154 ++++++ src/common/coverage/mod.rs | 11 + src/common/mod.rs | 1 + src/config.rs | 38 ++ src/main.rs | 78 +++ src/rna/rseqc/accumulators.rs | 13 + tests/expected/coverage/test.bedgraph | 738 ++++++++++++++++++++++++++ tests/integration_test.rs | 155 ++++++ 13 files changed, 1941 insertions(+), 23 deletions(-) create mode 100644 src/common/coverage/bedgraph.rs create mode 100644 src/common/coverage/bigwig.rs create mode 100644 src/common/coverage/mod.rs create mode 100644 tests/expected/coverage/test.bedgraph diff --git a/AGENTS.md b/AGENTS.md index 3856f70d..2d487396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,10 @@ src/ bam_flags.rs — BAM flag constants and aux-tag helpers bam_stat.rs — bam_stat.py reimplementation, result types bam_stat_accum.rs — Read-level counter accumulator feeding bam_stat and samtools + coverage/ + mod.rs — Coverage tracks + bedgraph.rs — bedtools genomecov semantics, per-contig accumulators + bigwig.rs — bigWig writing via bigtools (behind the `bigwig` feature) cpp_rng.rs — C++ RNG FFI shim for preseq bootstrap reproducibility preseq.rs — preseq lc_extrap library complexity extrapolation samtools/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6566d048..5c77dc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ `genome_results.txt`, the raw data tables and an HTML summary. Validated against mosdepth 0.3.14, samtools 1.24, Picard 3.4.0 and Qualimap 2.3. +- New bigWig coverage tracks for `rustqc rna`, computed in the existing single + pass and replacing the `bedtools genomecov` into `bedGraphToBigWig` + round-trip (#112). Off by default; enable with `coverage_tracks.enabled` and + optionally `stranded`. Behind the `bigwig` cargo feature. + ### Changed - Internal: assay-agnostic analyses (BAM flag helpers, read-level statistics, diff --git a/Cargo.lock b/Cargo.lock index da20215e..f18e3d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,38 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bigtools" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1b9bbf6596d602e472a23ed5aa5d611fb04f14e7772226fb61c720e806202e" +dependencies = [ + "bincode", + "byteorder", + "byteordered", + "bytes", + "crossbeam-channel", + "crossbeam-utils", + "futures", + "index_list", + "itertools 0.10.5", + "libdeflater", + "serde", + "smallvec", + "tempfile", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -97,7 +129,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -105,7 +137,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -151,6 +183,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteordered" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf2cd9424f5ff404aba1959c835cbc448ee8b689b870a9981c76c0fd46280e6" +dependencies = [ + "byteorder", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -254,7 +301,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -373,6 +420,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -427,7 +483,7 @@ checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -438,7 +494,7 @@ checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -470,7 +526,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -535,6 +591,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -615,7 +687,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -653,6 +725,94 @@ dependencies = [ "quick-error", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -896,6 +1056,12 @@ dependencies = [ "png", ] +[[package]] +name = "index_list" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30141a73bc8a129ac1ce472e33f45af3e2091d86b3479061b9c2f92fdbe9a28c" + [[package]] name = "indexmap" version = "2.13.0" @@ -927,6 +1093,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -963,7 +1138,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1016,6 +1191,24 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libdeflate-sys" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6949d73714ba8c32d2757405b89c94e427f64f078a4041debe07e1b8e9e850e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libdeflater" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b72b274104f747cb65358c918e38c6cd4a69937937a36c8ad3cfd516c9c471e" +dependencies = [ + "libdeflate-sys", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1054,6 +1247,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1198,6 +1397,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1303,7 +1508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1475,11 +1680,25 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustqc" version = "0.2.1" dependencies = [ "anyhow", + "bigtools", "cc", "clap", "coitrees", @@ -1502,6 +1721,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "tokio", ] [[package]] @@ -1564,7 +1784,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1605,6 +1825,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1633,7 +1859,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -1647,6 +1873,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1655,7 +1892,20 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -1684,7 +1934,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1695,7 +1945,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1708,6 +1958,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "ttf-parser" version = "0.20.0" @@ -1834,7 +2093,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1959,7 +2218,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1970,7 +2229,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2118,7 +2377,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2134,7 +2393,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2212,7 +2471,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2233,7 +2492,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2253,7 +2512,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2287,7 +2546,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ebf02f0e..4423d2fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,18 @@ path = "src/lib.rs" name = "rustqc" path = "src/main.rs" +[features] +default = ["bigwig"] +# bigWig coverage tracks. Enabled by default; disable with +# `--no-default-features` to drop bigtools and its transitive packages when +# only the text outputs are wanted. +bigwig = ["dep:bigtools", "dep:tokio"] + [dependencies] +# bigWig writing for coverage tracks (optional, see the `bigwig` feature) +bigtools = { version = "0.5", default-features = false, features = ["write", "read"], optional = true } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread"], optional = true } + # CLI argument parsing clap = { version = "4", features = ["derive", "env"] } diff --git a/src/common/coverage/bedgraph.rs b/src/common/coverage/bedgraph.rs new file mode 100644 index 00000000..8bc79392 --- /dev/null +++ b/src/common/coverage/bedgraph.rs @@ -0,0 +1,451 @@ +//! Per-base coverage in `bedtools genomecov` semantics. +//! +//! # Upstream semantics +//! +//! Matches `bedtools genomecov -ibam -bg -split`. Two things about it are +//! worth stating, because neither matches the depth engines already in this +//! crate: +//! +//! `-split` counts only the reference blocks a read actually aligns to, so an +//! `N` in the CIGAR breaks the read into separate intervals rather than +//! covering the intron. Deletions break it too. +//! +//! There is no filtering. Duplicates, secondary alignments and low mapping +//! quality all contribute, and overlapping mates of a pair each count. That is +//! the opposite of what mosdepth does by default and of what +//! `CollectWgsMetrics` does, and it is why this has its own accumulator rather +//! than reusing either. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use rust_htslib::bam; +use rust_htslib::bam::record::Cigar; + +use crate::common::bam_flags::*; + +/// One bedGraph interval: a half-open span at a constant depth. +#[derive(Debug, Clone, PartialEq)] +pub struct Interval { + /// Contig name. + pub chrom: String, + /// Zero-based start. + pub start: u32, + /// Half-open end. + pub end: u32, + /// Depth over the span, after any scaling. + pub value: f32, +} + +/// Accumulates per-base coverage for one contig. +#[derive(Debug)] +pub struct CoverageAccum { + /// Delta array of length `contig_len + 1`. + deltas: Vec, + len: usize, + /// Only reads on this strand contribute; `None` counts every read. + strand: Option, + /// Whether the array grows to fit the reads rather than being clamped to a + /// known contig length. This is a field rather than something inferred + /// from the array, because after its first read a growable accumulator is + /// indistinguishable from a fixed one of that size and would silently stop + /// growing. + growable: bool, +} + +impl CoverageAccum { + /// Allocate for one contig of known length. + /// + /// `strand` restricts the reads counted, which is how the forward and + /// reverse tracks are produced. It follows `bedtools genomecov -strand`: + /// the read's own strand, not the fragment's. + pub fn new(length: u64, strand: Option) -> Self { + let len = length as usize; + Self { + deltas: vec![0i32; len + 1], + len, + strand, + growable: false, + } + } + + /// Allocate without knowing the contig length, growing as reads arrive. + /// + /// This is what lets the accumulator sit in the single alignment pass + /// alongside the others, which are constructed before any contig is known. + /// The length is only needed when the track is written, and it comes from + /// the alignment header there. + pub fn growable(strand: Option) -> Self { + Self { + deltas: Vec::new(), + len: 0, + strand, + growable: true, + } + } + + /// Offer one record. + pub fn process_read(&mut self, record: &bam::Record) { + if record.flags() & BAM_FUNMAP != 0 { + return; + } + if let Some(wanted) = self.strand { + let reverse = record.flags() & BAM_FREVERSE != 0; + let actual = if reverse { '-' } else { '+' }; + if actual != wanted { + return; + } + } + + let mut position = record.pos(); + for op in record.cigar().iter() { + match op { + Cigar::Match(n) | Cigar::Equal(n) | Cigar::Diff(n) => { + let n = i64::from(*n); + self.add(position, position + n); + position += n; + } + // Both break the read into separate intervals under `-split`. + Cigar::Del(n) | Cigar::RefSkip(n) => position += i64::from(*n), + Cigar::Ins(_) | Cigar::SoftClip(_) | Cigar::HardClip(_) | Cigar::Pad(_) => {} + } + } + } + + /// Record a half-open block, clamped to the contig when its length is + /// known and growing the array when it is not. + fn add(&mut self, start: i64, end: i64) { + let start = start.max(0) as usize; + let mut end = end.max(0) as usize; + if self.growable { + if end + 1 > self.deltas.len() { + self.deltas.resize(end + 1, 0); + self.len = end; + } + } else { + end = end.min(self.len); + } + if start >= end { + return; + } + self.deltas[start] += 1; + self.deltas[end] -= 1; + } + + /// Fold another accumulator for the same contig in. + pub fn merge(&mut self, other: CoverageAccum) { + if other.deltas.len() > self.deltas.len() { + self.deltas.resize(other.deltas.len(), 0); + self.len = other.len.max(self.len); + } + for (index, delta) in other.deltas.iter().enumerate() { + self.deltas[index] += delta; + } + } + + /// Collapse into bedGraph intervals, dropping the zero-depth spans that + /// `bedtools genomecov -bg` omits. + /// + /// `scale` multiplies every depth, which is how normalised tracks are + /// produced; a scale of 1.0 leaves the raw counts. + pub fn into_intervals(self, chrom: &str, scale: f32) -> Vec { + let mut intervals = Vec::new(); + let mut running = 0i32; + let mut run_start = 0usize; + let mut run_depth = 0i32; + + for position in 0..self.len { + running += self.deltas[position]; + if position == 0 { + run_depth = running; + run_start = 0; + continue; + } + if running != run_depth { + if run_depth > 0 { + intervals.push(Interval { + chrom: chrom.to_string(), + start: run_start as u32, + end: position as u32, + value: run_depth as f32 * scale, + }); + } + run_depth = running; + run_start = position; + } + } + if run_depth > 0 && run_start < self.len { + intervals.push(Interval { + chrom: chrom.to_string(), + start: run_start as u32, + end: self.len as u32, + value: run_depth as f32 * scale, + }); + } + intervals + } +} + +/// Write intervals as a bedGraph. +pub fn write_bedgraph(intervals: &[Interval], path: &Path) -> Result<()> { + let mut out = std::fs::File::create(path) + .map(std::io::BufWriter::new) + .with_context(|| format!("Failed to create bedGraph: {}", path.display()))?; + for interval in intervals { + // Whole numbers print without a fractional part, as bedtools does for + // unscaled counts; a scaled track keeps its decimals. + if interval.value.fract() == 0.0 { + writeln!( + out, + "{}\t{}\t{}\t{}", + interval.chrom, interval.start, interval.end, interval.value as i64 + )?; + } else { + writeln!( + out, + "{}\t{}\t{}\t{}", + interval.chrom, interval.start, interval.end, interval.value + )?; + } + } + out.flush()?; + Ok(()) +} + +/// Coverage tracks across every contig, one accumulator per strand. +/// +/// Positions are contig-local, so accumulators from different contigs must +/// never be summed. This keeps them keyed by contig, which is what makes the +/// per-chromosome workers safe to merge. +#[derive(Debug, Default)] +pub struct CoverageTracks { + /// Strands to track, in output order. A single `None` means one combined + /// track counting every read. + strands: Vec>, + /// Per contig, one accumulator per entry in `strands`. + per_chrom: std::collections::HashMap>, +} + +impl CoverageTracks { + /// Track the given strands. An empty list disables the tracks entirely. + pub fn new(strands: Vec>) -> Self { + Self { + strands, + per_chrom: std::collections::HashMap::new(), + } + } + + /// Whether anything is being tracked. + pub fn is_enabled(&self) -> bool { + !self.strands.is_empty() + } + + /// Offer one record, which must belong to `chrom`. + pub fn process_read(&mut self, record: &bam::Record, chrom: &str) { + if self.strands.is_empty() { + return; + } + let strands = &self.strands; + let accums = self.per_chrom.entry(chrom.to_string()).or_insert_with(|| { + strands + .iter() + .map(|strand| CoverageAccum::growable(*strand)) + .collect() + }); + for accum in accums.iter_mut() { + accum.process_read(record); + } + } + + /// Fold another set in, contig by contig. + pub fn merge(&mut self, other: CoverageTracks) { + for (chrom, theirs) in other.per_chrom { + match self.per_chrom.get_mut(&chrom) { + Some(mine) => { + for (mine, theirs) in mine.iter_mut().zip(theirs) { + mine.merge(theirs); + } + } + None => { + self.per_chrom.insert(chrom, theirs); + } + } + } + } + + /// Collapse into one interval list per tracked strand, contigs in the + /// order given, which is the alignment header's order. + pub fn into_intervals( + mut self, + chrom_order: &[String], + scale: f32, + ) -> Vec<(Option, Vec)> { + let mut out: Vec<(Option, Vec)> = + self.strands.iter().map(|s| (*s, Vec::new())).collect(); + for chrom in chrom_order { + if let Some(accums) = self.per_chrom.remove(chrom) { + for (index, accum) in accums.into_iter().enumerate() { + out[index].1.extend(accum.into_intervals(chrom, scale)); + } + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_htslib::bam::record::{CigarString, Record}; + + fn record(pos: i64, cigar: Vec, flags: u16) -> Record { + let query: usize = cigar + .iter() + .map(|op| match op { + Cigar::Match(n) | Cigar::Ins(n) | Cigar::SoftClip(n) => *n as usize, + Cigar::Equal(n) | Cigar::Diff(n) => *n as usize, + _ => 0, + }) + .sum(); + let mut r = Record::new(); + r.set( + b"q", + Some(&CigarString(cigar)), + &vec![b'A'; query], + &vec![30u8; query], + ); + r.set_pos(pos); + r.set_flags(flags); + r + } + + #[test] + fn a_spliced_read_does_not_cover_its_intron() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record( + 0, + vec![Cigar::Match(3), Cigar::RefSkip(5), Cigar::Match(3)], + 0, + )); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!( + intervals, + vec![ + Interval { + chrom: "chr1".into(), + start: 0, + end: 3, + value: 1.0 + }, + Interval { + chrom: "chr1".into(), + start: 8, + end: 11, + value: 1.0 + }, + ], + "the intron is absent rather than at depth zero" + ); + } + + #[test] + fn a_deletion_also_splits_the_interval() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record( + 0, + vec![Cigar::Match(2), Cigar::Del(2), Cigar::Match(2)], + 0, + )); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!(intervals.len(), 2); + assert_eq!((intervals[0].start, intervals[0].end), (0, 2)); + assert_eq!((intervals[1].start, intervals[1].end), (4, 6)); + } + + #[test] + fn runs_of_equal_depth_collapse_into_one_interval() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record(0, vec![Cigar::Match(10)], 0)); + accum.process_read(&record(0, vec![Cigar::Match(10)], 0)); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!(intervals.len(), 1, "one span at depth 2"); + assert_eq!(intervals[0].value, 2.0); + } + + #[test] + fn zero_depth_spans_are_omitted() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record(5, vec![Cigar::Match(3)], 0)); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!(intervals.len(), 1); + assert_eq!((intervals[0].start, intervals[0].end), (5, 8)); + } + + #[test] + fn nothing_is_filtered_out() { + // Duplicates and secondary alignments contribute, unlike every other + // depth engine in this crate. + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record(0, vec![Cigar::Match(4)], BAM_FDUP)); + accum.process_read(&record(0, vec![Cigar::Match(4)], BAM_FSECONDARY)); + assert_eq!(accum.into_intervals("chr1", 1.0)[0].value, 2.0); + } + + #[test] + fn an_unmapped_read_contributes_nothing() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record(0, vec![Cigar::Match(4)], BAM_FUNMAP)); + assert!(accum.into_intervals("chr1", 1.0).is_empty()); + } + + #[test] + fn the_strand_filter_follows_the_reads_own_strand() { + let mut forward = CoverageAccum::new(20, Some('+')); + forward.process_read(&record(0, vec![Cigar::Match(4)], 0)); + forward.process_read(&record(0, vec![Cigar::Match(4)], BAM_FREVERSE)); + assert_eq!(forward.into_intervals("chr1", 1.0)[0].value, 1.0); + + let mut reverse = CoverageAccum::new(20, Some('-')); + reverse.process_read(&record(0, vec![Cigar::Match(4)], 0)); + reverse.process_read(&record(0, vec![Cigar::Match(4)], BAM_FREVERSE)); + assert_eq!(reverse.into_intervals("chr1", 1.0)[0].value, 1.0); + } + + #[test] + fn scaling_multiplies_every_depth() { + let mut accum = CoverageAccum::new(20, None); + accum.process_read(&record(0, vec![Cigar::Match(4)], 0)); + assert_eq!(accum.into_intervals("chr1", 2.5)[0].value, 2.5); + } + + #[test] + fn a_growable_accumulator_needs_no_length_up_front() { + let mut accum = CoverageAccum::growable(None); + accum.process_read(&record(100, vec![Cigar::Match(5)], 0)); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!(intervals.len(), 1); + assert_eq!((intervals[0].start, intervals[0].end), (100, 105)); + } + + #[test] + fn merging_adds_two_workers_coverage() { + let mut a = CoverageAccum::growable(None); + a.process_read(&record(0, vec![Cigar::Match(4)], 0)); + let mut b = CoverageAccum::growable(None); + b.process_read(&record(0, vec![Cigar::Match(4)], 0)); + b.process_read(&record(10, vec![Cigar::Match(2)], 0)); + a.merge(b); + let intervals = a.into_intervals("chr1", 1.0); + assert_eq!(intervals[0].value, 2.0, "the shared span doubles"); + assert_eq!((intervals[1].start, intervals[1].end), (10, 12)); + } + + #[test] + fn a_read_running_past_the_contig_end_is_clipped() { + let mut accum = CoverageAccum::new(6, None); + accum.process_read(&record(4, vec![Cigar::Match(10)], 0)); + let intervals = accum.into_intervals("chr1", 1.0); + assert_eq!((intervals[0].start, intervals[0].end), (4, 6)); + } +} diff --git a/src/common/coverage/bigwig.rs b/src/common/coverage/bigwig.rs new file mode 100644 index 00000000..c2014bc3 --- /dev/null +++ b/src/common/coverage/bigwig.rs @@ -0,0 +1,154 @@ +//! bigWig writing for coverage tracks. +//! +//! Replaces the `bedtools genomecov` into `bedGraphToBigWig` round-trip with a +//! single write from the coverage already computed in the alignment pass. +//! +//! Behind the `bigwig` cargo feature, so a build that only wants the text +//! outputs need not carry `bigtools`. + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use bigtools::beddata::BedParserStreamingIterator; +use bigtools::{BigWigWrite, Value}; + +use super::bedgraph::Interval; + +/// Write intervals as a bigWig, returning whether a file was produced. +/// +/// `chrom_sizes` must name every contig the intervals refer to, and comes from +/// the alignment header. Intervals are expected in the order the contigs +/// appear there. +/// +/// An empty interval list writes nothing and returns `false`. The format has +/// no representation for a track with no data, and `bigtools` rejects it +/// outright, so the alternative would be failing a run because an alignment +/// covered nothing. +/// +/// Note that a gap between intervals is not zero coverage in a bigWig: it is +/// *undefined*, and reads back as `NaN`. That is the format's own semantics +/// and matches what `bedGraphToBigWig` produces from a bedGraph that omits its +/// zero-depth spans, which is what `bedtools genomecov -bg` emits. +pub fn write_bigwig( + intervals: &[Interval], + chrom_sizes: &[(String, u64)], + path: &Path, +) -> Result { + if intervals.is_empty() { + return Ok(false); + } + let sizes: HashMap = chrom_sizes + .iter() + .map(|(name, length)| (name.clone(), *length as u32)) + .collect(); + + let values: Vec<(String, Value)> = intervals + .iter() + .map(|interval| { + ( + interval.chrom.clone(), + Value { + start: interval.start, + end: interval.end, + value: interval.value, + }, + ) + }) + .collect(); + + // `false` because the intervals are already grouped by contig in header + // order; letting the writer accept out-of-order chromosomes would hide a + // bug in the caller rather than catching it. + let data = BedParserStreamingIterator::wrap_infallible_iter(values.into_iter(), false); + + let writer = BigWigWrite::create_file(path, sizes) + .with_context(|| format!("Failed to create bigWig: {}", path.display()))?; + + // One worker: the encoding is not the bottleneck next to reading the + // alignment, and a fixed thread count keeps the output deterministic. + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .build() + .context("Failed to start the bigWig writer runtime")?; + + writer + .write(data, runtime) + .map_err(|e| anyhow!("Failed to write bigWig {}: {e}", path.display()))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("rustqc-bigwig-tests"); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn a_written_track_reads_back_with_the_same_values() { + use bigtools::BigWigRead; + + let intervals = vec![ + Interval { + chrom: "chr1".into(), + start: 10, + end: 20, + value: 3.0, + }, + Interval { + chrom: "chr1".into(), + start: 30, + end: 35, + value: 7.5, + }, + ]; + let path = scratch("roundtrip.bw"); + assert!(write_bigwig(&intervals, &[("chr1".to_string(), 100)], &path).unwrap()); + + let mut reader = BigWigRead::open_file(&path).unwrap(); + let values = reader.values("chr1", 0, 100).unwrap(); + assert_eq!(values[15], 3.0, "inside the first interval"); + assert_eq!(values[32], 7.5, "inside the second"); + } + + #[test] + fn a_gap_is_undefined_rather_than_zero() { + use bigtools::BigWigRead; + + let intervals = vec![Interval { + chrom: "chr1".into(), + start: 10, + end: 20, + value: 3.0, + }]; + let path = scratch("gap.bw"); + write_bigwig(&intervals, &[("chr1".to_string(), 100)], &path).unwrap(); + + let mut reader = BigWigRead::open_file(&path).unwrap(); + let values = reader.values("chr1", 0, 100).unwrap(); + assert!( + values[50].is_nan(), + "a bigWig has no representation for zero coverage; the gap is \ + undefined and reads back as NaN, got {}", + values[50] + ); + } + + #[test] + fn nothing_to_write_produces_no_file_rather_than_failing() { + let path = scratch("empty.bw"); + let _ = std::fs::remove_file(&path); + assert!( + !write_bigwig(&[], &[("chr1".to_string(), 100)], &path).unwrap(), + "an empty track reports that nothing was written" + ); + assert!( + !path.exists(), + "the format cannot express an empty track, so none is created" + ); + } +} diff --git a/src/common/coverage/mod.rs b/src/common/coverage/mod.rs new file mode 100644 index 00000000..131a905a --- /dev/null +++ b/src/common/coverage/mod.rs @@ -0,0 +1,11 @@ +//! Coverage tracks. +//! +//! Produces the per-base coverage that pipelines currently get by running +//! `bedtools genomecov` and then converting its bedGraph to bigWig. Both come +//! out of the alignment pass that is already happening. + +pub mod bedgraph; + +/// bigWig writing, available when built with the `bigwig` feature. +#[cfg(feature = "bigwig")] +pub mod bigwig; diff --git a/src/common/mod.rs b/src/common/mod.rs index 31c3a7d3..77f5a0b0 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -8,6 +8,7 @@ pub mod bam_flags; pub mod bam_stat; pub mod bam_stat_accum; +pub mod coverage; pub mod cpp_rng; pub mod preseq; pub mod samtools; diff --git a/src/config.rs b/src/config.rs index 0045116d..25928bfe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,6 +162,10 @@ pub struct RnaConfig { #[serde(default)] pub samtools_stats: SamtoolsStatsConfig, + /// bigWig coverage track configuration. + #[serde(default)] + pub coverage_tracks: CoverageTracksConfig, + /// preseq lc_extrap library complexity extrapolation configuration. #[serde(default)] pub preseq: PreseqConfig, @@ -912,6 +916,40 @@ impl RnaConfig { } } +/// Configuration for the bigWig coverage tracks. +/// +/// Replaces the `bedtools genomecov` into `bedGraphToBigWig` round-trip that +/// pipelines otherwise run per strand. +/// +/// Example: +/// ```yaml +/// coverage_tracks: +/// enabled: true +/// stranded: true +/// scale: 1.0 +/// ``` +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct CoverageTracksConfig { + /// Whether to write coverage tracks. Off by default, since a bigWig is + /// large next to the other outputs and not every run wants one. + pub enabled: bool, + /// Write separate forward and reverse tracks rather than one combined. + pub stranded: bool, + /// Multiplies every depth, for normalised tracks. 1.0 leaves raw counts. + pub scale: f32, +} + +impl Default for CoverageTracksConfig { + fn default() -> Self { + Self { + enabled: false, + stranded: false, + scale: 1.0, + } + } +} + // =================================================================== // DNA QC configuration // =================================================================== diff --git a/src/main.rs b/src/main.rs index 75736e3e..68212308 100644 --- a/src/main.rs +++ b/src/main.rs @@ -902,6 +902,29 @@ fn depth_worker_budget(threads: usize, override_value: Option, largest: u threads.min(affordable).max(1) } +/// Write one coverage track, returning whether a file was produced. +/// +/// Split out so the `bigwig` feature gate lives in one place: without it the +/// tracks are simply not written, and the run says so rather than failing. +#[cfg(feature = "bigwig")] +fn write_coverage_track( + intervals: &[rustqc::common::coverage::bedgraph::Interval], + chrom_sizes: &[(String, u64)], + path: &Path, +) -> Result { + rustqc::common::coverage::bigwig::write_bigwig(intervals, chrom_sizes, path) +} + +/// Stub for builds without the `bigwig` feature. +#[cfg(not(feature = "bigwig"))] +fn write_coverage_track( + _intervals: &[rustqc::common::coverage::bedgraph::Interval], + _chrom_sizes: &[(String, u64)], + _path: &Path, +) -> Result { + Ok(false) +} + /// Reconstruct the command line for the featureCounts-compatible header comment. fn reconstruct_command_line(args: &cli::RnaArgs) -> String { let mut parts = vec![format!( @@ -1330,6 +1353,7 @@ fn run_rna(args: cli::RnaArgs, ui: &Ui) -> Result<()> { // Build the shared parameters struct for process_single_bam let shared = SharedParams { + coverage_scale: config.coverage_tracks.scale, ui, stranded: effective_stranded, paired: effective_paired, @@ -1615,6 +1639,8 @@ struct SharedParams<'a> { inner_distance_step: i64, /// Pre-built TIN index for transcript integrity analysis (from GTF). tin_index: Option<&'a rna::rseqc::tin::TinIndex>, + /// Multiplies every coverage depth, for normalised tracks. + coverage_scale: f32, /// Number of equally-spaced positions to sample per transcript for TIN. tin_sample_size: usize, /// Minimum read-start count per transcript to compute TIN. @@ -1763,6 +1789,15 @@ fn process_single_bam( // === Build RSeQC config and annotations === let rseqc_config = RseqcConfig { + coverage_strands: if config.coverage_tracks.enabled { + if config.coverage_tracks.stranded { + vec![Some('+'), Some('-')] + } else { + vec![None] + } + } else { + Vec::new() + }, mapq_cut: params.mapq_cut, infer_experiment_sample_size: params.infer_experiment_sample_size, min_intron: params.min_intron, @@ -2476,6 +2511,49 @@ fn write_rseqc_outputs( written.push(("read_distribution".into(), p)); } + // --- coverage tracks --- + if accums.coverage.is_enabled() { + let dir_path = if params.flat_output { + outdir.to_path_buf() + } else { + outdir.join("coverage") + }; + std::fs::create_dir_all(&dir_path)?; + let chrom_order: Vec = bam_header_refs + .iter() + .map(|(name, _)| name.clone()) + .collect(); + for (strand, intervals) in accums + .coverage + .into_intervals(&chrom_order, params.coverage_scale) + { + let suffix = match strand { + Some('+') => ".forward", + Some('-') => ".reverse", + _ => "", + }; + let output_path = dir_path.join(format!("{sample_name}{suffix}.bigWig")); + let wrote = write_coverage_track(&intervals, bam_header_refs, &output_path)?; + if wrote { + let p = output_path.display().to_string(); + ui.output_item("coverage", &p); + ui.output_detail(&format!( + "{} intervals", + format_count(intervals.len() as u64) + )); + written.push(("coverage".into(), p)); + } else { + ui.warn(&format!( + "no coverage on the {} strand, so no track was written", + match strand { + Some(s) => s.to_string(), + None => "combined".to_string(), + } + )); + } + } + } + // --- junction_annotation --- if let Some(accum) = accums.junc_annot { std::fs::create_dir_all(&rseqc_junc_annot_dir)?; diff --git a/src/rna/rseqc/accumulators.rs b/src/rna/rseqc/accumulators.rs index 20f2b964..22ff90a4 100644 --- a/src/rna/rseqc/accumulators.rs +++ b/src/rna/rseqc/accumulators.rs @@ -58,6 +58,9 @@ pub struct RseqcAnnotations<'a> { #[derive(Debug, Clone)] #[allow(dead_code)] pub struct RseqcConfig { + /// Strands to build coverage track accumulators for. Empty disables them; + /// a single `None` entry counts every read into one combined track. + pub coverage_strands: Vec>, /// MAPQ cutoff for read quality filtering. pub mapq_cut: u8, /// Maximum reads to sample for infer_experiment. @@ -928,6 +931,8 @@ pub struct RseqcAccumulators { pub tin: Option, /// preseq library complexity accumulator (`None` when disabled). pub preseq: Option, + /// Coverage tracks, kept per contig so workers merge safely. + pub coverage: crate::common::coverage::bedgraph::CoverageTracks, } impl RseqcAccumulators { @@ -943,6 +948,7 @@ impl RseqcAccumulators { inner_dist: None, tin: None, preseq: None, + coverage: crate::common::coverage::bedgraph::CoverageTracks::default(), } } @@ -1001,6 +1007,9 @@ impl RseqcAccumulators { } else { None }, + coverage: crate::common::coverage::bedgraph::CoverageTracks::new( + config.coverage_strands.clone(), + ), } } @@ -1032,6 +1041,9 @@ impl RseqcAccumulators { accum.process_read(record, chrom, model, config.mapq_cut); } + // Coverage tracks: no filtering at all, matching bedtools genomecov. + self.coverage.process_read(record, chrom); + // read_distribution: needs region sets, uses uppercased chrom if let (Some(ref mut accum), Some(regions)) = (&mut self.read_dist, annotations.rd_regions) { @@ -1116,6 +1128,7 @@ impl RseqcAccumulators { if let (Some(ref mut a), Some(b)) = (&mut self.tin, other.tin) { a.merge(b); } + self.coverage.merge(other.coverage); if let (Some(ref mut a), Some(b)) = (&mut self.preseq, other.preseq) { a.merge(b); } diff --git a/tests/expected/coverage/test.bedgraph b/tests/expected/coverage/test.bedgraph new file mode 100644 index 00000000..8d68f36e --- /dev/null +++ b/tests/expected/coverage/test.bedgraph @@ -0,0 +1,738 @@ +chr1 1014 1022 1 +chr1 1022 1023 3 +chr1 1023 1031 4 +chr1 1031 1038 5 +chr1 1038 1039 6 +chr1 1039 1040 7 +chr1 1040 1047 8 +chr1 1047 1058 9 +chr1 1058 1064 10 +chr1 1064 1072 9 +chr1 1072 1073 7 +chr1 1073 1074 6 +chr1 1074 1076 7 +chr1 1076 1080 8 +chr1 1080 1081 9 +chr1 1081 1088 8 +chr1 1088 1090 7 +chr1 1090 1097 6 +chr1 1097 1098 5 +chr1 1098 1101 6 +chr1 1101 1105 7 +chr1 1105 1107 8 +chr1 1107 1108 9 +chr1 1108 1124 8 +chr1 1124 1126 7 +chr1 1126 1128 6 +chr1 1128 1130 7 +chr1 1130 1139 6 +chr1 1139 1144 5 +chr1 1144 1147 6 +chr1 1147 1148 7 +chr1 1148 1151 6 +chr1 1151 1155 5 +chr1 1155 1157 4 +chr1 1157 1160 3 +chr1 1160 1163 4 +chr1 1163 1178 5 +chr1 1178 1180 4 +chr1 1180 1186 5 +chr1 1186 1194 6 +chr1 1194 1195 5 +chr1 1195 1197 6 +chr1 1197 1210 5 +chr1 1210 1213 4 +chr1 1213 1224 3 +chr1 1224 1230 4 +chr1 1230 1236 3 +chr1 1236 1245 2 +chr1 1245 1252 1 +chr1 1252 1257 2 +chr1 1257 1260 3 +chr1 1260 1266 5 +chr1 1266 1274 6 +chr1 1274 1286 5 +chr1 1286 1292 6 +chr1 1292 1294 7 +chr1 1294 1296 8 +chr1 1296 1297 9 +chr1 1297 1302 11 +chr1 1302 1307 10 +chr1 1307 1309 9 +chr1 1309 1310 10 +chr1 1310 1312 8 +chr1 1312 1315 9 +chr1 1315 1316 10 +chr1 1316 1319 9 +chr1 1319 1323 10 +chr1 1323 1336 11 +chr1 1336 1342 10 +chr1 1342 1344 9 +chr1 1344 1347 8 +chr1 1347 1354 6 +chr1 1354 1359 7 +chr1 1359 1362 6 +chr1 1362 1365 5 +chr1 1365 1367 4 +chr1 1367 1369 5 +chr1 1369 1372 4 +chr1 1372 1373 5 +chr1 1373 1375 4 +chr1 1375 1388 5 +chr1 1388 1396 6 +chr1 1396 1400 5 +chr1 1400 1408 6 +chr1 1408 1417 7 +chr1 1417 1422 6 +chr1 1422 1424 5 +chr1 1424 1438 6 +chr1 1438 1440 5 +chr1 1440 1450 6 +chr1 1450 1454 5 +chr1 1454 1458 4 +chr1 1458 1474 3 +chr1 1474 1475 2 +chr1 1475 1490 1 +chr1 2018 2021 1 +chr1 2021 2028 2 +chr1 2028 2030 3 +chr1 2030 2051 4 +chr1 2051 2059 5 +chr1 2059 2064 6 +chr1 2064 2068 7 +chr1 2068 2071 6 +chr1 2071 2073 5 +chr1 2073 2078 7 +chr1 2078 2080 6 +chr1 2080 2084 5 +chr1 2084 2093 6 +chr1 2093 2101 7 +chr1 2101 2108 6 +chr1 2108 2109 7 +chr1 2109 2114 6 +chr1 2114 2123 5 +chr1 2123 2131 3 +chr1 2131 2134 4 +chr1 2134 2143 3 +chr1 2143 2148 2 +chr1 2148 2154 3 +chr1 2154 2158 4 +chr1 2158 2168 3 +chr1 2168 2169 4 +chr1 2169 2170 5 +chr1 2170 2173 6 +chr1 2173 2177 7 +chr1 2177 2183 8 +chr1 2183 2185 9 +chr1 2185 2187 10 +chr1 2187 2197 11 +chr1 2197 2198 12 +chr1 2198 2204 11 +chr1 2204 2211 10 +chr1 2211 2218 11 +chr1 2218 2219 10 +chr1 2219 2220 9 +chr1 2220 2223 8 +chr1 2223 2227 7 +chr1 2227 2231 6 +chr1 2231 2233 5 +chr1 2233 2235 4 +chr1 2235 2237 3 +chr1 2237 2245 2 +chr1 2245 2247 3 +chr1 2247 2250 2 +chr1 2250 2253 3 +chr1 2253 2261 4 +chr1 2261 2267 3 +chr1 2267 2274 5 +chr1 2274 2280 6 +chr1 2280 2295 7 +chr1 2295 2300 6 +chr1 2300 2301 5 +chr1 2301 2303 6 +chr1 2303 2305 5 +chr1 2305 2307 6 +chr1 2307 2311 7 +chr1 2311 2317 8 +chr1 2317 2320 7 +chr1 2320 2321 8 +chr1 2321 2324 9 +chr1 2324 2328 8 +chr1 2328 2343 9 +chr1 2343 2346 10 +chr1 2346 2351 11 +chr1 2351 2355 10 +chr1 2355 2357 9 +chr1 2357 2358 8 +chr1 2358 2361 9 +chr1 2361 2367 8 +chr1 2367 2370 7 +chr1 2370 2378 6 +chr1 2378 2380 5 +chr1 2380 2383 4 +chr1 2383 2393 5 +chr1 2393 2398 4 +chr1 2398 2407 5 +chr1 2407 2408 6 +chr1 2408 2409 5 +chr1 2409 2421 6 +chr1 2421 2433 5 +chr1 2433 2448 4 +chr1 2448 2457 3 +chr1 2457 2459 2 +chr1 2459 2496 1 +chr1 3002 3008 1 +chr1 3008 3010 2 +chr1 3010 3018 3 +chr1 3018 3028 4 +chr1 3028 3039 5 +chr1 3039 3041 6 +chr1 3041 3047 8 +chr1 3047 3052 10 +chr1 3052 3058 9 +chr1 3058 3060 8 +chr1 3060 3065 7 +chr1 3065 3068 9 +chr1 3068 3076 8 +chr1 3076 3080 9 +chr1 3080 3084 10 +chr1 3084 3089 11 +chr1 3089 3091 10 +chr1 3091 3092 8 +chr1 3092 3093 9 +chr1 3093 3095 10 +chr1 3095 3097 11 +chr1 3097 3098 9 +chr1 3098 3102 10 +chr1 3102 3105 11 +chr1 3105 3111 12 +chr1 3111 3115 13 +chr1 3115 3123 11 +chr1 3123 3126 12 +chr1 3126 3128 11 +chr1 3128 3130 10 +chr1 3130 3142 9 +chr1 3142 3143 8 +chr1 3143 3145 7 +chr1 3145 3148 6 +chr1 3148 3152 5 +chr1 3152 3155 4 +chr1 3155 3157 3 +chr1 3157 3161 4 +chr1 3161 3173 3 +chr1 3173 3174 2 +chr1 3174 3184 3 +chr1 3184 3194 2 +chr1 3194 3207 3 +chr1 3207 3217 2 +chr1 3217 3218 3 +chr1 3218 3224 4 +chr1 3224 3232 3 +chr1 3232 3244 4 +chr1 3244 3247 3 +chr1 3247 3248 4 +chr1 3248 3258 5 +chr1 3258 3261 6 +chr1 3261 3266 7 +chr1 3266 3267 8 +chr1 3267 3268 7 +chr1 3268 3273 6 +chr1 3273 3278 7 +chr1 3278 3282 8 +chr1 3282 3297 7 +chr1 3297 3298 6 +chr1 3298 3301 5 +chr1 3301 3308 6 +chr1 3308 3311 5 +chr1 3311 3313 4 +chr1 3313 3316 5 +chr1 3316 3323 4 +chr1 3323 3328 3 +chr1 3328 3332 2 +chr1 3332 3334 3 +chr1 3334 3344 4 +chr1 3344 3348 5 +chr1 3348 3351 7 +chr1 3351 3358 6 +chr1 3358 3363 7 +chr1 3363 3365 6 +chr1 3365 3368 7 +chr1 3368 3371 8 +chr1 3371 3382 9 +chr1 3382 3384 8 +chr1 3384 3394 7 +chr1 3394 3398 6 +chr1 3398 3403 4 +chr1 3403 3405 5 +chr1 3405 3408 7 +chr1 3408 3412 6 +chr1 3412 3415 7 +chr1 3415 3417 6 +chr1 3417 3421 7 +chr1 3421 3423 6 +chr1 3423 3434 7 +chr1 3434 3445 8 +chr1 3445 3449 9 +chr1 3449 3453 10 +chr1 3453 3455 9 +chr1 3455 3462 7 +chr1 3462 3467 6 +chr1 3467 3473 5 +chr1 3473 3475 4 +chr1 3475 3477 5 +chr1 3477 3480 6 +chr1 3480 3484 7 +chr1 3484 3489 6 +chr1 3489 3492 8 +chr1 3492 3495 9 +chr1 3495 3499 8 +chr1 3499 3518 7 +chr1 3518 3527 6 +chr1 3527 3539 5 +chr1 3539 3542 3 +chr1 3542 3561 2 +chr1 3561 3566 3 +chr1 3566 3575 4 +chr1 3575 3580 3 +chr1 3580 3594 2 +chr1 3594 3613 3 +chr1 3613 3616 4 +chr1 3616 3624 3 +chr1 3624 3626 4 +chr1 3626 3633 5 +chr1 3633 3636 6 +chr1 3636 3637 7 +chr1 3637 3638 8 +chr1 3638 3644 9 +chr1 3644 3652 8 +chr1 3652 3661 9 +chr1 3661 3663 8 +chr1 3663 3664 7 +chr1 3664 3667 9 +chr1 3667 3675 11 +chr1 3675 3676 12 +chr1 3676 3683 11 +chr1 3683 3686 10 +chr1 3686 3687 9 +chr1 3687 3688 8 +chr1 3688 3689 7 +chr1 3689 3698 8 +chr1 3698 3702 9 +chr1 3702 3714 8 +chr1 3714 3717 6 +chr1 3717 3721 4 +chr1 3721 3724 5 +chr1 3724 3725 4 +chr1 3725 3727 3 +chr1 3727 3739 4 +chr1 3739 3740 3 +chr1 3740 3741 4 +chr1 3741 3742 5 +chr1 3742 3748 6 +chr1 3748 3771 5 +chr1 3771 3777 4 +chr1 3777 3790 3 +chr1 3790 3791 2 +chr1 3791 3792 1 +chr1 5000 5008 1 +chr1 5008 5010 2 +chr1 5010 5050 3 +chr1 5050 5058 2 +chr1 5058 5061 1 +chr1 5061 5110 2 +chr1 5110 5111 1 +chr1 5122 5125 1 +chr1 5125 5172 2 +chr1 5172 5175 1 +chr1 5178 5196 1 +chr1 5196 5199 2 +chr1 5199 5206 4 +chr1 5206 5210 5 +chr1 5210 5228 6 +chr1 5228 5246 5 +chr1 5246 5249 4 +chr1 5249 5253 2 +chr1 5253 5256 3 +chr1 5256 5260 2 +chr1 5260 5267 1 +chr1 5267 5303 3 +chr1 5303 5317 2 +chr1 5335 5380 1 +chr1 5380 5385 2 +chr1 5385 5399 1 +chr1 5399 5430 2 +chr1 5430 5442 1 +chr1 5442 5449 2 +chr1 5449 5492 1 +chr1 5537 5538 1 +chr1 5538 5540 2 +chr1 5540 5587 3 +chr1 5587 5588 2 +chr1 5588 5590 1 +chr1 6006 6008 1 +chr1 6008 6010 2 +chr1 6010 6020 3 +chr1 6020 6034 4 +chr1 6034 6056 5 +chr1 6056 6058 4 +chr1 6058 6060 3 +chr1 6060 6070 2 +chr1 6070 6075 1 +chr1 6075 6084 2 +chr1 6084 6095 1 +chr1 6095 6117 2 +chr1 6117 6118 3 +chr1 6118 6124 4 +chr1 6124 6125 5 +chr1 6125 6145 4 +chr1 6145 6152 3 +chr1 6152 6167 4 +chr1 6167 6168 3 +chr1 6168 6174 2 +chr1 6174 6188 1 +chr1 6188 6192 2 +chr1 6192 6202 3 +chr1 6202 6216 2 +chr1 6216 6218 3 +chr1 6218 6238 4 +chr1 6238 6242 3 +chr1 6242 6266 2 +chr1 6266 6268 1 +chr1 6271 6310 1 +chr1 6310 6315 2 +chr1 6315 6321 3 +chr1 6321 6359 2 +chr1 6359 6365 3 +chr1 6365 6368 2 +chr1 6368 6376 3 +chr1 6376 6402 4 +chr1 6402 6409 5 +chr1 6409 6410 4 +chr1 6410 6414 3 +chr1 6414 6418 4 +chr1 6418 6424 3 +chr1 6424 6426 4 +chr1 6426 6438 3 +chr1 6438 6439 4 +chr1 6439 6452 5 +chr1 6452 6464 4 +chr1 6464 6474 3 +chr1 6474 6488 2 +chr1 6488 6489 1 +chr1 8001 8051 1 +chr1 8082 8091 1 +chr1 8091 8132 2 +chr1 8132 8141 1 +chr1 8179 8229 1 +chr1 8237 8250 1 +chr1 8250 8282 2 +chr1 8282 8287 3 +chr1 8287 8300 2 +chr1 8300 8311 1 +chr1 8311 8332 2 +chr1 8332 8336 1 +chr1 8336 8361 2 +chr1 8361 8372 1 +chr1 8372 8386 2 +chr1 8386 8422 1 +chr1 10059 10109 1 +chr1 10214 10223 1 +chr1 10223 10264 2 +chr1 10264 10273 1 +chr2 1025 1035 1 +chr2 1035 1044 2 +chr2 1044 1047 3 +chr2 1047 1075 4 +chr2 1075 1083 3 +chr2 1083 1084 4 +chr2 1084 1085 5 +chr2 1085 1086 4 +chr2 1086 1094 5 +chr2 1094 1097 4 +chr2 1097 1100 3 +chr2 1100 1103 4 +chr2 1103 1111 5 +chr2 1111 1113 6 +chr2 1113 1119 7 +chr2 1119 1133 8 +chr2 1133 1134 7 +chr2 1134 1136 6 +chr2 1136 1137 5 +chr2 1137 1146 6 +chr2 1146 1150 7 +chr2 1150 1151 6 +chr2 1151 1153 7 +chr2 1153 1154 6 +chr2 1154 1163 7 +chr2 1163 1167 6 +chr2 1167 1168 7 +chr2 1168 1196 8 +chr2 1196 1200 7 +chr2 1200 1201 8 +chr2 1201 1204 7 +chr2 1204 1205 6 +chr2 1205 1212 7 +chr2 1212 1217 8 +chr2 1217 1218 7 +chr2 1218 1219 6 +chr2 1219 1223 5 +chr2 1223 1224 6 +chr2 1224 1237 7 +chr2 1237 1238 6 +chr2 1238 1248 7 +chr2 1248 1250 8 +chr2 1250 1255 7 +chr2 1255 1261 6 +chr2 1261 1262 5 +chr2 1262 1266 4 +chr2 1266 1272 5 +chr2 1272 1273 6 +chr2 1273 1274 5 +chr2 1274 1278 4 +chr2 1278 1285 5 +chr2 1285 1288 6 +chr2 1288 1290 5 +chr2 1290 1298 7 +chr2 1298 1316 6 +chr2 1316 1322 5 +chr2 1322 1328 4 +chr2 1328 1335 3 +chr2 1335 1340 2 +chr2 2009 2017 1 +chr2 2017 2023 2 +chr2 2023 2026 3 +chr2 2026 2038 4 +chr2 2038 2045 5 +chr2 2045 2051 6 +chr2 2051 2059 7 +chr2 2059 2065 6 +chr2 2065 2067 7 +chr2 2067 2068 6 +chr2 2068 2076 7 +chr2 2076 2085 6 +chr2 2085 2088 7 +chr2 2088 2095 6 +chr2 2095 2101 5 +chr2 2101 2104 4 +chr2 2104 2111 5 +chr2 2111 2115 6 +chr2 2115 2116 5 +chr2 2116 2118 6 +chr2 2118 2123 5 +chr2 2123 2135 4 +chr2 2135 2136 3 +chr2 2136 2154 4 +chr2 2154 2161 3 +chr2 2161 2166 2 +chr2 2166 2169 1 +chr2 2169 2186 2 +chr2 2186 2189 1 +chr2 2189 2197 2 +chr2 2197 2202 3 +chr2 2202 2206 4 +chr2 2206 2213 5 +chr2 2213 2214 6 +chr2 2214 2219 8 +chr2 2219 2239 7 +chr2 2239 2247 6 +chr2 2247 2248 5 +chr2 2248 2252 6 +chr2 2252 2253 5 +chr2 2253 2256 6 +chr2 2256 2263 5 +chr2 2263 2264 4 +chr2 2264 2272 2 +chr2 2272 2273 3 +chr2 2273 2292 4 +chr2 2292 2298 5 +chr2 2298 2303 4 +chr2 2303 2309 3 +chr2 2309 2322 4 +chr2 2322 2323 3 +chr2 2323 2332 2 +chr2 2332 2340 3 +chr2 2340 2341 4 +chr2 2341 2342 5 +chr2 2342 2346 4 +chr2 2346 2353 5 +chr2 2353 2359 6 +chr2 2359 2362 5 +chr2 2362 2366 6 +chr2 2366 2379 7 +chr2 2379 2382 9 +chr2 2382 2390 8 +chr2 2390 2391 7 +chr2 2391 2396 6 +chr2 2396 2409 5 +chr2 2409 2412 6 +chr2 2412 2416 5 +chr2 2416 2426 4 +chr2 2426 2429 5 +chr2 2429 2442 3 +chr2 2442 2453 4 +chr2 2453 2459 3 +chr2 2459 2466 2 +chr2 2466 2468 3 +chr2 2468 2469 4 +chr2 2469 2473 5 +chr2 2473 2474 6 +chr2 2474 2476 7 +chr2 2476 2486 6 +chr2 2486 2492 7 +chr2 2492 2505 6 +chr2 2505 2506 7 +chr2 2506 2515 8 +chr2 2515 2516 9 +chr2 2516 2517 8 +chr2 2517 2518 9 +chr2 2518 2519 8 +chr2 2519 2520 7 +chr2 2520 2524 8 +chr2 2524 2536 7 +chr2 2536 2542 6 +chr2 2542 2555 7 +chr2 2555 2556 6 +chr2 2556 2565 5 +chr2 2565 2567 4 +chr2 2567 2570 3 +chr2 2570 2573 2 +chr2 2573 2592 1 +chr2 4044 4052 1 +chr2 4052 4071 2 +chr2 4071 4094 3 +chr2 4094 4102 2 +chr2 4102 4121 1 +chr2 4136 4157 1 +chr2 4157 4186 2 +chr2 4186 4200 1 +chr2 4200 4204 2 +chr2 4204 4207 3 +chr2 4207 4210 2 +chr2 4210 4250 3 +chr2 4250 4254 2 +chr2 4254 4260 1 +chr2 4263 4299 1 +chr2 4299 4313 2 +chr2 4313 4333 1 +chr2 4333 4346 2 +chr2 4346 4349 3 +chr2 4349 4356 2 +chr2 4356 4376 3 +chr2 4376 4383 4 +chr2 4383 4385 3 +chr2 4385 4396 4 +chr2 4396 4406 3 +chr2 4406 4407 2 +chr2 4407 4426 3 +chr2 4426 4435 2 +chr2 4435 4457 1 +chr2 4512 4538 1 +chr2 4538 4540 2 +chr2 4540 4562 3 +chr2 4562 4588 2 +chr2 4588 4590 1 +chr2 4592 4603 1 +chr2 4603 4618 2 +chr2 4618 4621 3 +chr2 4621 4625 4 +chr2 4625 4642 5 +chr2 4642 4645 4 +chr2 4645 4651 5 +chr2 4651 4653 6 +chr2 4653 4657 5 +chr2 4657 4668 6 +chr2 4668 4671 5 +chr2 4671 4675 4 +chr2 4675 4682 3 +chr2 4682 4695 4 +chr2 4695 4701 3 +chr2 4701 4707 2 +chr2 4707 4709 1 +chr2 4709 4732 2 +chr2 4732 4745 1 +chr2 4745 4759 2 +chr2 4759 4795 1 +chr2 5999 6000 2 +chr2 6000 6001 3 +chr2 6001 6002 6 +chr2 6002 6003 9 +chr2 6003 6004 11 +chr2 6004 6007 14 +chr2 6007 6008 17 +chr2 6008 6009 22 +chr2 6009 6010 23 +chr2 6010 6012 25 +chr2 6012 6014 26 +chr2 6014 6015 27 +chr2 6015 6016 29 +chr2 6016 6017 31 +chr2 6017 6018 33 +chr2 6018 6020 37 +chr2 6020 6021 40 +chr2 6021 6023 44 +chr2 6023 6024 48 +chr2 6024 6025 50 +chr2 6025 6026 53 +chr2 6026 6027 54 +chr2 6027 6028 55 +chr2 6028 6029 57 +chr2 6029 6031 59 +chr2 6031 6032 61 +chr2 6032 6034 65 +chr2 6034 6035 66 +chr2 6035 6036 68 +chr2 6036 6037 70 +chr2 6037 6038 72 +chr2 6038 6039 74 +chr2 6039 6040 75 +chr2 6040 6041 79 +chr2 6041 6042 81 +chr2 6042 6043 83 +chr2 6043 6044 84 +chr2 6044 6045 91 +chr2 6045 6046 92 +chr2 6046 6047 94 +chr2 6047 6048 95 +chr2 6048 6049 96 +chr2 6049 6050 98 +chr2 6050 6051 97 +chr2 6051 6052 94 +chr2 6052 6053 91 +chr2 6053 6054 89 +chr2 6054 6057 86 +chr2 6057 6058 83 +chr2 6058 6059 78 +chr2 6059 6060 77 +chr2 6060 6062 75 +chr2 6062 6064 74 +chr2 6064 6065 73 +chr2 6065 6066 71 +chr2 6066 6067 69 +chr2 6067 6068 67 +chr2 6068 6070 63 +chr2 6070 6071 60 +chr2 6071 6073 56 +chr2 6073 6074 52 +chr2 6074 6075 50 +chr2 6075 6076 47 +chr2 6076 6077 46 +chr2 6077 6078 45 +chr2 6078 6079 43 +chr2 6079 6081 41 +chr2 6081 6082 39 +chr2 6082 6084 35 +chr2 6084 6085 34 +chr2 6085 6086 32 +chr2 6086 6087 30 +chr2 6087 6088 28 +chr2 6088 6089 26 +chr2 6089 6090 25 +chr2 6090 6091 21 +chr2 6091 6092 19 +chr2 6092 6093 17 +chr2 6093 6094 16 +chr2 6094 6095 9 +chr2 6095 6096 8 +chr2 6096 6097 6 +chr2 6097 6098 5 +chr2 6098 6099 4 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c7ea3acd..de915818 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1026,3 +1026,158 @@ fn test_dup_check_parallel_uses_global_duplicate_state() { let _ = fs::remove_dir_all(root); } + +// =================================================================== +// Coverage tracks +// =================================================================== + +/// Per-base coverage against `bedtools genomecov -bg -split`. +/// +/// This is the output nf-core/rnaseq currently gets by running bedtools and +/// then converting its bedGraph to bigWig. Note the semantics differ from +/// every other depth engine in the crate: nothing is filtered, so duplicates, +/// secondary alignments and overlapping mates all contribute. +#[test] +fn coverage_intervals_match_bedtools() { + use rust_htslib::bam::{Read as BamRead, Reader}; + use rustqc::common::coverage::bedgraph::CoverageAccum; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut reader = Reader::from_path(root.join("tests/data/test.bam")).unwrap(); + let header = reader.header().to_owned(); + + // One accumulator per contig, as the pipeline drives them. + let mut per_chrom: std::collections::BTreeMap = header + .target_names() + .iter() + .enumerate() + .map(|(tid, name)| { + let chrom = String::from_utf8_lossy(name).to_string(); + let len = header.target_len(tid as u32).unwrap(); + (chrom, CoverageAccum::new(len, None)) + }) + .collect(); + + let mut record = rust_htslib::bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + if record.tid() < 0 { + continue; + } + let chrom = String::from_utf8_lossy(header.tid2name(record.tid() as u32)).to_string(); + if let Some(accum) = per_chrom.get_mut(&chrom) { + accum.process_read(&record); + } + } + + // bedtools emits contigs in header order, so follow that rather than the + // alphabetical order the map would give. + let mut got = Vec::new(); + for (tid, name) in header.target_names().iter().enumerate() { + let _ = tid; + let chrom = String::from_utf8_lossy(name).to_string(); + if let Some(accum) = per_chrom.remove(&chrom) { + got.extend(accum.into_intervals(&chrom, 1.0)); + } + } + + let want: Vec<(String, u32, u32, f32)> = + std::fs::read_to_string(root.join("tests/expected/coverage/test.bedgraph")) + .unwrap() + .lines() + .map(|line| { + let f: Vec<&str> = line.split('\t').collect(); + ( + f[0].to_string(), + f[1].parse().unwrap(), + f[2].parse().unwrap(), + f[3].parse().unwrap(), + ) + }) + .collect(); + + assert_eq!(got.len(), want.len(), "interval count"); + for (index, (ours, theirs)) in got.iter().zip(&want).enumerate() { + assert_eq!( + (ours.chrom.as_str(), ours.start, ours.end, ours.value), + (theirs.0.as_str(), theirs.1, theirs.2, theirs.3), + "interval {} differs", + index + 1 + ); + } +} + +/// The written bigWig read back against the bedtools reference. +/// +/// A gap between intervals is *undefined* in a bigWig rather than zero, and +/// reads back as `NaN`. That is the format's own semantics and matches what +/// `bedGraphToBigWig` produces from a bedGraph that omits its zero-depth +/// spans, so the check is that every covered base agrees and every uncovered +/// one is undefined. +#[cfg(feature = "bigwig")] +#[test] +fn bigwig_track_matches_the_bedtools_reference() { + use bigtools::BigWigRead; + use rust_htslib::bam::{Read as BamRead, Reader}; + use rustqc::common::coverage::bedgraph::CoverageTracks; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut reader = Reader::from_path(root.join("tests/data/test.bam")).unwrap(); + let header = reader.header().to_owned(); + let chrom_sizes: Vec<(String, u64)> = header + .target_names() + .iter() + .enumerate() + .map(|(tid, name)| { + ( + String::from_utf8_lossy(name).to_string(), + header.target_len(tid as u32).unwrap(), + ) + }) + .collect(); + + let mut tracks = CoverageTracks::new(vec![None]); + let mut record = rust_htslib::bam::Record::new(); + while let Some(result) = reader.read(&mut record) { + result.unwrap(); + if record.tid() < 0 { + continue; + } + let chrom = String::from_utf8_lossy(header.tid2name(record.tid() as u32)).to_string(); + tracks.process_read(&record, &chrom); + } + + let order: Vec = chrom_sizes.iter().map(|(n, _)| n.clone()).collect(); + let intervals = tracks.into_intervals(&order, 1.0).remove(0).1; + + let path = std::env::temp_dir().join("rustqc-coverage-parity.bigWig"); + assert!( + rustqc::common::coverage::bigwig::write_bigwig(&intervals, &chrom_sizes, &path).unwrap() + ); + + // Every interval bedtools reported must read back at the same depth. + let mut bw = BigWigRead::open_file(&path).unwrap(); + let reference = + std::fs::read_to_string(root.join("tests/expected/coverage/test.bedgraph")).unwrap(); + let mut checked = 0usize; + for line in reference.lines() { + let f: Vec<&str> = line.split('\t').collect(); + let (chrom, start, end, depth) = ( + f[0], + f[1].parse::().unwrap(), + f[2].parse::().unwrap(), + f[3].parse::().unwrap(), + ); + let values = bw.values(chrom, start, end).unwrap(); + for (offset, value) in values.iter().enumerate() { + assert_eq!( + *value, + depth, + "{chrom}:{} should be at depth {depth}", + start as usize + offset + ); + checked += 1; + } + } + assert!(checked > 0, "the reference must cover something"); +}