Skip to main content

ferritin_core/model/
tables.rs

1//! Table structs for per-atom, per-residue, and per-chain data.
2//!
3//! These tables hold topology data that is constant across trajectory frames.
4//! All tables use struct-of-arrays (SoA) layout for cache-friendly iteration.
5
6use super::ModelError;
7use crate::info::elements::Element;
8
9/// Sentinel used when an mmCIF sequence identifier is explicitly missing (`.` or `?`).
10pub const MISSING_SEQ_ID: i32 = i32::MIN;
11
12fn require_len(table: &str, field: &str, actual: usize, expected: usize) -> Result<(), ModelError> {
13    if actual != expected {
14        return Err(ModelError::new(format!(
15            "{table}.{field} has length {actual}, expected {expected}"
16        )));
17    }
18    Ok(())
19}
20
21/// Residue classification: polymer (protein/nucleic acid) or non-polymer (ligand/solvent).
22#[derive(Clone, Debug, PartialEq)]
23pub enum ResidueGroup {
24    /// Polymer residue: part of a protein, DNA, or RNA chain.
25    Polymer,
26    /// Non-polymer residue: ligand, solvent, ion, etc.
27    NonPolymer,
28}
29
30/// Per-atom topology data (not coordinates — those live in
31/// [`AtomicConformation`][crate::model::AtomicConformation]).
32///
33/// Each field is a parallel array indexed by atom index.
34#[derive(Clone, Debug)]
35pub struct AtomsTable {
36    /// Canonical atom name (`label_atom_id` in mmCIF), e.g. "CA", "N", "CB".
37    pub atom_name: Vec<String>,
38    /// Author-assigned atom name (`auth_atom_id` in mmCIF).
39    pub auth_atom_name: Vec<String>,
40    /// Element type for each atom.
41    pub element: Vec<Element>,
42    /// Alternative location indicator (e.g. `Some('A')`), or `None`.
43    pub alt_loc: Vec<Option<char>>,
44    /// Formal charge, or `None` if unspecified.
45    pub formal_charge: Vec<Option<i8>>,
46}
47
48impl AtomsTable {
49    /// Validate that every atom column has the same length.
50    pub fn validate(&self) -> Result<(), ModelError> {
51        let n = self.atom_name.len();
52        require_len("atoms", "auth_atom_name", self.auth_atom_name.len(), n)?;
53        require_len("atoms", "element", self.element.len(), n)?;
54        require_len("atoms", "alt_loc", self.alt_loc.len(), n)?;
55        require_len("atoms", "formal_charge", self.formal_charge.len(), n)
56    }
57    /// Number of atoms.
58    pub fn len(&self) -> usize {
59        self.atom_name.len()
60    }
61
62    /// Returns `true` if the table contains no atoms.
63    pub fn is_empty(&self) -> bool {
64        self.atom_name.is_empty()
65    }
66}
67
68/// Per-residue topology data.
69///
70/// Each field is a parallel array indexed by residue index.
71#[derive(Clone, Debug)]
72pub struct ResiduesTable {
73    /// Canonical residue name (`label_comp_id`), e.g. "ALA", "HOH", "ATP".
74    pub comp_id: Vec<String>,
75    /// Author-assigned residue name (`auth_comp_id`).
76    pub auth_comp_id: Vec<String>,
77    /// Internal sequential residue index (label_seq_id in mmCIF).
78    /// [`MISSING_SEQ_ID`] represents an explicitly absent value.
79    pub label_seq_id: Vec<i32>,
80    /// Author-assigned sequence number (auth_seq_id in mmCIF).
81    /// Used as the primary sort key for canonical iteration order within a chain.
82    pub auth_seq_id: Vec<i32>,
83    /// Insertion code (e.g. `Some('A')`), or `None`. Secondary sort key for canonical order.
84    pub ins_code: Vec<Option<char>>,
85    /// Whether this residue belongs to a polymer or non-polymer group.
86    pub group: Vec<ResidueGroup>,
87}
88
89impl ResiduesTable {
90    /// Validate that every residue column has the same length.
91    pub fn validate(&self) -> Result<(), ModelError> {
92        let n = self.comp_id.len();
93        require_len("residues", "auth_comp_id", self.auth_comp_id.len(), n)?;
94        require_len("residues", "label_seq_id", self.label_seq_id.len(), n)?;
95        require_len("residues", "auth_seq_id", self.auth_seq_id.len(), n)?;
96        require_len("residues", "ins_code", self.ins_code.len(), n)?;
97        require_len("residues", "group", self.group.len(), n)
98    }
99    /// Number of residues.
100    pub fn len(&self) -> usize {
101        self.comp_id.len()
102    }
103
104    /// Returns `true` if the table contains no residues.
105    pub fn is_empty(&self) -> bool {
106        self.comp_id.is_empty()
107    }
108}
109
110/// Per-chain topology data.
111///
112/// Each field is a parallel array indexed by chain index.
113#[derive(Clone, Debug)]
114pub struct ChainsTable {
115    /// Internal chain identifier (label_asym_id in mmCIF).
116    pub label_asym_id: Vec<String>,
117    /// Author-assigned chain identifier (auth_asym_id in mmCIF).
118    pub auth_asym_id: Vec<String>,
119    /// Entity identifier this chain belongs to.
120    pub entity_id: Vec<String>,
121}
122
123impl ChainsTable {
124    /// Validate that every chain column has the same length.
125    pub fn validate(&self) -> Result<(), ModelError> {
126        let n = self.label_asym_id.len();
127        require_len("chains", "auth_asym_id", self.auth_asym_id.len(), n)?;
128        require_len("chains", "entity_id", self.entity_id.len(), n)
129    }
130    /// Number of chains.
131    pub fn len(&self) -> usize {
132        self.label_asym_id.len()
133    }
134
135    /// Returns `true` if the table contains no chains.
136    pub fn is_empty(&self) -> bool {
137        self.label_asym_id.is_empty()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_atoms_table_len() {
147        let table = AtomsTable {
148            atom_name: vec!["N".into(), "CA".into(), "C".into()],
149            auth_atom_name: vec!["N".into(), "CA".into(), "C".into()],
150            element: vec![Element::N, Element::C, Element::C],
151            alt_loc: vec![None, None, None],
152            formal_charge: vec![None, None, None],
153        };
154        assert_eq!(table.len(), 3);
155        assert!(!table.is_empty());
156
157        let empty = AtomsTable {
158            atom_name: vec![],
159            auth_atom_name: vec![],
160            element: vec![],
161            alt_loc: vec![],
162            formal_charge: vec![],
163        };
164        assert_eq!(empty.len(), 0);
165        assert!(empty.is_empty());
166    }
167
168    #[test]
169    fn test_residue_group_variants() {
170        let polymer = ResidueGroup::Polymer;
171        let non_polymer = ResidueGroup::NonPolymer;
172
173        assert_eq!(polymer, ResidueGroup::Polymer);
174        assert_eq!(non_polymer, ResidueGroup::NonPolymer);
175        assert_ne!(polymer, non_polymer);
176
177        // Verify Clone + Debug work
178        let cloned = polymer.clone();
179        assert_eq!(cloned, ResidueGroup::Polymer);
180        assert!(!format!("{:?}", non_polymer).is_empty());
181    }
182
183    #[test]
184    fn test_atoms_table_validation_rejects_mismatched_columns() {
185        let table = AtomsTable {
186            atom_name: vec!["CA".into()],
187            auth_atom_name: vec![],
188            element: vec![Element::C],
189            alt_loc: vec![None],
190            formal_charge: vec![None],
191        };
192        assert_eq!(
193            table.validate().unwrap_err().to_string(),
194            "atoms.auth_atom_name has length 0, expected 1"
195        );
196    }
197}