Skip to main content

fastqc_rust/utils/
phred.rs

1/// Phred quality encoding detection.
2///
3/// Replicates the logic from `Sequence/QualityEncoding/PhredEncoding.java`.
4
5#[derive(Debug, Clone, Copy)]
6pub struct PhredEncoding {
7    pub name: &'static str,
8    pub offset: u8,
9}
10
11// These constants match the Java SANGER_ENCODING_OFFSET and
12// ILLUMINA_1_3_ENCODING_OFFSET fields exactly.
13const SANGER_ENCODING_OFFSET: u8 = 33;
14const ILLUMINA_1_3_ENCODING_OFFSET: u8 = 64;
15
16impl PhredEncoding {
17    /// The Sanger / Illumina 1.9 (Phred+33) encoding.
18    ///
19    /// This is the encoding produced by construction for BAM/SAM input:
20    /// BAM stores raw Phred values which the reader converts to ASCII by
21    /// adding 33, and SAM quality strings are Phred+33 by specification.
22    pub const SANGER: PhredEncoding = PhredEncoding {
23        name: "Sanger / Illumina 1.9",
24        offset: SANGER_ENCODING_OFFSET,
25    };
26}
27
28impl PhredEncoding {
29    /// Marker for `--phred64`: tells [`resolve`] to read the quality as
30    /// Phred+64, naming Illumina 1.3 vs 1.5 from the lowest character.
31    pub const PHRED64: PhredEncoding = PhredEncoding {
32        name: "Illumina 1.5",
33        offset: ILLUMINA_1_3_ENCODING_OFFSET,
34    };
35}
36
37/// Lowest-quality-character starting value for modules that track it. Java
38/// uses 1000, an impossible char, so "no data seen" can be told apart.
39pub const NO_QUALITY_SEEN: u16 = 1000;
40
41/// Resolve the quality encoding for a data source.
42///
43/// `hint` comes from [`crate::modules::QCModule::set_phred_encoding`]: the
44/// encoding fixed by the file format (BAM/SAM), [`PhredEncoding::PHRED64`]
45/// for `--phred64`, or `None` for the Phred+33 default.
46pub fn resolve(hint: Option<PhredEncoding>, lowest_char: u16) -> Result<PhredEncoding, String> {
47    match hint {
48        Some(e) if e.offset == ILLUMINA_1_3_ENCODING_OFFSET => detect(lowest_char, true),
49        Some(e) => Ok(e),
50        None => detect(lowest_char, false),
51    }
52}
53
54/// Replicates `PhredEncoding.getFastQEncodingOffset(char)` from FastQC 0.13:
55/// Phred+33 unless `phred64` is set, with a feasibility check either way.
56pub fn detect(lowest_char: u16, phred64: bool) -> Result<PhredEncoding, String> {
57    let c = char::from_u32(lowest_char as u32).unwrap_or('?');
58    if lowest_char < 33 {
59        return Err(format!(
60            "No known encodings with chars < 33 (Yours was '{}' with value {})",
61            c, lowest_char
62        ));
63    }
64    if !phred64 {
65        return Ok(PhredEncoding::SANGER);
66    }
67    if lowest_char < ILLUMINA_1_3_ENCODING_OFFSET as u16 {
68        return Err(format!(
69            "Phred64 encoding is incompatible with having ASCII char '{}' with value {}) in the file",
70            c, lowest_char
71        ));
72    }
73    // Illumina 1.3 allowed quality 1 (ASCII 65); from 1.5 the minimum was 2.
74    if lowest_char == ILLUMINA_1_3_ENCODING_OFFSET as u16 + 1 {
75        Ok(PhredEncoding {
76            name: "Illumina 1.3",
77            offset: ILLUMINA_1_3_ENCODING_OFFSET,
78        })
79    } else {
80        Ok(PhredEncoding::PHRED64)
81    }
82}
83
84/// Java warns when Phred+33 data has nothing below Q31, which suggests the
85/// file may really be Phred+64.
86pub fn phred64_suspicion(lowest_char: u16) -> Option<String> {
87    (lowest_char >= ILLUMINA_1_3_ENCODING_OFFSET as u16 && lowest_char != NO_QUALITY_SEEN).then(
88        || {
89            format!(
90            "Using Phred33 encoding your lowest quality is {} could this file be Phred64 encoded?",
91            lowest_char - SANGER_ENCODING_OFFSET as u16
92        )
93        },
94    )
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_phred33_default() {
103        assert_eq!(detect(b'!' as u16, false).unwrap().offset, 33);
104        // Formerly misdetected as Illumina 1.5
105        assert_eq!(
106            detect(b'I' as u16, false).unwrap().name,
107            "Sanger / Illumina 1.9"
108        );
109        assert_eq!(detect(200, false).unwrap().offset, 33);
110        assert_eq!(detect(NO_QUALITY_SEEN, false).unwrap().offset, 33);
111    }
112
113    #[test]
114    fn test_phred64() {
115        assert_eq!(detect(65, true).unwrap().name, "Illumina 1.3");
116        assert_eq!(detect(66, true).unwrap().name, "Illumina 1.5");
117        assert_eq!(detect(66, true).unwrap().offset, 64);
118        assert!(detect(63, true)
119            .unwrap_err()
120            .contains("Phred64 encoding is incompatible"));
121    }
122
123    #[test]
124    fn test_error_below_33() {
125        assert!(detect(20, false).unwrap_err().contains("< 33"));
126    }
127
128    #[test]
129    fn test_resolve() {
130        let enc = resolve(Some(PhredEncoding::SANGER), b'I' as u16).unwrap();
131        assert_eq!(enc.name, "Sanger / Illumina 1.9");
132        assert_eq!(
133            resolve(Some(PhredEncoding::PHRED64), 65).unwrap().name,
134            "Illumina 1.3"
135        );
136        assert_eq!(resolve(None, b'I' as u16).unwrap().offset, 33);
137    }
138
139    #[test]
140    fn test_phred64_suspicion() {
141        assert!(phred64_suspicion(b'I' as u16)
142            .unwrap()
143            .contains("lowest quality is 40"));
144        assert!(phred64_suspicion(b'?' as u16).is_none());
145        assert!(phred64_suspicion(NO_QUALITY_SEEN).is_none());
146    }
147}