Skip to main content

fastx/
qual.rs

1//! Phred quality scores: decoding, statistics and trimming.
2//!
3//! FASTQ stores quality as printable ASCII. Sanger/Illumina 1.8+ files use an
4//! offset of 33 ([`QualityEncoding::Phred33`]); old Illumina 1.3–1.7 files use
5//! 64 ([`QualityEncoding::Phred64`]). Everything in this module takes the offset
6//! explicitly so that neither is assumed silently.
7
8use std::ops::Range;
9
10/// ASCII offset of Sanger / Illumina 1.8+ quality strings.
11pub const PHRED33: u8 = 33;
12/// ASCII offset of Illumina 1.3–1.7 quality strings.
13pub const PHRED64: u8 = 64;
14
15/// The ASCII offset used by a FASTQ quality string.
16///
17/// Deliberately *not* `#[non_exhaustive]`: these two are the whole of the
18/// offset-encoded world. Solexa's old scheme is not a third offset but a
19/// different formula, so it could not join this enum without changing what
20/// [`QualityEncoding::offset`] means.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22pub enum QualityEncoding {
23    /// Sanger / Illumina 1.8+ / PacBio / Nanopore: `!` == Q0.
24    #[default]
25    Phred33,
26    /// Illumina 1.3–1.7: `@` == Q0.
27    Phred64,
28}
29
30impl QualityEncoding {
31    /// The ASCII offset for this encoding.
32    pub const fn offset(self) -> u8 {
33        match self {
34            QualityEncoding::Phred33 => PHRED33,
35            QualityEncoding::Phred64 => PHRED64,
36        }
37    }
38
39    /// Guess the encoding from observed quality characters.
40    ///
41    /// Returns `None` when the sample is compatible with both encodings, which
42    /// is common for short high-quality samples — callers should then keep their
43    /// current default rather than guessing.
44    ///
45    /// ```
46    /// use fastx::qual::QualityEncoding;
47    /// assert_eq!(QualityEncoding::detect(b"!!##$$"), Some(QualityEncoding::Phred33));
48    /// assert_eq!(QualityEncoding::detect(b"hhhhhh"), Some(QualityEncoding::Phred64));
49    /// assert_eq!(QualityEncoding::detect(b"IIIIII"), None);
50    /// ```
51    pub fn detect(quality: &[u8]) -> Option<QualityEncoding> {
52        let mut min = u8::MAX;
53        let mut max = 0u8;
54        for &q in quality {
55            min = min.min(q);
56            max = max.max(q);
57        }
58        if quality.is_empty() {
59            return None;
60        }
61        // Phred64 never emits characters below '@' (64); Phred33 rarely exceeds
62        // 'J' (Q41) but long-read platforms can, so only the low end is decisive
63        // for Phred33 and only the high end for Phred64.
64        if min < PHRED64 {
65            Some(QualityEncoding::Phred33)
66        } else if max > b'J' {
67            Some(QualityEncoding::Phred64)
68        } else {
69            None
70        }
71    }
72}
73
74/// Decode one quality character into a Phred score, saturating at 0.
75#[inline]
76pub fn score(ch: u8, offset: u8) -> u8 {
77    ch.saturating_sub(offset)
78}
79
80/// Encode a Phred score as a quality character, clamped to printable ASCII.
81#[inline]
82pub fn encode(score: u8, offset: u8) -> u8 {
83    offset.saturating_add(score.min(126 - offset))
84}
85
86/// Decode a whole quality string into Phred scores.
87pub fn scores(quality: &[u8], offset: u8) -> Vec<u8> {
88    quality.iter().map(|&q| score(q, offset)).collect()
89}
90
91/// Probability that a base with the given Phred score is wrong: `10^(-q/10)`.
92///
93/// ```
94/// # use fastx::qual::error_probability;
95/// assert!((error_probability(20) - 0.01).abs() < 1e-12);
96/// assert!((error_probability(30) - 0.001).abs() < 1e-12);
97/// ```
98#[inline]
99pub fn error_probability(score: u8) -> f64 {
100    10f64.powf(-(score as f64) / 10.0)
101}
102
103/// Phred score corresponding to an error probability, clamped to `0..=93`.
104pub fn probability_to_score(p: f64) -> u8 {
105    if p <= 0.0 {
106        return 93;
107    }
108    let q = -10.0 * p.log10();
109    q.round().clamp(0.0, 93.0) as u8
110}
111
112/// Arithmetic mean of the Phred scores, or `None` for an empty string.
113pub fn mean_score(quality: &[u8], offset: u8) -> Option<f64> {
114    if quality.is_empty() {
115        return None;
116    }
117    let sum: u64 = quality.iter().map(|&q| score(q, offset) as u64).sum();
118    Some(sum as f64 / quality.len() as f64)
119}
120
121/// Sum of per-base error probabilities — the expected number of wrong bases.
122///
123/// This is the quantity `fastp`/`vsearch` filter on, and it is a better read
124/// quality summary than the arithmetic mean of Phred scores.
125pub fn expected_errors(quality: &[u8], offset: u8) -> f64 {
126    quality
127        .iter()
128        .map(|&q| error_probability(score(q, offset)))
129        .sum()
130}
131
132/// Mean quality expressed as a Phred score derived from the mean error rate.
133pub fn mean_quality(quality: &[u8], offset: u8) -> Option<f64> {
134    if quality.is_empty() {
135        return None;
136    }
137    let mean_p = expected_errors(quality, offset) / quality.len() as f64;
138    Some(-10.0 * mean_p.log10())
139}
140
141/// Fraction of bases with a Phred score of at least `threshold` (e.g. Q30).
142pub fn fraction_at_least(quality: &[u8], offset: u8, threshold: u8) -> f64 {
143    if quality.is_empty() {
144        return 0.0;
145    }
146    let n = quality
147        .iter()
148        .filter(|&&q| score(q, offset) >= threshold)
149        .count();
150    n as f64 / quality.len() as f64
151}
152
153/// Trim low-quality bases from both ends, keeping the inner region.
154///
155/// Returns an empty range positioned at 0 when every base is below `min_score`.
156pub fn trim_ends(quality: &[u8], offset: u8, min_score: u8) -> Range<usize> {
157    let start = quality.iter().position(|&q| score(q, offset) >= min_score);
158    match start {
159        None => 0..0,
160        Some(start) => {
161            let end = quality
162                .iter()
163                .rposition(|&q| score(q, offset) >= min_score)
164                .unwrap()
165                + 1;
166            start..end
167        }
168    }
169}
170
171/// Mott-style trimming: the highest-scoring subsequence of `q - threshold`.
172///
173/// This is the algorithm used by `phred`/`seqtk trimfq` and it handles reads
174/// whose quality dips in the middle far better than end trimming.
175pub fn trim_mott(quality: &[u8], offset: u8, threshold: u8) -> Range<usize> {
176    let mut best = 0..0;
177    let mut best_score = 0i64;
178    let mut running = 0i64;
179    let mut start = 0usize;
180
181    for (i, &q) in quality.iter().enumerate() {
182        running += score(q, offset) as i64 - threshold as i64;
183        if running < 0 {
184            running = 0;
185            start = i + 1;
186        } else if running > best_score {
187            best_score = running;
188            best = start..i + 1;
189        }
190    }
191    best
192}
193
194/// Sliding-window trimming from the 3' end, in the spirit of
195/// `trimmomatic SLIDINGWINDOW`.
196///
197/// Scans left to right and cuts at the first position where the mean score of
198/// the following `window` bases drops below `min_mean`; the returned range ends
199/// at the start of that window. Reads shorter than the window are kept intact.
200pub fn trim_sliding_window(
201    quality: &[u8],
202    offset: u8,
203    window: usize,
204    min_mean: f64,
205) -> Range<usize> {
206    if window == 0 || quality.len() < window {
207        return 0..quality.len();
208    }
209    let threshold = min_mean * window as f64;
210    let mut sum: f64 = quality[..window]
211        .iter()
212        .map(|&q| score(q, offset) as f64)
213        .sum();
214    if sum < threshold {
215        return 0..0;
216    }
217    for i in window..quality.len() {
218        sum += score(quality[i], offset) as f64;
219        sum -= score(quality[i - window], offset) as f64;
220        if sum < threshold {
221            return 0..i + 1 - window;
222        }
223    }
224    0..quality.len()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn scores_round_trip() {
233        for s in 0u8..=60 {
234            assert_eq!(score(encode(s, PHRED33), PHRED33), s);
235        }
236    }
237
238    #[test]
239    fn error_probabilities() {
240        assert_eq!(probability_to_score(0.001), 30);
241        assert_eq!(probability_to_score(1.0), 0);
242        assert!((expected_errors(b"!!!!", PHRED33) - 4.0).abs() < 1e-9);
243    }
244
245    #[test]
246    fn mean_quality_is_error_weighted() {
247        // One terrible base drags the error-weighted mean far below the
248        // arithmetic mean of the Phred scores.
249        let q = b"IIIII!"; // five Q40 and one Q0
250        let arithmetic = mean_score(q, PHRED33).unwrap();
251        let weighted = mean_quality(q, PHRED33).unwrap();
252        assert!(arithmetic > 33.0, "{arithmetic}");
253        assert!(weighted < 8.0, "{weighted}");
254    }
255
256    #[test]
257    fn trims_ends() {
258        assert_eq!(trim_ends(b"!!III!!", PHRED33, 30), 2..5);
259        assert_eq!(trim_ends(b"!!!!", PHRED33, 30), 0..0);
260        assert_eq!(trim_ends(b"IIII", PHRED33, 30), 0..4);
261    }
262
263    #[test]
264    fn mott_keeps_best_window() {
265        // Low quality at both ends, a good stretch in the middle.
266        let q = b"###IIIIIIII###";
267        assert_eq!(trim_mott(q, PHRED33, 20), 3..11);
268        assert_eq!(trim_mott(b"####", PHRED33, 20), 0..0);
269    }
270
271    #[test]
272    fn sliding_window_cuts_at_drop() {
273        // Eight Q40 bases then four Q2 bases: the first window that fails
274        // starts at index 7, so everything from there on is discarded.
275        let q = b"IIIIIIII####";
276        assert_eq!(trim_sliding_window(q, PHRED33, 4, 20.0), 0..7);
277        assert_eq!(trim_sliding_window(b"IIIIIIII", PHRED33, 4, 20.0), 0..8);
278        assert_eq!(trim_sliding_window(b"II", PHRED33, 4, 20.0), 0..2);
279        assert_eq!(trim_sliding_window(b"####IIII", PHRED33, 4, 20.0), 0..0);
280    }
281
282    #[test]
283    fn q30_fraction() {
284        assert!((fraction_at_least(b"IIII!!!!", PHRED33, 30) - 0.5).abs() < 1e-9);
285        assert_eq!(fraction_at_least(b"", PHRED33, 30), 0.0);
286    }
287}