Skip to main content

koth_core/scoring/
model.rs

1//! Analyte-class isotope models: the average composition koth scores an isotope
2//! envelope against.
3//!
4//! Hill detection and chain assembly assume only that isotopes sit at
5//! charge-spaced m/z intervals. Scoring is the single place an analyte class
6//! enters, through an "averagine": an average residue composition scaled to a
7//! molecule's neutral mass (Senko 1995, for peptides). Carrying that
8//! composition as data rather than as constants is what lets a non-peptide
9//! analyte be scored against its own model.
10//!
11//! **Phosphorus is deliberately absent.** ³¹P is the only stable phosphorus
12//! isotope, so a phosphate contributes a delta at offset 0 and cannot shift an
13//! isotope *pattern*. The nucleic-acid models carry their P in `residue_mass`,
14//! which is what sets the scale, and nowhere else — excluding it from the
15//! convolution is exact, not an approximation.
16
17use serde::{Deserialize, Serialize};
18
19use super::elements::{cache, K_PATTERN};
20
21/// Average residue composition of an analyte class, expressed as element counts
22/// per `residue_mass` Da of neutral mass. Scaling these by
23/// `neutral_mass / residue_mass` and rounding gives the composition whose exact
24/// isotopologue distribution [`super::elements`] convolves.
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct IsotopeModel {
28    /// Mass of one average residue, in Da. The denominator of every ratio.
29    pub residue_mass: f64,
30    /// Carbon atoms per residue.
31    pub c: f64,
32    /// Hydrogen atoms per residue.
33    pub h: f64,
34    /// Nitrogen atoms per residue.
35    pub n: f64,
36    /// Oxygen atoms per residue.
37    pub o: f64,
38    /// Sulfur atoms per residue. Zero for the nucleic-acid models, which
39    /// disables sulfur-aware scoring (see [`IsotopeModel::has_sulfur`]).
40    #[serde(default)]
41    pub s: f64,
42}
43
44/// Peptide averagine — Senko's averaged amino acid, C₄.₉₃₈₄ H₇.₇₅₈₃ N₁.₃₅₇₇
45/// O₁.₄₇₇₃ S₀.₀₄₁₇ per 111.1254 Da. koth's default and the only model the
46/// published benchmark exercises.
47pub const PEPTIDE: IsotopeModel = IsotopeModel {
48    residue_mass: 111.1254,
49    c: 4.9384,
50    h: 7.7583,
51    n: 1.3577,
52    o: 1.4773,
53    s: 0.0417,
54};
55
56/// RNA — the mean of the four ribonucleotide chain residues (a nucleoside
57/// monophosphate less one water, the unit a phosphodiester chain repeats):
58///
59/// | residue | formula        | monoisotopic |
60/// |---------|----------------|--------------|
61/// | A       | C₁₀H₁₂N₅O₆P    | 329.05252    |
62/// | C       | C₉H₁₂N₃O₇P     | 305.04129    |
63/// | G       | C₁₀H₁₂N₅O₇P    | 345.04743    |
64/// | U       | C₉H₁₁N₂O₈P     | 306.02530    |
65///
66/// Unweighted mean: C₉.₅ H₁₁.₇₅ N₃.₇₅ O₇ (P₁) per 321.2916 Da. Unweighted
67/// because base composition is sequence-specific; weight it yourself with a
68/// custom model if you know the organism's rRNA composition.
69pub const RNA: IsotopeModel = IsotopeModel {
70    residue_mass: 321.2916,
71    c: 9.5,
72    h: 11.75,
73    n: 3.75,
74    o: 7.0,
75    s: 0.0,
76};
77
78/// DNA — the same construction over the four deoxyribonucleotide residues
79/// (dA C₁₀H₁₂N₅O₅P, dC C₉H₁₂N₃O₆P, dG C₁₀H₁₂N₅O₆P, dT C₁₀H₁₃N₂O₇P):
80/// C₉.₇₅ H₁₂.₂₅ N₃.₇₅ O₆ (P₁) per 308.8006 Da.
81pub const DNA: IsotopeModel = IsotopeModel {
82    residue_mass: 308.8006,
83    c: 9.75,
84    h: 12.25,
85    n: 3.75,
86    o: 6.0,
87    s: 0.0,
88};
89
90impl Default for IsotopeModel {
91    fn default() -> Self {
92        PEPTIDE
93    }
94}
95
96impl IsotopeModel {
97    /// Integer element counts (C, H, N, O, S) at `neutral_mass`. Half-up
98    /// rounding from the scaled floats.
99    pub fn counts(&self, neutral_mass: f64) -> (u32, u32, u32, u32, u32) {
100        let scale = neutral_mass.max(0.0) / self.residue_mass;
101        let round = |x: f64| -> u32 { (x + 0.5).floor().max(0.0) as u32 };
102        (
103            round(self.c * scale),
104            round(self.h * scale),
105            round(self.n * scale),
106            round(self.o * scale),
107            round(self.s * scale),
108        )
109    }
110
111    /// Theoretical isotope distribution at `neutral_mass`, normalized
112    /// `[p0, …, p9]`.
113    pub fn distribution(&self, neutral_mass: f64) -> [f64; K_PATTERN] {
114        let (c, h, n, o, s) = self.counts(neutral_mass);
115        cache().distribution(c, h, n, o, s)
116    }
117
118    /// As [`Self::distribution`] with the sulfur count overridden: C/H/N/O held
119    /// at their scaled values, S set to `n_s`.
120    pub fn distribution_with_sulfur(&self, neutral_mass: f64, n_s: u32) -> [f64; K_PATTERN] {
121        let (c, h, n, o, _) = self.counts(neutral_mass);
122        cache().distribution(c, h, n, o, n_s)
123    }
124
125    /// Expected sulfur count rounded **up** rather than half-up. Only the sulfur
126    /// term changes; C/H/N/O keep [`Self::counts`]' rounding. For the peptide
127    /// model (`s = 0.0417`, i.e. 3.7524e-4·M) the ceiling steps at M ≈ 2665 /
128    /// 5330 / 7995 Da.
129    pub fn sulfur_ceil(&self, neutral_mass: f64) -> u32 {
130        let scale = neutral_mass.max(0.0) / self.residue_mass;
131        (self.s * scale).ceil().max(0.0) as u32
132    }
133
134    /// Whether the model contains sulfur at all. A model that does not (RNA,
135    /// DNA) is scored against one template regardless of `sulfur_offsets`:
136    /// varying a count that is structurally zero would only hand every
137    /// candidate, decoys included, a free maximum over templates.
138    pub fn has_sulfur(&self) -> bool {
139        self.s > 0.0
140    }
141}
142
143/// The built-in analyte classes, selected by name in a config file.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum IsotopeClass {
147    /// [`PEPTIDE`] — the default.
148    Peptide,
149    /// [`RNA`].
150    Rna,
151    /// [`DNA`].
152    Dna,
153}
154
155/// What a config file may say for `isotope_model`: either a class name
156/// (`isotope_model = "rna"`) or an explicit composition table
157/// (`isotope_model = { residue_mass = 321.2916, c = 9.5, h = 11.75, n = 3.75,
158/// o = 7.0 }`). An unknown class name is a parse error, not a silent fallback.
159#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
160#[serde(untagged)]
161pub enum IsotopeModelSpec {
162    /// One of the built-in classes.
163    Class(IsotopeClass),
164    /// An explicit composition.
165    Custom(IsotopeModel),
166}
167
168impl Default for IsotopeModelSpec {
169    fn default() -> Self {
170        Self::Class(IsotopeClass::Peptide)
171    }
172}
173
174impl IsotopeModelSpec {
175    /// Resolve to the composition to score against.
176    pub fn model(&self) -> IsotopeModel {
177        match self {
178            Self::Class(IsotopeClass::Peptide) => PEPTIDE,
179            Self::Class(IsotopeClass::Rna) => RNA,
180            Self::Class(IsotopeClass::Dna) => DNA,
181            Self::Custom(m) => *m,
182        }
183    }
184}
185
186#[cfg(test)]
187#[path = "model_tests.rs"]
188mod tests;