Skip to main content

ferritin_plms/utils/
residue_constants.rs

1//! Residue chemistry constants for structure prediction.
2//!
3//! - [`chi_angles_atoms`] — 4-atom tuples defining each sidechain dihedral
4//! - [`atom14_to_atom37_for_residue`] — per-residue atom14 → atom37 slot mapping
5//!
6//! Core atom37/amino-acid constants live in `ferritin_core::info::{atom37, amino_acids}`
7//! and are re-exported here for backward compatibility.
8
9// Re-export core constants from ferritin-core so existing callers don't break.
10pub use ferritin_core::info::atom37::{
11    AAAtom, ATOM37_NAMES, NUM_ATOM37, NUM_RESIDUES, atom_order, atom37_index,
12};
13
14// ── Chi angle definitions ──────────────────────────────────────────────────
15
16/// Sidechain chi-angle atom tuples for a residue given by 3-letter code.
17///
18/// Returns a slice of `(a1, a2, a3, a4)` tuples, one per chi angle (chi1
19/// first). An empty slice means the residue has no rotatable sidechains.
20///
21/// Source: OpenFold / AlphaFold2 `residue_constants.py`.
22#[rustfmt::skip]
23pub fn chi_angles_atoms(res3: &str) -> &'static [(&'static str, &'static str, &'static str, &'static str)] {
24    match res3 {
25        "ALA" | "GLY" | "UNK" => &[],
26        "ARG" => &[
27            ("N","CA","CB","CG"), ("CA","CB","CG","CD"),
28            ("CB","CG","CD","NE"), ("CG","CD","NE","CZ"),
29        ],
30        "ASN" => &[
31            ("N","CA","CB","CG"), ("CA","CB","CG","OD1"),
32        ],
33        "ASP" => &[
34            ("N","CA","CB","CG"), ("CA","CB","CG","OD1"),
35        ],
36        "CYS" => &[
37            ("N","CA","CB","SG"),
38        ],
39        "GLN" => &[
40            ("N","CA","CB","CG"), ("CA","CB","CG","CD"), ("CB","CG","CD","OE1"),
41        ],
42        "GLU" => &[
43            ("N","CA","CB","CG"), ("CA","CB","CG","CD"), ("CB","CG","CD","OE1"),
44        ],
45        "HIS" => &[
46            ("N","CA","CB","CG"), ("CA","CB","CG","ND1"),
47        ],
48        "ILE" => &[
49            ("N","CA","CB","CG1"), ("CA","CB","CG1","CD1"),
50        ],
51        "LEU" => &[
52            ("N","CA","CB","CG"), ("CA","CB","CG","CD1"),
53        ],
54        "LYS" => &[
55            ("N","CA","CB","CG"), ("CA","CB","CG","CD"),
56            ("CB","CG","CD","CE"), ("CG","CD","CE","NZ"),
57        ],
58        "MET" => &[
59            ("N","CA","CB","CG"), ("CA","CB","CG","SD"), ("CB","CG","SD","CE"),
60        ],
61        "PHE" => &[
62            ("N","CA","CB","CG"), ("CA","CB","CG","CD1"),
63        ],
64        "PRO" => &[
65            ("N","CA","CB","CG"), ("CA","CB","CG","CD"),
66        ],
67        "SER" => &[
68            ("N","CA","CB","OG"),
69        ],
70        "THR" => &[
71            ("N","CA","CB","OG1"),
72        ],
73        "TRP" => &[
74            ("N","CA","CB","CG"), ("CA","CB","CG","CD1"),
75        ],
76        "TYR" => &[
77            ("N","CA","CB","CG"), ("CA","CB","CG","CD1"),
78        ],
79        "VAL" => &[
80            ("N","CA","CB","CG1"),
81        ],
82        _ => &[],
83    }
84}
85
86// ── atom14 → atom37 mapping ────────────────────────────────────────────────
87
88/// For a residue given by its 3-letter code, return the atom37 slot index for
89/// each of the 14 atom14 positions, or `None` if that slot is unoccupied.
90///
91/// The atom14 slot ordering matches the `Residue::atoms14()` method in
92/// `featurize::utilities`: [N, CA, C, O, CB, sidechain…].
93#[rustfmt::skip]
94pub fn atom14_to_atom37_for_residue(res3: &str) -> [Option<usize>; 14] {
95    let names: [Option<&str>; 14] = match res3 {
96        "ALA" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),None,None,None,None,None,None,None,None,None],
97        "CYS" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("SG"),None,None,None,None,None,None,None,None],
98        "ASP" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("OD1"),Some("OD2"),None,None,None,None,None,None],
99        "GLU" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD"),Some("OE1"),Some("OE2"),None,None,None,None,None],
100        "PHE" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD1"),Some("CD2"),Some("CE1"),Some("CE2"),Some("CZ"),None,None,None],
101        "GLY" => [Some("N"),Some("CA"),Some("C"),Some("O"),None,None,None,None,None,None,None,None,None,None],
102        "HIS" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("ND1"),Some("CD2"),Some("CE1"),Some("NE2"),None,None,None,None],
103        "ILE" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG1"),Some("CG2"),Some("CD1"),None,None,None,None,None,None],
104        "LYS" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD"),Some("CE"),Some("NZ"),None,None,None,None,None],
105        "LEU" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD1"),Some("CD2"),None,None,None,None,None,None],
106        "MET" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("SD"),Some("CE"),None,None,None,None,None,None],
107        "ASN" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("OD1"),Some("ND2"),None,None,None,None,None,None],
108        "PRO" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD"),None,None,None,None,None,None,None],
109        "GLN" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD"),Some("OE1"),Some("NE2"),None,None,None,None,None],
110        "ARG" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD"),Some("NE"),Some("CZ"),Some("NH1"),Some("NH2"),None,None,None],
111        "SER" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("OG"),None,None,None,None,None,None,None,None],
112        "THR" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("OG1"),Some("CG2"),None,None,None,None,None,None,None],
113        "VAL" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG1"),Some("CG2"),None,None,None,None,None,None,None],
114        "TRP" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD1"),Some("CD2"),Some("CE2"),Some("CE3"),Some("NE1"),Some("CZ2"),Some("CZ3"),Some("CH2")],
115        "TYR" => [Some("N"),Some("CA"),Some("C"),Some("O"),Some("CB"),Some("CG"),Some("CD1"),Some("CD2"),Some("CE1"),Some("CE2"),Some("CZ"),Some("OH"),None,None],
116        _     => [None; 14],
117    };
118
119    let mut out = [None; 14];
120    for (i, name_opt) in names.iter().enumerate() {
121        out[i] = name_opt.and_then(atom37_index);
122    }
123    out
124}
125
126// ── Tests ──────────────────────────────────────────────────────────────────
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_atom_order_completeness() {
134        let order = atom_order();
135        assert_eq!(order.len(), NUM_ATOM37);
136        assert_eq!(order["N"], 0);
137        assert_eq!(order["CA"], 1);
138        assert_eq!(order["C"], 2);
139        assert_eq!(order["CB"], 3);
140        assert_eq!(order["O"], 4);
141    }
142
143    #[test]
144    fn test_atom37_index_roundtrip() {
145        for (i, &name) in ATOM37_NAMES.iter().enumerate() {
146            assert_eq!(atom37_index(name), Some(i));
147        }
148        assert_eq!(atom37_index("ZZZ"), None);
149    }
150
151    #[test]
152    fn test_chi_gly_ala_empty() {
153        assert!(chi_angles_atoms("GLY").is_empty());
154        assert!(chi_angles_atoms("ALA").is_empty());
155    }
156
157    #[test]
158    fn test_chi_arg_four_angles() {
159        assert_eq!(chi_angles_atoms("ARG").len(), 4);
160        assert_eq!(chi_angles_atoms("ARG")[0], ("N", "CA", "CB", "CG"));
161    }
162
163    #[test]
164    fn test_chi_val_one_angle() {
165        let chis = chi_angles_atoms("VAL");
166        assert_eq!(chis.len(), 1);
167        assert_eq!(chis[0], ("N", "CA", "CB", "CG1"));
168    }
169
170    #[test]
171    fn test_atom14_to_atom37_gly() {
172        let mapping = atom14_to_atom37_for_residue("GLY");
173        assert_eq!(mapping[0], Some(0)); // N
174        assert_eq!(mapping[1], Some(1)); // CA
175        assert_eq!(mapping[2], Some(2)); // C
176        assert_eq!(mapping[3], Some(4)); // O
177        assert_eq!(mapping[4], None); // no CB for GLY
178        #[allow(clippy::needless_range_loop)] // asserting a fixed index range
179        for i in 4..14 {
180            assert_eq!(mapping[i], None, "GLY slot {i} should be None");
181        }
182    }
183
184    #[test]
185    fn test_atom14_to_atom37_ala() {
186        let mapping = atom14_to_atom37_for_residue("ALA");
187        assert_eq!(mapping[4], Some(3)); // CB → atom37 slot 3
188        assert_eq!(mapping[5], None);
189    }
190
191    #[test]
192    fn test_atom14_to_atom37_trp_full() {
193        let mapping = atom14_to_atom37_for_residue("TRP");
194        assert!(
195            mapping.iter().all(|m| m.is_some()),
196            "TRP should fill all 14 slots"
197        );
198    }
199
200    #[test]
201    fn test_atom14_to_atom37_unk() {
202        let mapping = atom14_to_atom37_for_residue("UNK");
203        assert!(mapping.iter().all(|m| m.is_none()));
204    }
205}