Skip to main content

fibertools_rs/utils/
platform.rs

1//! Detect the sequencing platform (PacBio vs ONT) per read, so downstream
2//! commands can auto-select models/parameters. Aux tags are checked first;
3//! when they've been stripped, the read name is used as a fallback.
4
5use lazy_static::lazy_static;
6use regex::Regex;
7use rust_htslib::bam::Record;
8
9/// PacBio-only per-read aux tags: `zm` ZMW, `np` passes, `rq` read quality,
10/// `sn` SNR. Never emitted by ONT or re-used by fibertools.
11/// See <https://pacbiofileformats.readthedocs.io/en/13.1/BAM.html#use-of-read-tags-for-per-read-information>.
12const PACBIO_READ_TAGS: [&[u8; 2]; 4] = [b"zm", b"np", b"rq", b"sn"];
13
14/// ONT-only per-read aux tags: `mx` mux, `ch` channel, `st` start time,
15/// `du` duration, `ts` trimmed samples. Excludes `ns`/`nl`/`fn`/`rn`, which
16/// fibertools re-uses for its own annotation arrays.
17/// See <https://nanoporetech.github.io/ont-output-specifications/latest/read_formats/bam/#read-tags>.
18const ONT_READ_TAGS: [&[u8; 2]; 5] = [b"mx", b"ch", b"st", b"du", b"ts"];
19
20lazy_static! {
21    /// PacBio read names are `<movie>/<zmw>/<type>`, e.g.
22    /// `m84081_231207_200206_s1/240456095/ccs`.
23    /// See <https://pacbiofileformats.readthedocs.io/en/13.1/BAM.html#qname-convention>.
24    static ref PACBIO_QNAME: Regex = Regex::new(r"^m\w+/\d+/").unwrap();
25    /// ONT read names are the per-read UUID (`read_id`), e.g.
26    /// `d78c9b31-cec7-4381-bc1c-5f1513d070b9`.
27    /// See <https://nanoporetech.github.io/ont-output-specifications/latest/read_formats/bam/>.
28    static ref ONT_QNAME: Regex = Regex::new(
29        r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
30    ).unwrap();
31}
32
33/// The sequencing platform a single read originated from.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SeqPlatform {
36    PacBio,
37    Ont,
38    /// No usable signal on this read.
39    Unknown,
40}
41
42/// Detect the platform of a single read: aux tags first, then the read name as
43/// a fallback for reads whose basecaller tags have been stripped.
44pub fn platform_from_record(rec: &Record) -> SeqPlatform {
45    match platform_from_aux(rec) {
46        SeqPlatform::Unknown => platform_from_qname(rec.qname()),
47        known => known,
48    }
49}
50
51/// Detect from per-read aux tag signatures. Presence is a positive signal only;
52/// absence means nothing (many tags are optional/flag-gated).
53pub fn platform_from_aux(rec: &Record) -> SeqPlatform {
54    let has_any = |tags: &[&[u8; 2]]| tags.iter().any(|t| rec.aux(&t[..]).is_ok());
55    let pacbio = has_any(&PACBIO_READ_TAGS);
56    let ont = has_any(&ONT_READ_TAGS);
57    match (pacbio, ont) {
58        (true, false) => SeqPlatform::PacBio,
59        (false, true) => SeqPlatform::Ont,
60        // Neither, or contradictory: don't guess.
61        _ => SeqPlatform::Unknown,
62    }
63}
64
65/// Detect from the read-name convention: PacBio `<movie>/<zmw>/...` vs an ONT
66/// UUID. Survives aux-tag stripping.
67pub fn platform_from_qname(qname: &[u8]) -> SeqPlatform {
68    let name = String::from_utf8_lossy(qname);
69    if PACBIO_QNAME.is_match(&name) {
70        SeqPlatform::PacBio
71    } else if ONT_QNAME.is_match(&name) {
72        SeqPlatform::Ont
73    } else {
74        SeqPlatform::Unknown
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use rust_htslib::bam::{self, Read};
82
83    fn first_record(path: &str) -> Record {
84        let mut reader = bam::Reader::from_path(path).unwrap();
85        let mut rec = Record::new();
86        reader
87            .read(&mut rec)
88            .expect("fixture has at least one record")
89            .expect("read ok");
90        rec
91    }
92
93    const PACBIO_FIXTURES: [&str; 5] = [
94        "tests/data/revio.bam",
95        "tests/data/two_two.bam",
96        "tests/data/three_two.bam",
97        "tests/data/all.bam",
98        "tests/data/ctcf.bam",
99    ];
100    const ONT_FIXTURE: &str = "tests/data/ONT.NAPA.bam";
101
102    #[test]
103    fn aux_detects_pacbio() {
104        for f in PACBIO_FIXTURES {
105            assert_eq!(
106                platform_from_aux(&first_record(f)),
107                SeqPlatform::PacBio,
108                "{f}"
109            );
110        }
111    }
112
113    #[test]
114    fn aux_detects_ont() {
115        assert_eq!(
116            platform_from_aux(&first_record(ONT_FIXTURE)),
117            SeqPlatform::Ont
118        );
119    }
120
121    #[test]
122    fn qname_detects_pacbio() {
123        for f in PACBIO_FIXTURES {
124            assert_eq!(
125                platform_from_qname(first_record(f).qname()),
126                SeqPlatform::PacBio,
127                "{f}"
128            );
129        }
130    }
131
132    #[test]
133    fn qname_detects_ont() {
134        assert_eq!(
135            platform_from_qname(first_record(ONT_FIXTURE).qname()),
136            SeqPlatform::Ont
137        );
138    }
139
140    #[test]
141    fn qname_recovers_platform_after_aux_stripping() {
142        // Strip the discriminator aux tags; the read name must still classify.
143        let mut rec = first_record(ONT_FIXTURE);
144        for t in ONT_READ_TAGS {
145            rec.remove_aux(t).ok();
146        }
147        assert_eq!(platform_from_aux(&rec), SeqPlatform::Unknown);
148        assert_eq!(platform_from_record(&rec), SeqPlatform::Ont);
149
150        let mut rec = first_record("tests/data/revio.bam");
151        for t in PACBIO_READ_TAGS {
152            rec.remove_aux(t).ok();
153        }
154        assert_eq!(platform_from_aux(&rec), SeqPlatform::Unknown);
155        assert_eq!(platform_from_record(&rec), SeqPlatform::PacBio);
156    }
157
158    #[test]
159    fn qname_patterns_and_non_matches() {
160        assert_eq!(
161            platform_from_qname(b"m84081_231207_200206_s1/240456095/ccs"),
162            SeqPlatform::PacBio
163        );
164        assert_eq!(
165            platform_from_qname(b"m54329U_210810_004956/120523046/0_5000"),
166            SeqPlatform::PacBio
167        );
168        assert_eq!(
169            platform_from_qname(b"d78c9b31-cec7-4381-bc1c-5f1513d070b9"),
170            SeqPlatform::Ont
171        );
172        assert_eq!(platform_from_qname(b"read_12345"), SeqPlatform::Unknown);
173    }
174}