Skip to main content

ferritin_core/model/
conformation.rs

1//! Per-model coordinate data that varies across trajectory frames.
2//!
3//! [`AtomicConformation`] holds everything that differs between frames: Cartesian
4//! coordinates, occupancy, B-factors, and per-atom confidence scores. Topology
5//! (connectivity, sequence, etc.) lives in [`super::hierarchy::AtomicHierarchy`].
6
7use super::ModelError;
8
9/// Per-model coordinate data — varies across trajectory frames.
10///
11/// Coordinates are stored as struct-of-arrays (SoA): `x[i]`, `y[i]`, `z[i]`
12/// for atom `i`. This layout is cache-friendly for common operations that
13/// process one coordinate component at a time (e.g., computing pairwise
14/// distances, centroid calculations).
15///
16/// # Host-side only
17///
18/// Coordinates are plain `Vec<f32>` — no GPU tensors or device types.
19/// ferritin-core is intentionally framework-agnostic.
20#[derive(Clone, Debug)]
21pub struct AtomicConformation {
22    /// X coordinates (Å), one per atom.
23    pub x: Vec<f32>,
24    /// Y coordinates (Å), one per atom.
25    pub y: Vec<f32>,
26    /// Z coordinates (Å), one per atom.
27    pub z: Vec<f32>,
28    /// Occupancy for each atom (0.0–1.0), or `None` if not recorded.
29    pub occupancy: Option<Vec<f32>>,
30    /// Isotropic B-factor (temperature factor) for each atom, or `None`.
31    /// For predicted structures use [`Self::confidence`] instead to avoid semantic ambiguity.
32    pub b_iso: Option<Vec<f32>>,
33    /// Per-atom confidence score (e.g. pLDDT from AlphaFold / ESM3), or `None`.
34    ///
35    /// Kept separate from `b_iso` so callers can distinguish experimental
36    /// B-factors from model confidence without inspecting metadata.
37    pub confidence: Option<Vec<f32>>,
38}
39
40impl AtomicConformation {
41    /// Validate all per-atom columns against `n_atoms`.
42    pub fn validate(&self, n_atoms: usize) -> Result<(), ModelError> {
43        for (name, len) in [
44            ("x", self.x.len()),
45            ("y", self.y.len()),
46            ("z", self.z.len()),
47        ] {
48            if len != n_atoms {
49                return Err(ModelError::new(format!(
50                    "conformation.{name} has length {len}, expected {n_atoms}"
51                )));
52            }
53        }
54        for (name, values) in [
55            ("occupancy", self.occupancy.as_ref()),
56            ("b_iso", self.b_iso.as_ref()),
57            ("confidence", self.confidence.as_ref()),
58        ] {
59            if let Some(values) = values
60                && values.len() != n_atoms
61            {
62                return Err(ModelError::new(format!(
63                    "conformation.{name} has length {}, expected {n_atoms}",
64                    values.len()
65                )));
66            }
67        }
68        Ok(())
69    }
70    /// Number of atoms.
71    pub fn n_atoms(&self) -> usize {
72        self.x.len()
73    }
74
75    /// Get `[x, y, z]` for atom `i`.
76    ///
77    /// # Panics
78    /// Panics if `i >= n_atoms()`.
79    pub fn coord(&self, i: usize) -> [f32; 3] {
80        [self.x[i], self.y[i], self.z[i]]
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_conformation_coord() {
90        let conf = AtomicConformation {
91            x: vec![1.0, 4.0, 7.0],
92            y: vec![2.0, 5.0, 8.0],
93            z: vec![3.0, 6.0, 9.0],
94            occupancy: Some(vec![1.0, 0.5, 1.0]),
95            b_iso: None,
96            confidence: Some(vec![90.0, 85.0, 92.0]),
97        };
98
99        assert_eq!(conf.n_atoms(), 3);
100        assert_eq!(conf.coord(0), [1.0, 2.0, 3.0]);
101        assert_eq!(conf.coord(1), [4.0, 5.0, 6.0]);
102        assert_eq!(conf.coord(2), [7.0, 8.0, 9.0]);
103    }
104
105    #[test]
106    fn test_conformation_optional_fields() {
107        let conf = AtomicConformation {
108            x: vec![0.0],
109            y: vec![0.0],
110            z: vec![0.0],
111            occupancy: None,
112            b_iso: None,
113            confidence: None,
114        };
115        assert_eq!(conf.n_atoms(), 1);
116        assert_eq!(conf.coord(0), [0.0, 0.0, 0.0]);
117        assert!(conf.occupancy.is_none());
118        assert!(conf.b_iso.is_none());
119        assert!(conf.confidence.is_none());
120    }
121
122    #[test]
123    fn test_conformation_validation_rejects_mismatched_columns() {
124        let conf = AtomicConformation {
125            x: vec![0.0],
126            y: vec![],
127            z: vec![0.0],
128            occupancy: None,
129            b_iso: None,
130            confidence: None,
131        };
132        assert_eq!(
133            conf.validate(1).unwrap_err().to_string(),
134            "conformation.y has length 0, expected 1"
135        );
136    }
137}