Skip to main content

nlr_core/
motif.rs

1//! Single motif hit type.
2//!
3//! Key behavior:
4//! - Holds both protein coordinates (position, protein_sequence) and DNA coordinates (dna_start, dna_end, strand, frame);
5//! - `set_dna` maps protein coordinates to genomic coordinates (including reverse strand mirroring);
6//! - The sorting rule is asymmetric: forward strand sorted ascending by dna_start, reverse strand descending.
7
8use std::cmp::Ordering;
9
10use crate::signature_def::MotifId;
11use crate::strand::Strand;
12
13/// Format an `f64` as a short round-trippable decimal: plain notation when
14/// `1e-3 <= |d| < 1e7`, otherwise scientific notation with uppercase `E`.
15pub fn format_double_java(d: f64) -> String {
16    if d == 0.0 {
17        return "0.0".to_string();
18    }
19    if d.is_nan() {
20        return "NaN".to_string();
21    }
22    if d.is_infinite() {
23        return if d < 0.0 {
24            "-Infinity".to_string()
25        } else {
26            "Infinity".to_string()
27        };
28    }
29    let abs = d.abs();
30    if abs >= 1e-3 && abs < 1e7 {
31        format!("{}", d)
32    } else {
33        format!("{:e}", d).replace('e', "E")
34    }
35}
36
37/// A single motif hit.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Motif {
40    /// Motif id (1..=20).
41    pub id: MotifId,
42    /// Protein sequence id (translated fragment name, e.g. `chr1_0_frame+0`).
43    pub protein_sequence_id: String,
44    /// Protein coordinate (1-based semantics).
45    pub position: u64,
46    /// The actual matched amino acid sequence.
47    pub protein_sequence: String,
48    /// p-value.
49    pub pvalue: f64,
50    /// Score (always 0.0 during scanning; only read from TSV on import).
51    pub score: f64,
52    /// DNA sequence id (None when unset).
53    pub dna_sequence_id: Option<String>,
54    /// DNA start (0-based, leftmost; always < dna_end on both strands).
55    pub dna_start: u64,
56    /// DNA end (0-based, rightmost).
57    pub dna_end: u64,
58    /// Strand.
59    pub strand: Strand,
60    /// Reading frame 0/1/2.
61    pub frame: u8,
62    /// Whether DNA parameters have been set.
63    pub dna_parameters_set: bool,
64}
65
66impl Motif {
67    /// Construct a motif with only protein-side information.
68    pub fn new_protein(
69        id: MotifId,
70        protein_sequence_id: String,
71        position: u64,
72        protein_sequence: String,
73        pvalue: f64,
74    ) -> Self {
75        Motif {
76            id,
77            protein_sequence_id,
78            position,
79            protein_sequence,
80            pvalue,
81            score: 0.0,
82            dna_sequence_id: None,
83            dna_start: 0,
84            dna_end: 0,
85            strand: Strand::Forward,
86            frame: 0,
87            dna_parameters_set: false,
88        }
89    }
90
91    /// Set DNA coordinates from protein coordinates.
92    ///
93    /// - `offset`: 0-based start of the fragment on the chromosome;
94    /// - `fragment_length`: fragment length (needed for reverse-strand computation);
95    /// - `frame`: reading frame 0/1/2;
96    /// - `strand`: strand.
97    ///
98    /// Forward strand: `dna_start = (position-1)*3 + frame + offset`
99    /// Reverse strand: `dna_start = offset + frag_len - ((position+len-1)*3 + frame)`
100    ///
101    /// On both strands, `dna_start` is always leftmost and `dna_end` always rightmost (`dna_start < dna_end`).
102    pub fn set_dna(
103        &mut self,
104        dna_sequence_id: String,
105        offset: u64,
106        fragment_length: u64,
107        frame: u8,
108        strand: Strand,
109    ) {
110        // The stored position is 1-based; the protein sequence length equals the motif length.
111        let len = self.protein_sequence.len() as u64;
112        let pos_minus_1 = self.position.saturating_sub(1);
113        let (start, end) = match strand {
114            Strand::Forward => {
115                let s = pos_minus_1 * 3 + frame as u64 + offset;
116                let e = (self.position + len - 1) * 3 + frame as u64 + offset;
117                (s, e)
118            }
119            Strand::Reverse => {
120                let s = offset + fragment_length - ((self.position + len - 1) * 3 + frame as u64);
121                let e = offset + fragment_length - (pos_minus_1 * 3 + frame as u64);
122                (s, e)
123            }
124        };
125        self.dna_sequence_id = Some(dna_sequence_id);
126        self.dna_start = start;
127        self.dna_end = end;
128        self.strand = strand;
129        self.frame = frame;
130        self.dna_parameters_set = true;
131    }
132
133    /// Whether the sequence contains a stop codon `*`.
134    #[inline]
135    pub fn has_stop(&self) -> bool {
136        self.protein_sequence.contains('*')
137    }
138
139    /// Export a TSV string with 11 fields.
140    pub fn export_string(&self) -> String {
141        let mut s = format!(
142            "{}\t{}\t{}\t{}\t{}\t{}",
143            crate::signature_def::motif_id_str(self.id),
144            self.protein_sequence_id,
145            self.position,
146            self.protein_sequence,
147            format_double_java(self.pvalue),
148            format_double_java(self.score)
149        );
150        if self.dna_parameters_set {
151            s.push_str(&format!(
152                "\t{}\t{}\t{}\t{}\t{}",
153                self.dna_sequence_id.as_deref().unwrap_or(""),
154                self.dna_start,
155                self.dna_end,
156                self.strand.symbol(),
157                self.frame
158            ));
159        } else {
160            s.push_str("\t\t\t\t\t");
161        }
162        s
163    }
164
165    /// Deserialize from an exported TSV line (used for `-c` import).
166    pub fn from_export_line(line: &str) -> Option<Self> {
167        let cols: Vec<&str> = line.split('\t').collect();
168        if cols.len() < 6 {
169            return None;
170        }
171        // Parse the "motif_N" id.
172        let id = cols[0]
173            .trim_start_matches("motif_")
174            .parse::<MotifId>()
175            .ok()?;
176        let position: u64 = cols[2].parse().ok()?;
177        let pvalue: f64 = cols[4].parse().ok()?;
178        let score: f64 = cols[5].parse().ok()?;
179        let mut m = Motif::new_protein(
180            id,
181            cols[1].to_string(),
182            position,
183            cols[3].to_string(),
184            pvalue,
185        );
186        m.score = score;
187        // If the last 5 columns exist and are non-empty, restore DNA parameters.
188        if cols.len() >= 11 && !cols[6].is_empty() {
189            m.dna_sequence_id = Some(cols[6].to_string());
190            m.dna_start = cols[7].parse().ok()?;
191            m.dna_end = cols[8].parse().ok()?;
192            m.strand = if cols[9] == "-" {
193                Strand::Reverse
194            } else {
195                Strand::Forward
196            };
197            m.frame = cols[10].parse().ok()?;
198            m.dna_parameters_set = true;
199        }
200        Some(m)
201    }
202}
203
204/// Sort key: only participates in coordinate sorting when DNA parameters are set.
205/// (The actual sorting logic is in the Ord impl below; this comment documents the intent.)
206impl PartialOrd for Motif {
207    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
208        Some(self.cmp(other))
209    }
210}
211
212impl Eq for Motif {}
213
214impl Ord for Motif {
215    /// Asymmetric sorting rule.
216    ///
217    /// `dna_sequence_id` (chromosome id) determines the same group; only within a group does
218    /// sorting use strand and DNA start. Different groups are sorted by `protein_sequence_id`.
219    /// Do not use `protein_sequence_id` to determine group membership, because fragment ids
220    /// are nearly unique per motif.
221    fn cmp(&self, other: &Self) -> Ordering {
222        if self.dna_parameters_set && other.dna_parameters_set {
223            let self_id = self.dna_sequence_id.as_deref().unwrap_or("");
224            let other_id = other.dna_sequence_id.as_deref().unwrap_or("");
225            if self_id.eq_ignore_ascii_case(other_id) {
226                // Same group: first by strand (forward strand first), then by coordinate.
227                match (self.strand, other.strand) {
228                    (Strand::Forward, Strand::Reverse) => Ordering::Less,
229                    (Strand::Reverse, Strand::Forward) => Ordering::Greater,
230                    _ => match self.strand {
231                        Strand::Forward => self.dna_start.cmp(&other.dna_start),
232                        Strand::Reverse => other.dna_start.cmp(&self.dna_start),
233                    },
234                }
235            } else {
236                // Different groups: sort by fragment id.
237                self.protein_sequence_id.cmp(&other.protein_sequence_id)
238            }
239        } else {
240            // DNA unset: sort by protein id + position.
241            if self.protein_sequence_id.eq_ignore_ascii_case(&other.protein_sequence_id) {
242                self.position.cmp(&other.position)
243            } else {
244                self.protein_sequence_id.cmp(&other.protein_sequence_id)
245            }
246        }
247    }
248}