@@ -72,6 +72,58 @@ pub struct FastQStats {
7272 pub max_len : i64 ,
7373 /// Base counts: (A, C, G, T, other), each case-insensitive.
7474 pub bp : ( i64 , i64 , i64 , i64 , i64 ) ,
75+ /// Per-position quality statistics `(mean, median, lower_quartile, upper_quartile)`, one
76+ /// tuple per base position (mirrors `qualityPercentiles` in `Data/FastQ.hs`).
77+ pub qual_percentiles : Vec < ( i64 , i64 , i64 , i64 ) > ,
78+ }
79+
80+ /// Quality-value offset for the per-position quality histograms (mirrors Haskell's
81+ /// `minQualityValue`): quality `q` is counted at histogram index `q - MIN_QUALITY_VALUE`.
82+ const MIN_QUALITY_VALUE : i64 = -5 ;
83+
84+ /// Width of each per-position quality histogram (mirrors the 256-wide `VU.Vector Int`).
85+ const QUAL_HIST_WIDTH : usize = 256 ;
86+
87+ /// A per-position quality histogram: for each base position a 256-wide count vector indexed by
88+ /// `q - MIN_QUALITY_VALUE` (mirrors `qualCounts :: [VU.Vector Int]`).
89+ type QualCounts = Vec < [ i64 ; QUAL_HIST_WIDTH ] > ;
90+
91+ /// Fold a read's decoded qualities into the per-position histograms, growing them as needed
92+ /// (mirrors the `update` inner loop of `fqStatsC`).
93+ fn accumulate_qualities ( qual_counts : & mut QualCounts , qualities : & [ i8 ] ) {
94+ if qual_counts. len ( ) < qualities. len ( ) {
95+ qual_counts. resize ( qualities. len ( ) , [ 0 ; QUAL_HIST_WIDTH ] ) ;
96+ }
97+ for ( i, & q) in qualities. iter ( ) . enumerate ( ) {
98+ let idx = ( q as i64 - MIN_QUALITY_VALUE ) as usize ;
99+ qual_counts[ i] [ idx] += 1 ;
100+ }
101+ }
102+
103+ /// Per-position `(mean, median, lower_quartile, upper_quartile)` quality, a faithful port of
104+ /// `qualityPercentiles`. Each output is offset by `MIN_QUALITY_VALUE`, matching Haskell.
105+ fn quality_percentiles ( qual_counts : & QualCounts ) -> Vec < ( i64 , i64 , i64 , i64 ) > {
106+ qual_counts. iter ( ) . map ( position_stats) . collect ( )
107+ }
108+
109+ fn position_stats ( qs : & [ i64 ; QUAL_HIST_WIDTH ] ) -> ( i64 , i64 , i64 , i64 ) {
110+ let elem_total: i64 = qs. iter ( ) . sum ( ) ;
111+ // bpSum = Σ i * qs[i]; mean uses Haskell's integer `div` (floor, both operands ≥ 0).
112+ let bp_sum: i64 = qs. iter ( ) . enumerate ( ) . map ( |( i, & q) | i as i64 * q) . sum ( ) ;
113+ let mean = bp_sum / elem_total + MIN_QUALITY_VALUE ;
114+ // The index of the first inclusive prefix-sum reaching `ceil(elemTotal * perc)`.
115+ let percentile = |perc : f64 | -> i64 {
116+ let lim = ( elem_total as f64 * perc) . ceil ( ) as i64 ;
117+ let mut acc = 0i64 ;
118+ for ( i, & q) in qs. iter ( ) . enumerate ( ) {
119+ acc += q;
120+ if acc >= lim {
121+ return i as i64 + MIN_QUALITY_VALUE ;
122+ }
123+ }
124+ unreachable ! ( "ERROR: Logical impossibility in calcPercentile function" )
125+ } ;
126+ ( mean, percentile ( 0.50 ) , percentile ( 0.25 ) , percentile ( 0.75 ) )
75127}
76128
77129impl FastQStats {
@@ -99,6 +151,7 @@ pub fn stats_from_reads(reads: &[ShortRead]) -> FastQStats {
99151 let ( mut a, mut c, mut g, mut t, mut o) = ( 0i64 , 0i64 , 0i64 , 0i64 , 0i64 ) ;
100152 let mut min_len = i64:: MAX ;
101153 let mut max_len = 0i64 ;
154+ let mut qual_counts: QualCounts = Vec :: new ( ) ;
102155 for r in reads {
103156 let len = r. sequence . len ( ) as i64 ;
104157 min_len = min_len. min ( len) ;
@@ -112,12 +165,14 @@ pub fn stats_from_reads(reads: &[ShortRead]) -> FastQStats {
112165 _ => o += 1 ,
113166 }
114167 }
168+ accumulate_qualities ( & mut qual_counts, & r. qualities ) ;
115169 }
116170 FastQStats {
117171 n_seq : reads. len ( ) as i64 ,
118172 min_len,
119173 max_len,
120174 bp : ( a, c, g, t, o) ,
175+ qual_percentiles : quality_percentiles ( & qual_counts) ,
121176 }
122177}
123178
@@ -309,9 +364,9 @@ impl<R: BufRead> Iterator for FastqReader<R> {
309364}
310365
311366/// Incremental version of [`stats_from_reads`]: fold reads one at a time so QC statistics can
312- /// be collected during a single streaming pass. Uses only the sequence (never qualities), so it
313- /// is independent of the quality encoding — which is what lets the QC pass be decoupled from
314- /// encoding detection . `finish` on an empty accumulator yields `(min_len, max_len) =
367+ /// be collected during a single streaming pass. The reads are already decoded with the file's
368+ /// detected encoding, so the per-position quality histograms (`qual_counts`) match Haskell's
369+ /// `qualCounts` . `finish` on an empty accumulator yields `(min_len, max_len) =
315370/// (i64::MAX, 0)`, exactly as `stats_from_reads(&[])`.
316371#[ derive( Clone , Debug ) ]
317372pub struct FastQStatsAcc {
@@ -323,6 +378,7 @@ pub struct FastQStatsAcc {
323378 g : i64 ,
324379 t : i64 ,
325380 o : i64 ,
381+ qual_counts : QualCounts ,
326382}
327383
328384impl Default for FastQStatsAcc {
@@ -342,6 +398,7 @@ impl FastQStatsAcc {
342398 g : 0 ,
343399 t : 0 ,
344400 o : 0 ,
401+ qual_counts : Vec :: new ( ) ,
345402 }
346403 }
347404
@@ -364,6 +421,7 @@ impl FastQStatsAcc {
364421 _ => self . o += 1 ,
365422 }
366423 }
424+ accumulate_qualities ( & mut self . qual_counts , & sr. qualities ) ;
367425 }
368426
369427 /// Fold another accumulator into this one. Every field is a commutative/associative
@@ -379,6 +437,15 @@ impl FastQStatsAcc {
379437 self . g += other. g ;
380438 self . t += other. t ;
381439 self . o += other. o ;
440+ if self . qual_counts . len ( ) < other. qual_counts . len ( ) {
441+ self . qual_counts
442+ . resize ( other. qual_counts . len ( ) , [ 0 ; QUAL_HIST_WIDTH ] ) ;
443+ }
444+ for ( dst, src) in self . qual_counts . iter_mut ( ) . zip ( other. qual_counts . iter ( ) ) {
445+ for i in 0 ..QUAL_HIST_WIDTH {
446+ dst[ i] += src[ i] ;
447+ }
448+ }
382449 }
383450
384451 pub fn finish ( & self ) -> FastQStats {
@@ -387,6 +454,7 @@ impl FastQStatsAcc {
387454 min_len : self . min_len ,
388455 max_len : self . max_len ,
389456 bp : ( self . a , self . c , self . g , self . t , self . o ) ,
457+ qual_percentiles : quality_percentiles ( & self . qual_counts ) ,
390458 }
391459 }
392460}
@@ -922,4 +990,49 @@ mod tests {
922990 assert_eq ! ( trimmed. sequence, "TTT" ) ;
923991 assert_eq ! ( trimmed. qualities, vec![ 123 , 122 , 111 ] ) ;
924992 }
993+
994+ #[ test]
995+ fn quality_percentiles_matches_haskell ( ) {
996+ // Two reads, hand-computed against `qualityPercentiles` (mean, median, lq, uq), each
997+ // offset by minQualityValue = -5.
998+ let reads = vec ! [
999+ ShortRead :: new( "a" , "AC" , vec![ 10 , 20 ] ) ,
1000+ ShortRead :: new( "b" , "GT" , vec![ 30 , 40 ] ) ,
1001+ ] ;
1002+ // Position 0 qualities {10,30}: mean 20, median 10, lq 10, uq 30.
1003+ // Position 1 qualities {20,40}: mean 30, median 20, lq 20, uq 40.
1004+ let expected = vec ! [ ( 20 , 10 , 10 , 30 ) , ( 30 , 20 , 20 , 40 ) ] ;
1005+ assert_eq ! ( stats_from_reads( & reads) . qual_percentiles, expected) ;
1006+
1007+ // The streaming accumulator agrees with the batch computation, one read at a time.
1008+ let mut acc = FastQStatsAcc :: new ( ) ;
1009+ for r in & reads {
1010+ acc. update ( r) ;
1011+ }
1012+ assert_eq ! ( acc. finish( ) . qual_percentiles, expected) ;
1013+ }
1014+
1015+ #[ test]
1016+ fn quality_percentiles_merge_and_uneven_lengths ( ) {
1017+ // Reads of different lengths: only the longer read reaches position 2.
1018+ let left = vec ! [ ShortRead :: new( "a" , "ACG" , vec![ 10 , 20 , 30 ] ) ] ;
1019+ let right = vec ! [ ShortRead :: new( "b" , "AC" , vec![ 14 , 24 ] ) ] ;
1020+
1021+ let serial = stats_from_reads ( & [ left[ 0 ] . clone ( ) , right[ 0 ] . clone ( ) ] ) ;
1022+
1023+ // Merging per-block accumulators in input order matches the single serial fold.
1024+ let mut a = FastQStatsAcc :: new ( ) ;
1025+ for r in & left {
1026+ a. update ( r) ;
1027+ }
1028+ let mut b = FastQStatsAcc :: new ( ) ;
1029+ for r in & right {
1030+ b. update ( r) ;
1031+ }
1032+ a. merge ( & b) ;
1033+ assert_eq ! ( a. finish( ) . qual_percentiles, serial. qual_percentiles) ;
1034+
1035+ // Empty input has no per-position statistics.
1036+ assert ! ( stats_from_reads( & [ ] ) . qual_percentiles. is_empty( ) ) ;
1037+ }
9251038}
0 commit comments