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;