Skip to main content

ferritin_core/model/
hierarchy.rs

1//! Atomic hierarchy: topology layer shared across trajectory frames.
2//!
3//! [`AtomicHierarchy`] holds all structural data that does not change between
4//! trajectory frames (connectivity, sequence, chain/residue assignments).
5//! It is wrapped in [`std::sync::Arc`] so multiple [`super::model::Model`]s
6//! (frames) can share a single topology without cloning.
7
8use std::ops::Range;
9use crate::data::Segmentation;
10use super::tables::{AtomsTable, ResiduesTable, ChainsTable};
11use super::bonds::Bonds;
12
13/// Topology layer: all structural data that is constant across trajectory frames.
14///
15/// Wrap in `Arc<AtomicHierarchy>` so multiple `Model`s (frames) share one topology
16/// without duplication.
17///
18/// # Hierarchy
19///
20/// ```text
21/// Chain  ──┐
22///           ├── Residue ──┐
23///                          └── Atom
24/// ```
25///
26/// The `atom_to_residue` and `residue_to_chain` segmentations provide O(1)
27/// range queries and O(log n) reverse lookups.
28#[derive(Clone, Debug)]
29pub struct AtomicHierarchy {
30    /// Per-atom topology data.
31    pub atoms: AtomsTable,
32    /// Per-residue topology data.
33    pub residues: ResiduesTable,
34    /// Per-chain topology data.
35    pub chains: ChainsTable,
36    /// Segmentation mapping atom index → residue index.
37    ///
38    /// `atom_to_residue.segment(res_idx)` gives the range of atom indices
39    /// belonging to residue `res_idx`.
40    pub atom_to_residue: Segmentation,
41    /// Segmentation mapping residue index → chain index.
42    ///
43    /// `residue_to_chain.segment(chain_idx)` gives the range of residue indices
44    /// belonging to chain `chain_idx`.
45    pub residue_to_chain: Segmentation,
46    /// Bond connectivity.
47    pub bonds: Bonds,
48}
49
50impl AtomicHierarchy {
51    /// Total number of atoms.
52    pub fn n_atoms(&self) -> usize {
53        self.atoms.len()
54    }
55
56    /// Total number of residues.
57    pub fn n_residues(&self) -> usize {
58        self.residues.len()
59    }
60
61    /// Total number of chains.
62    pub fn n_chains(&self) -> usize {
63        self.chains.len()
64    }
65
66    /// Returns the range of atom indices belonging to residue `res_idx`.
67    ///
68    /// # Panics
69    /// Panics if `res_idx >= n_residues()`.
70    pub fn atoms_in_residue(&self, res_idx: usize) -> Range<usize> {
71        self.atom_to_residue.segment(res_idx)
72    }
73
74    /// Returns the range of residue indices belonging to chain `chain_idx`.
75    ///
76    /// # Panics
77    /// Panics if `chain_idx >= n_chains()`.
78    pub fn residues_in_chain(&self, chain_idx: usize) -> Range<usize> {
79        self.residue_to_chain.segment(chain_idx)
80    }
81
82    /// Returns the residue index that contains atom `atom_idx`.
83    ///
84    /// O(log n) binary search over residue boundaries.
85    ///
86    /// # Panics
87    /// Panics if `atom_idx >= n_atoms()`.
88    pub fn residue_of_atom(&self, atom_idx: usize) -> usize {
89        self.atom_to_residue.segment_of(atom_idx)
90    }
91
92    /// Returns the chain index that contains residue `res_idx`.
93    ///
94    /// O(log n) binary search over chain boundaries.
95    ///
96    /// # Panics
97    /// Panics if `res_idx >= n_residues()`.
98    pub fn chain_of_residue(&self, res_idx: usize) -> usize {
99        self.residue_to_chain.segment_of(res_idx)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::model::tables::ResidueGroup;
107
108    /// Build a test hierarchy: 3 chains, 10 residues (4+3+3), 30 atoms (3 per residue).
109    fn make_test_hierarchy() -> AtomicHierarchy {
110        // Chain 0: residues 0..4, Chain 1: residues 4..7, Chain 2: residues 7..10
111        // Each residue has 3 atoms.
112        let n_residues = 10;
113        let n_atoms = 30; // 3 per residue
114        let _n_chains = 3;
115
116        let atoms = AtomsTable {
117            atom_name: (0..n_atoms).map(|i| match i % 3 { 0 => "N", 1 => "CA", _ => "C" }.to_string()).collect(),
118            element: (0..n_atoms).map(|i| match i % 3 { 0 => "N", _ => "C" }.to_string()).collect(),
119            alt_loc: vec![None; n_atoms],
120            formal_charge: vec![None; n_atoms],
121        };
122
123        let residues = ResiduesTable {
124            comp_id: (0..n_residues).map(|i| format!("RES{}", i)).collect(),
125            label_seq_id: (0..n_residues as i32).collect(),
126            auth_seq_id: (1..=n_residues as i32).collect(),
127            ins_code: vec![None; n_residues],
128            group: vec![ResidueGroup::Polymer; n_residues],
129        };
130
131        let chains = ChainsTable {
132            label_asym_id: vec!["A".into(), "B".into(), "C".into()],
133            auth_asym_id:  vec!["A".into(), "B".into(), "C".into()],
134            entity_id:     vec!["1".into(), "2".into(), "3".into()],
135        };
136
137        // atom_to_residue: each residue has 3 atoms => offsets [0,3,6,...,30]
138        let atom_offsets: Vec<u32> = (0..=n_residues as u32).map(|i| i * 3).collect();
139        let atom_to_residue = Segmentation::from_offsets(atom_offsets);
140
141        // residue_to_chain: chain 0 has 4 residues, chain 1 has 3, chain 2 has 3
142        let residue_offsets: Vec<u32> = vec![0, 4, 7, 10];
143        let residue_to_chain = Segmentation::from_offsets(residue_offsets);
144
145        let bonds = Bonds::from_unsorted(vec![], vec![], vec![], n_atoms);
146
147        AtomicHierarchy { atoms, residues, chains, atom_to_residue, residue_to_chain, bonds }
148    }
149
150    #[test]
151    fn test_hierarchy_basic() {
152        let h = make_test_hierarchy();
153
154        assert_eq!(h.n_atoms(), 30);
155        assert_eq!(h.n_residues(), 10);
156        assert_eq!(h.n_chains(), 3);
157
158        // residue 0 -> atoms 0..3
159        assert_eq!(h.atoms_in_residue(0), 0..3);
160        // residue 4 -> atoms 12..15
161        assert_eq!(h.atoms_in_residue(4), 12..15);
162        // residue 9 -> atoms 27..30
163        assert_eq!(h.atoms_in_residue(9), 27..30);
164
165        // chain 0 -> residues 0..4
166        assert_eq!(h.residues_in_chain(0), 0..4);
167        // chain 1 -> residues 4..7
168        assert_eq!(h.residues_in_chain(1), 4..7);
169        // chain 2 -> residues 7..10
170        assert_eq!(h.residues_in_chain(2), 7..10);
171    }
172
173    #[test]
174    fn test_hierarchy_residue_of_atom() {
175        let h = make_test_hierarchy();
176
177        // atoms 0,1,2 -> residue 0
178        assert_eq!(h.residue_of_atom(0), 0);
179        assert_eq!(h.residue_of_atom(1), 0);
180        assert_eq!(h.residue_of_atom(2), 0);
181        // atoms 3,4,5 -> residue 1
182        assert_eq!(h.residue_of_atom(3), 1);
183        assert_eq!(h.residue_of_atom(5), 1);
184        // atom 29 -> residue 9
185        assert_eq!(h.residue_of_atom(29), 9);
186    }
187
188    #[test]
189    fn test_hierarchy_chain_of_residue() {
190        let h = make_test_hierarchy();
191
192        // residues 0..4 -> chain 0
193        for r in 0..4 {
194            assert_eq!(h.chain_of_residue(r), 0, "residue {} should be in chain 0", r);
195        }
196        // residues 4..7 -> chain 1
197        for r in 4..7 {
198            assert_eq!(h.chain_of_residue(r), 1, "residue {} should be in chain 1", r);
199        }
200        // residues 7..10 -> chain 2
201        for r in 7..10 {
202            assert_eq!(h.chain_of_residue(r), 2, "residue {} should be in chain 2", r);
203        }
204    }
205}