Skip to main content

ferritin_core/
atomcollection.rs

1//! AtomCollection
2//!
3//! An AtomCollection is primarily a group of atoms with some atomic properties like coordinates, element type
4//! and residue information. Additional data like bonds can be added post-instantiation.
5//! The data for residues within this collection can be iterated through. Other useful queries like inter-atomic
6//! distances are supported.
7use super::bonds::{Bond, BondOrder};
8use super::info::constants::get_bonds_canonical20;
9use super::views::chain::ChainView;
10use super::views::residue::ResidueView;
11use crate::data::Segmentation;
12use crate::info::elements::Element;
13use crate::model::tables::{AtomsTable, ChainsTable, ResidueGroup, ResiduesTable};
14use crate::model::{AtomicConformation, AtomicHierarchy, Bonds, Model};
15use compact_str::CompactString;
16use itertools::{Itertools, izip};
17use std::sync::Arc;
18
19/// Atom Collection
20///
21/// The core data structure of ferritin-core.
22///
23/// it strives to be simple, high performance, and extensible using
24/// traits.
25#[derive(Clone)]
26pub struct AtomCollection {
27    size: usize,
28    coords: Vec<[f32; 3]>,
29    res_ids: Vec<i32>,
30    // Residue/atom/chain names are short (≤5 chars) and highly repetitive across
31    // atoms, so they are stored as `CompactString` (24 bytes, inline — no heap
32    // allocation for these lengths) rather than `String` (heap alloc per atom).
33    res_names: Vec<CompactString>,
34    is_hetero: Vec<bool>,
35    elements: Vec<Element>,
36    atom_names: Vec<CompactString>,
37    chain_ids: Vec<CompactString>,
38    bonds: Option<Vec<Bond>>,
39    residue_start_indices: Option<Vec<usize>>,
40    chain_start_indices: Option<Vec<usize>>,
41    // Per-atom B-factor/pLDDT and occupancy, when available from the source Model.
42    // Optional because not every structure (e.g. hand-built test fixtures) carries them.
43    b_factor: Option<Vec<f32>>,
44    occupancy: Option<Vec<f32>>,
45}
46
47impl AtomCollection {
48    #[allow(clippy::too_many_arguments)] // parallel per-atom columns; grouping them is ferritin-100.8's job
49    pub fn new(
50        size: usize,
51        coords: Vec<[f32; 3]>,
52        res_ids: Vec<i32>,
53        res_names: Vec<String>,
54        is_hetero: Vec<bool>,
55        elements: Vec<Element>,
56        atom_names: Vec<String>,
57        chain_ids: Vec<String>,
58        bonds: Option<Vec<Bond>>,
59    ) -> Self {
60        Self::from_compact(
61            size,
62            coords,
63            res_ids,
64            res_names.into_iter().map(CompactString::from).collect(),
65            is_hetero,
66            elements,
67            atom_names.into_iter().map(CompactString::from).collect(),
68            chain_ids.into_iter().map(CompactString::from).collect(),
69            bonds,
70        )
71    }
72    /// Constructor over the internal `CompactString` name representation.
73    ///
74    /// Used by paths that already hold `CompactString` names (e.g. `filter`)
75    /// to avoid a needless round-trip through `String`.
76    #[allow(clippy::too_many_arguments)] // mirrors `new`; parallel per-atom columns
77    fn from_compact(
78        size: usize,
79        coords: Vec<[f32; 3]>,
80        res_ids: Vec<i32>,
81        res_names: Vec<CompactString>,
82        is_hetero: Vec<bool>,
83        elements: Vec<Element>,
84        atom_names: Vec<CompactString>,
85        chain_ids: Vec<CompactString>,
86        bonds: Option<Vec<Bond>>,
87    ) -> Self {
88        let mut ac = AtomCollection {
89            size,
90            coords,
91            res_ids,
92            res_names,
93            is_hetero,
94            elements,
95            atom_names,
96            chain_ids,
97            bonds,
98            residue_start_indices: None,
99            chain_start_indices: None,
100            b_factor: None,
101            occupancy: None,
102        };
103        ac.calculate_chain_indices();
104        ac
105    }
106
107    /// Attach per-atom B-factor (crystallographic) or pLDDT confidence values.
108    ///
109    /// Used by the `Uncertainty` and `PlddtConfidence` color themes.
110    ///
111    /// # Panics
112    /// Panics if `values.len() != self.get_size()`.
113    pub fn with_b_factor(mut self, values: Vec<f32>) -> Self {
114        assert_eq!(
115            values.len(),
116            self.size,
117            "b_factor length must equal atom count"
118        );
119        self.b_factor = Some(values);
120        self
121    }
122
123    /// Attach per-atom occupancy values (0.0-1.0). Used by the `Occupancy` color theme.
124    ///
125    /// # Panics
126    /// Panics if `values.len() != self.get_size()`.
127    pub fn with_occupancy(mut self, values: Vec<f32>) -> Self {
128        assert_eq!(
129            values.len(),
130            self.size,
131            "occupancy length must equal atom count"
132        );
133        self.occupancy = Some(values);
134        self
135    }
136
137    pub fn get_b_factor(&self, idx: usize) -> Option<f32> {
138        self.b_factor.as_ref().map(|v| v[idx])
139    }
140
141    pub fn get_b_factors(&self) -> Option<&[f32]> {
142        self.b_factor.as_deref()
143    }
144
145    pub fn get_occupancy(&self, idx: usize) -> Option<f32> {
146        self.occupancy.as_ref().map(|v| v[idx])
147    }
148
149    pub fn get_occupancies(&self) -> Option<&[f32]> {
150        self.occupancy.as_deref()
151    }
152    // Calculate and cache chain start indices
153    pub fn calculate_chain_indices(&mut self) {
154        if self.chain_start_indices.is_none() {
155            if self.residue_start_indices.is_none() {
156                let residue_starts = self.get_residue_starts();
157                self.residue_start_indices = Some(residue_starts);
158            }
159
160            // Get chain starts as residue indices
161            let residue_starts = self.residue_start_indices.as_ref().unwrap();
162            let chain_starts: Vec<usize> = self
163                .get_chain_starts()
164                .iter()
165                .map(|&atom_idx| {
166                    // Find the residue index that contains this atom
167
168                    residue_starts
169                        .iter()
170                        .enumerate()
171                        .rfind(|&(_, &res_start)| res_start <= atom_idx)
172                        .map(|(i, _)| i)
173                        .unwrap_or(0)
174                })
175                .collect();
176
177            self.chain_start_indices = Some(chain_starts);
178        }
179    }
180    pub fn connect_via_residue_names(&mut self) {
181        if self.bonds.is_some() {
182            println!("Bonds already in place. Not overwriting.");
183            return;
184        }
185        let aa_bond_info = get_bonds_canonical20();
186        let residue_starts = self.get_residue_starts();
187        let n_atoms = self.size;
188        let mut bonds = Vec::new();
189        for res_i in 0..residue_starts.len() - 1 {
190            let curr_start_i = residue_starts[res_i];
191            let next_start_i = residue_starts[res_i + 1];
192            if let Some(bond_dict_for_res) =
193                aa_bond_info.get(&self.res_names[curr_start_i].as_str())
194            {
195                for &(atom_name1, atom_name2, bond_type) in bond_dict_for_res {
196                    let atom_indices1: Vec<usize> = (curr_start_i..next_start_i)
197                        .filter(|&i| self.atom_names[i] == atom_name1)
198                        .collect();
199                    let atom_indices2: Vec<usize> = (curr_start_i..next_start_i)
200                        .filter(|&i| self.atom_names[i] == atom_name2)
201                        .collect();
202                    for &i in &atom_indices1 {
203                        for &j in &atom_indices2 {
204                            bonds.push(Bond::new(i as i32, j as i32, bond_type));
205                        }
206                    }
207                }
208            }
209        }
210        // Backbone C→N peptide bonds between consecutive residues on the same chain
211        for res_i in 0..residue_starts.len() - 1 {
212            let curr_start_i = residue_starts[res_i];
213            let next_start_i = residue_starts[res_i + 1];
214            // Skip if these residues are on different chains
215            if self.chain_ids[curr_start_i] != self.chain_ids[next_start_i] {
216                continue;
217            }
218            // Skip hetero residues (ligands, solvent)
219            if self.is_hetero[curr_start_i] || self.is_hetero[next_start_i] {
220                continue;
221            }
222            let next_end_i = residue_starts.get(res_i + 2).copied().unwrap_or(n_atoms);
223            let c_idx = (curr_start_i..next_start_i).find(|&i| self.atom_names[i] == "C");
224            let n_idx = (next_start_i..next_end_i).find(|&i| self.atom_names[i] == "N");
225            if let (Some(c), Some(n)) = (c_idx, n_idx) {
226                bonds.push(Bond::new(c as i32, n as i32, BondOrder::Single));
227            }
228        }
229        self.bonds = Some(bonds);
230    }
231    pub fn get_size(&self) -> usize {
232        self.size
233    }
234    pub fn get_atom_name(&self, idx: usize) -> &str {
235        &self.atom_names[idx]
236    }
237    pub fn get_bonds(&self) -> Option<&Vec<Bond>> {
238        self.bonds.as_ref()
239    }
240    pub fn get_chain_id(&self, idx: usize) -> &str {
241        &self.chain_ids[idx]
242    }
243    pub fn get_coord(&self, idx: usize) -> &[f32; 3] {
244        &self.coords[idx]
245    }
246    pub fn get_coords(&self) -> &Vec<[f32; 3]> {
247        self.coords.as_ref()
248    }
249    pub fn get_element(&self, idx: usize) -> &Element {
250        &self.elements[idx]
251    }
252    pub fn get_elements(&self) -> &Vec<Element> {
253        self.elements.as_ref()
254    }
255    pub fn get_is_hetero(&self, idx: usize) -> bool {
256        self.is_hetero[idx]
257    }
258    pub fn get_resnames(&self) -> &[CompactString] {
259        self.res_names.as_ref()
260    }
261    pub fn get_res_id(&self, idx: usize) -> &i32 {
262        &self.res_ids[idx]
263    }
264    pub fn get_resids(&self) -> &Vec<i32> {
265        self.res_ids.as_ref()
266    }
267    pub fn get_res_name(&self, idx: usize) -> &str {
268        &self.res_names[idx]
269    }
270    /// A new residue starts, either when the chain ID, residue ID,
271    /// insertion code or residue name changes from one to the next atom.
272    fn get_residue_starts(&self) -> Vec<usize> {
273        let mut starts = vec![0];
274
275        starts.extend(
276            izip!(&self.res_ids, &self.res_names, &self.chain_ids)
277                .tuple_windows()
278                .enumerate()
279                .filter_map(
280                    |(i, ((res_id1, name1, chain1), (res_id2, name2, chain2)))| {
281                        if res_id1 != res_id2 || name1 != name2 || chain1 != chain2 {
282                            Some(i + 1)
283                        } else {
284                            None
285                        }
286                    },
287                ),
288        );
289        starts
290    }
291    pub fn get_residue_start_indices(&self) -> Option<&Vec<usize>> {
292        self.residue_start_indices.as_ref()
293    }
294    /// A new chain starts when the chain ID changes from one atom to the next.
295    fn get_chain_starts(&self) -> Vec<usize> {
296        let mut starts = vec![0];
297        starts.extend(
298            self.chain_ids
299                .iter()
300                .tuple_windows()
301                .enumerate()
302                .filter_map(
303                    |(i, (chain1, chain2))| {
304                        if chain1 != chain2 { Some(i + 1) } else { None }
305                    },
306                ),
307        );
308        starts
309    }
310
311    /// Filter atoms using a boolean mask, returning a new `AtomCollection`.
312    ///
313    /// Bonds are remapped — only bonds where both endpoints survive the mask are kept,
314    /// with indices adjusted to the new compact numbering.
315    ///
316    /// # Panics
317    /// Panics if `mask.len() != self.size`.
318    pub fn filter(&self, mask: &[bool]) -> AtomCollection {
319        assert_eq!(mask.len(), self.size, "mask length must equal atom count");
320
321        // Build old-index → new-index map in one pass.
322        let mut next = 0usize;
323        let remap: Vec<Option<usize>> = mask
324            .iter()
325            .map(|&keep| {
326                if keep {
327                    let idx = next;
328                    next += 1;
329                    Some(idx)
330                } else {
331                    None
332                }
333            })
334            .collect();
335
336        let selected: Vec<usize> = remap
337            .iter()
338            .enumerate()
339            .filter_map(|(i, r)| r.map(|_| i))
340            .collect();
341
342        let coords: Vec<[f32; 3]> = selected.iter().map(|&i| self.coords[i]).collect();
343        let res_ids: Vec<i32> = selected.iter().map(|&i| self.res_ids[i]).collect();
344        let res_names: Vec<CompactString> = selected
345            .iter()
346            .map(|&i| self.res_names[i].clone())
347            .collect();
348        let is_hetero: Vec<bool> = selected.iter().map(|&i| self.is_hetero[i]).collect();
349        let elements: Vec<Element> = selected.iter().map(|&i| self.elements[i]).collect();
350        let atom_names: Vec<CompactString> = selected
351            .iter()
352            .map(|&i| self.atom_names[i].clone())
353            .collect();
354        let chain_ids: Vec<CompactString> = selected
355            .iter()
356            .map(|&i| self.chain_ids[i].clone())
357            .collect();
358
359        let bonds: Option<Vec<Bond>> = self.bonds.as_ref().map(|bonds| {
360            bonds
361                .iter()
362                .filter_map(|b| {
363                    let (a, b_idx) = b.get_atom_indices();
364                    match (remap[a as usize], remap[b_idx as usize]) {
365                        (Some(new_a), Some(new_b)) => {
366                            Some(Bond::new(new_a as i32, new_b as i32, b.get_order()))
367                        }
368                        _ => None,
369                    }
370                })
371                .collect()
372        });
373
374        let mut filtered = AtomCollection::from_compact(
375            next, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, bonds,
376        );
377        if let Some(values) = &self.b_factor {
378            filtered.b_factor = Some(selected.iter().map(|&i| values[i]).collect());
379        }
380        if let Some(values) = &self.occupancy {
381            filtered.occupancy = Some(selected.iter().map(|&i| values[i]).collect());
382        }
383        filtered
384    }
385
386    /// Build a boolean mask selecting atoms belonging to `chain_id`.
387    pub fn select_chain(&self, chain_id: &str) -> Vec<bool> {
388        self.chain_ids.iter().map(|c| c == chain_id).collect()
389    }
390
391    /// Build a boolean mask selecting hetero atoms (ligands, solvent, etc.).
392    pub fn select_hetero(&self) -> Vec<bool> {
393        self.is_hetero.clone()
394    }
395
396    /// Build a boolean mask selecting solvent atoms (HOH, WAT, H2O).
397    pub fn select_water(&self) -> Vec<bool> {
398        const WATER: [&str; 3] = ["HOH", "WAT", "H2O"];
399        self.res_names
400            .iter()
401            .map(|n| WATER.contains(&n.as_str()))
402            .collect()
403    }
404
405    /// Build a boolean mask selecting single-atom hetero residues that are not water (ions).
406    pub fn select_ion(&self) -> Vec<bool> {
407        let mut mask = vec![false; self.size];
408        for residue in self.iter_residues() {
409            if residue.is_ion() {
410                #[allow(clippy::needless_range_loop)] // index range spans one residue's atoms
411                for idx in residue.start_atom_idx..residue.end_atom_idx {
412                    mask[idx] = true;
413                }
414            }
415        }
416        mask
417    }
418
419    /// Build a boolean mask selecting backbone atoms (N, CA, C, O).
420    pub fn select_backbone(&self) -> Vec<bool> {
421        const BACKBONE: [&str; 4] = ["N", "CA", "C", "O"];
422        self.atom_names
423            .iter()
424            .map(|n| BACKBONE.contains(&n.as_str()))
425            .collect()
426    }
427
428    /// Build a boolean mask selecting atoms with `atom_name`.
429    pub fn select_atom_name(&self, atom_name: &str) -> Vec<bool> {
430        self.atom_names.iter().map(|n| n == atom_name).collect()
431    }
432
433    /// Build a boolean mask selecting residues with `res_name` (e.g. `"ALA"`).
434    pub fn select_residue_name(&self, res_name: &str) -> Vec<bool> {
435        self.res_names.iter().map(|n| n == res_name).collect()
436    }
437
438    pub fn iter_coords_and_elements(&self) -> impl Iterator<Item = (&[f32; 3], &Element)> {
439        izip!(&self.coords, &self.elements)
440    }
441
442    pub fn iter_chains(&self) -> impl Iterator<Item = ChainView<'_>> {
443        // Make sure indices are calculated
444        let chain_starts = match &self.chain_start_indices {
445            Some(indices) => indices.clone(),
446            None => Vec::new(),
447        };
448
449        (0..chain_starts.len()).map(move |i| {
450            let start_residue_idx = chain_starts[i];
451            let end_residue_idx = if i + 1 < chain_starts.len() {
452                chain_starts[i + 1]
453            } else {
454                // If it's the last chain, go to the end of the structure
455                match &self.residue_start_indices {
456                    Some(indices) => indices.len(),
457                    None => self.size,
458                }
459            };
460
461            ChainView {
462                data: self,
463                start_residue_idx,
464                end_residue_idx,
465            }
466        })
467    }
468    pub fn iter_residues(&self) -> impl Iterator<Item = ResidueView<'_>> {
469        let residue_starts = self.get_residue_starts();
470        let atom_size = self.get_size();
471        // Create a copy of the last element if it exists
472        // Generate pairs for all residues
473        let last_atom_idx = residue_starts.last().copied();
474        (0..residue_starts.len().saturating_sub(1))
475            .map(move |i| ResidueView::new(self, residue_starts[i], residue_starts[i + 1]))
476            .chain(last_atom_idx.map(|idx| ResidueView::new(self, idx, atom_size)))
477    }
478    /// Iterates over amino acid residues in the collection
479    ///
480    /// Returns a filtered iterator that only includes standard amino acid residues
481    pub fn iter_residues_aminoacid(&self) -> impl Iterator<Item = ResidueView<'_>> {
482        self.iter_residues()
483            .filter(|residue| residue.is_amino_acid())
484    }
485
486    /// Convert this AtomCollection to a Model representation.
487    ///
488    /// Lifts the flat per-atom vectors into the hierarchical Model structure
489    /// (AtomicHierarchy + AtomicConformation).
490    pub fn to_model(&self) -> Model {
491        let n_atoms = self.size;
492
493        let atoms = AtomsTable {
494            atom_name: self.atom_names.iter().map(|s| s.to_string()).collect(),
495            auth_atom_name: self.atom_names.iter().map(|s| s.to_string()).collect(),
496            element: self.elements.clone(),
497            alt_loc: vec![None; n_atoms],
498            formal_charge: vec![None; n_atoms],
499        };
500
501        let residue_starts = self.get_residue_starts();
502        let n_residues = residue_starts.len();
503        let mut comp_id = Vec::with_capacity(n_residues);
504        let mut label_seq_id = Vec::with_capacity(n_residues);
505        let mut auth_seq_id = Vec::with_capacity(n_residues);
506        let mut ins_code = Vec::with_capacity(n_residues);
507        let mut group = Vec::with_capacity(n_residues);
508
509        for &start in &residue_starts {
510            comp_id.push(self.res_names[start].to_string());
511            let res_id = self.res_ids[start];
512            label_seq_id.push(res_id);
513            auth_seq_id.push(res_id);
514            ins_code.push(None);
515            group.push(if self.is_hetero[start] {
516                ResidueGroup::NonPolymer
517            } else {
518                ResidueGroup::Polymer
519            });
520        }
521
522        let residues = ResiduesTable {
523            comp_id,
524            auth_comp_id: self
525                .get_residue_starts()
526                .iter()
527                .map(|&start| self.res_names[start].to_string())
528                .collect(),
529            label_seq_id,
530            auth_seq_id,
531            ins_code,
532            group,
533        };
534
535        let chain_starts = self.get_chain_starts();
536        let n_chains = chain_starts.len();
537        let mut label_asym_id = Vec::with_capacity(n_chains);
538        let mut auth_asym_id = Vec::with_capacity(n_chains);
539        let mut entity_id = Vec::with_capacity(n_chains);
540
541        for &start in &chain_starts {
542            let chain_id = self.chain_ids[start].to_string();
543            label_asym_id.push(chain_id.clone());
544            auth_asym_id.push(chain_id.clone());
545            entity_id.push(chain_id);
546        }
547
548        let chains = ChainsTable {
549            label_asym_id,
550            auth_asym_id,
551            entity_id,
552        };
553
554        let mut atom_offsets: Vec<u32> = residue_starts.iter().map(|&s| s as u32).collect();
555        atom_offsets.push(n_atoms as u32);
556        let atom_to_residue = Segmentation::from_offsets(atom_offsets);
557
558        let residue_offsets: Vec<u32> = chain_starts
559            .iter()
560            .map(|&atom_start| {
561                residue_starts
562                    .iter()
563                    .position(|&r| r == atom_start)
564                    .unwrap_or(0) as u32
565            })
566            .chain(std::iter::once(n_residues as u32))
567            .collect();
568        let residue_to_chain = Segmentation::from_offsets(residue_offsets);
569
570        let bonds = Bonds::from_unsorted(vec![], vec![], vec![], n_atoms);
571
572        let hierarchy = Arc::new(AtomicHierarchy {
573            atoms,
574            residues,
575            chains,
576            atom_to_residue,
577            residue_to_chain,
578            bonds,
579        });
580
581        let x: Vec<f32> = self.coords.iter().map(|c| c[0]).collect();
582        let y: Vec<f32> = self.coords.iter().map(|c| c[1]).collect();
583        let z: Vec<f32> = self.coords.iter().map(|c| c[2]).collect();
584
585        let conformation = AtomicConformation {
586            x,
587            y,
588            z,
589            occupancy: self.occupancy.clone(),
590            b_iso: self.b_factor.clone(),
591            confidence: None,
592        };
593
594        Model::new(hierarchy, conformation)
595    }
596}
597
598impl From<&Model> for AtomCollection {
599    /// Convert a Model to an AtomCollection.
600    ///
601    /// Regenerates per-atom vectors from the hierarchy and conformation.
602    fn from(model: &Model) -> Self {
603        let n_atoms = model.n_atoms();
604        let hierarchy = &model.hierarchy;
605        let conformation = &model.conformation;
606
607        let coords: Vec<[f32; 3]> = (0..n_atoms)
608            .map(|i| [conformation.x[i], conformation.y[i], conformation.z[i]])
609            .collect();
610
611        let mut res_ids = Vec::with_capacity(n_atoms);
612        let mut res_names = Vec::with_capacity(n_atoms);
613        let mut is_hetero = Vec::with_capacity(n_atoms);
614        let mut chain_ids = Vec::with_capacity(n_atoms);
615
616        for atom_idx in 0..n_atoms {
617            let res_idx = hierarchy.residue_of_atom(atom_idx);
618            let chain_idx = hierarchy.chain_of_residue(res_idx);
619
620            res_ids.push(hierarchy.residues.auth_seq_id[res_idx]);
621            res_names.push(hierarchy.residues.comp_id[res_idx].clone());
622            is_hetero.push(hierarchy.residues.group[res_idx] == ResidueGroup::NonPolymer);
623            chain_ids.push(hierarchy.chains.auth_asym_id[chain_idx].clone());
624        }
625
626        let elements = hierarchy.atoms.element.clone();
627
628        let atom_names = hierarchy.atoms.atom_name.clone();
629
630        let mut ac = AtomCollection::new(
631            n_atoms, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
632        );
633
634        // Predicted structures (AlphaFold/ESM3) carry per-atom confidence rather than a
635        // crystallographic B-factor; viz themes read both from the same slot, so prefer
636        // confidence when both are present.
637        if let Some(values) = conformation
638            .confidence
639            .clone()
640            .or_else(|| conformation.b_iso.clone())
641        {
642            ac = ac.with_b_factor(values);
643        }
644        if let Some(values) = conformation.occupancy.clone() {
645            ac = ac.with_occupancy(values);
646        }
647
648        ac
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    fn make_test_atom_collection() -> AtomCollection {
657        let coords = vec![
658            [1.0, 2.0, 3.0],
659            [4.0, 5.0, 6.0],
660            [7.0, 8.0, 9.0],
661            [10.0, 11.0, 12.0],
662            [13.0, 14.0, 15.0],
663        ];
664        let res_ids = vec![1, 1, 1, 2, 2];
665        let res_names = vec![
666            "ALA".into(),
667            "ALA".into(),
668            "ALA".into(),
669            "GLY".into(),
670            "GLY".into(),
671        ];
672        let is_hetero = vec![false, false, false, false, false];
673        let elements = vec![Element::N, Element::C, Element::C, Element::N, Element::C];
674        let atom_names = vec!["N".into(), "CA".into(), "C".into(), "N".into(), "CA".into()];
675        let chain_ids = vec!["A".into(), "A".into(), "A".into(), "A".into(), "A".into()];
676
677        AtomCollection::new(
678            5, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
679        )
680    }
681
682    #[test]
683    fn test_b_factor_and_occupancy_roundtrip_through_model() {
684        let original = make_test_atom_collection()
685            .with_b_factor(vec![10.0, 20.0, 30.0, 40.0, 50.0])
686            .with_occupancy(vec![1.0, 1.0, 0.5, 1.0, 0.3]);
687
688        let model = original.to_model();
689        assert_eq!(
690            model.conformation.b_iso.as_deref(),
691            Some(&[10.0, 20.0, 30.0, 40.0, 50.0][..])
692        );
693        assert_eq!(
694            model.conformation.occupancy.as_deref(),
695            Some(&[1.0, 1.0, 0.5, 1.0, 0.3][..])
696        );
697
698        let restored = AtomCollection::from(&model);
699        assert_eq!(
700            restored.get_b_factors(),
701            Some(&[10.0, 20.0, 30.0, 40.0, 50.0][..])
702        );
703        assert_eq!(
704            restored.get_occupancies(),
705            Some(&[1.0, 1.0, 0.5, 1.0, 0.3][..])
706        );
707    }
708
709    #[test]
710    fn test_confidence_preferred_over_b_iso_when_converting_from_model() {
711        let mut model = make_test_atom_collection().to_model();
712        model.conformation.b_iso = Some(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
713        model.conformation.confidence = Some(vec![90.0, 91.0, 92.0, 93.0, 94.0]);
714
715        let ac = AtomCollection::from(&model);
716        assert_eq!(
717            ac.get_b_factors(),
718            Some(&[90.0, 91.0, 92.0, 93.0, 94.0][..])
719        );
720    }
721
722    #[test]
723    fn test_filter_remaps_b_factor_and_occupancy() {
724        let ac = make_two_chain_collection()
725            .with_b_factor(vec![1.0, 2.0, 3.0, 4.0, 5.0])
726            .with_occupancy(vec![0.1, 0.2, 0.3, 0.4, 0.5]);
727        let mask = vec![true, false, true, false, true];
728        let filtered = ac.filter(&mask);
729        assert_eq!(filtered.get_b_factors(), Some(&[1.0, 3.0, 5.0][..]));
730        assert_eq!(filtered.get_occupancies(), Some(&[0.1, 0.3, 0.5][..]));
731    }
732
733    #[test]
734    fn test_atomcollection_to_model_roundtrip() {
735        let original = make_test_atom_collection();
736
737        let model = original.to_model();
738        let restored = AtomCollection::from(&model);
739
740        assert_eq!(original.get_size(), restored.get_size());
741
742        for i in 0..original.get_size() {
743            assert_eq!(
744                original.get_coord(i),
745                restored.get_coord(i),
746                "coord mismatch at {}",
747                i
748            );
749            assert_eq!(
750                original.get_res_id(i),
751                restored.get_res_id(i),
752                "res_id mismatch at {}",
753                i
754            );
755            assert_eq!(
756                original.get_res_name(i),
757                restored.get_res_name(i),
758                "res_name mismatch at {}",
759                i
760            );
761            assert_eq!(
762                original.get_atom_name(i),
763                restored.get_atom_name(i),
764                "atom_name mismatch at {}",
765                i
766            );
767            assert_eq!(
768                original.get_chain_id(i),
769                restored.get_chain_id(i),
770                "chain_id mismatch at {}",
771                i
772            );
773            assert_eq!(
774                original.get_is_hetero(i),
775                restored.get_is_hetero(i),
776                "is_hetero mismatch at {}",
777                i
778            );
779        }
780    }
781
782    #[test]
783    fn test_model_to_atomcollection_coords() {
784        let ac = make_test_atom_collection();
785        let model = ac.to_model();
786
787        let model_coords = model.coords_as_slice();
788        assert_eq!(model_coords.len(), ac.get_size());
789        #[allow(clippy::needless_range_loop)] // paired with get_coord(i), a method not an index
790        for i in 0..ac.get_size() {
791            assert_eq!(model_coords[i], *ac.get_coord(i), "coord mismatch at {}", i);
792        }
793
794        assert_eq!(model.x(), &[1.0, 4.0, 7.0, 10.0, 13.0]);
795        assert_eq!(model.y(), &[2.0, 5.0, 8.0, 11.0, 14.0]);
796        assert_eq!(model.z(), &[3.0, 6.0, 9.0, 12.0, 15.0]);
797    }
798
799    #[test]
800    fn test_model_hierarchy_structure() {
801        let ac = make_test_atom_collection();
802        let model = ac.to_model();
803
804        assert_eq!(model.n_atoms(), 5);
805        assert_eq!(model.n_residues(), 2);
806        assert_eq!(model.n_chains(), 1);
807
808        assert_eq!(model.hierarchy.atoms_in_residue(0), 0..3);
809        assert_eq!(model.hierarchy.atoms_in_residue(1), 3..5);
810
811        assert_eq!(model.hierarchy.residues_in_chain(0), 0..2);
812    }
813
814    #[test]
815    fn test_multi_chain_roundtrip() {
816        let coords = vec![[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]];
817        let res_ids = vec![1, 1, 10];
818        let res_names = vec!["ALA".into(), "ALA".into(), "GLY".into()];
819        let is_hetero = vec![false, false, true];
820        let elements = vec![Element::N, Element::C, Element::N];
821        let atom_names = vec!["N".into(), "CA".into(), "N".into()];
822        let chain_ids = vec!["A".into(), "A".into(), "B".into()];
823
824        let original = AtomCollection::new(
825            3, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
826        );
827
828        let model = original.to_model();
829        assert_eq!(model.n_chains(), 2);
830        assert_eq!(model.n_residues(), 2);
831
832        let restored = AtomCollection::from(&model);
833        assert_eq!(restored.get_chain_id(0), "A");
834        assert_eq!(restored.get_chain_id(2), "B");
835        assert!(restored.get_is_hetero(2));
836        assert!(!restored.get_is_hetero(0));
837    }
838
839    fn make_two_chain_collection() -> AtomCollection {
840        // Chain A: 3 atoms (N, CA, C), res 1 ALA
841        // Chain B: 2 atoms (N, CA), res 2 GLY
842        // Atom order: 0=A/N, 1=A/CA, 2=A/C, 3=B/N, 4=B/CA
843        let coords = vec![
844            [1., 0., 0.],
845            [2., 0., 0.],
846            [3., 0., 0.],
847            [4., 0., 0.],
848            [5., 0., 0.],
849        ];
850        let res_ids = vec![1, 1, 1, 2, 2];
851        let res_names: Vec<String> = ["ALA", "ALA", "ALA", "GLY", "GLY"]
852            .iter()
853            .map(|s| s.to_string())
854            .collect();
855        let is_hetero = vec![false, false, false, false, false];
856        let elements = vec![Element::N, Element::C, Element::C, Element::N, Element::C];
857        let atom_names: Vec<String> = ["N", "CA", "C", "N", "CA"]
858            .iter()
859            .map(|s| s.to_string())
860            .collect();
861        let chain_ids: Vec<String> = ["A", "A", "A", "B", "B"]
862            .iter()
863            .map(|s| s.to_string())
864            .collect();
865        let bonds = vec![
866            Bond::new(0, 1, BondOrder::Single),
867            Bond::new(1, 2, BondOrder::Single),
868            Bond::new(3, 4, BondOrder::Single),
869        ];
870        AtomCollection::new(
871            5,
872            coords,
873            res_ids,
874            res_names,
875            is_hetero,
876            elements,
877            atom_names,
878            chain_ids,
879            Some(bonds),
880        )
881    }
882
883    #[test]
884    fn test_filter_keeps_selected_atoms() {
885        let ac = make_two_chain_collection();
886        let mask = vec![true, false, true, false, true];
887        let filtered = ac.filter(&mask);
888        assert_eq!(filtered.get_size(), 3);
889        assert_eq!(filtered.get_coord(0), &[1., 0., 0.]);
890        assert_eq!(filtered.get_coord(1), &[3., 0., 0.]);
891        assert_eq!(filtered.get_coord(2), &[5., 0., 0.]);
892    }
893
894    #[test]
895    fn test_filter_remaps_bonds() {
896        let ac = make_two_chain_collection();
897        // Keep atoms 0,1,2 (chain A). Bond 0-1 and 1-2 survive; bond 3-4 is dropped.
898        let mask = vec![true, true, true, false, false];
899        let filtered = ac.filter(&mask);
900        let bonds = filtered.get_bonds().unwrap();
901        assert_eq!(bonds.len(), 2);
902        let indices: Vec<(i32, i32)> = bonds.iter().map(|b| b.get_atom_indices()).collect();
903        assert!(indices.contains(&(0, 1)));
904        assert!(indices.contains(&(1, 2)));
905    }
906
907    #[test]
908    fn test_filter_drops_cross_boundary_bonds() {
909        let ac = make_two_chain_collection();
910        // Keep only atoms 0 and 4 — bond 0-1 and 3-4 are cut; bond 3-4's 3 is missing.
911        let mask = vec![true, false, false, false, true];
912        let filtered = ac.filter(&mask);
913        assert_eq!(filtered.get_size(), 2);
914        let bonds = filtered.get_bonds().unwrap();
915        assert!(bonds.is_empty());
916    }
917
918    #[test]
919    fn test_select_chain() {
920        let ac = make_two_chain_collection();
921        let mask = ac.select_chain("B");
922        assert_eq!(mask, vec![false, false, false, true, true]);
923        let filtered = ac.filter(&mask);
924        assert_eq!(filtered.get_size(), 2);
925    }
926
927    #[test]
928    fn test_select_backbone() {
929        let ac = make_two_chain_collection();
930        let mask = ac.select_backbone();
931        // N, CA, C, N, CA — all 5 are backbone
932        assert_eq!(mask, vec![true, true, true, true, true]);
933    }
934
935    #[test]
936    fn test_select_atom_name() {
937        let ac = make_two_chain_collection();
938        let mask = ac.select_atom_name("CA");
939        assert_eq!(mask, vec![false, true, false, false, true]);
940    }
941
942    #[test]
943    fn test_select_residue_name() {
944        let ac = make_two_chain_collection();
945        let mask = ac.select_residue_name("ALA");
946        assert_eq!(mask, vec![true, true, true, false, false]);
947    }
948
949    #[test]
950    #[should_panic(expected = "mask length must equal atom count")]
951    fn test_filter_wrong_mask_length() {
952        let ac = make_test_atom_collection();
953        ac.filter(&[true, false]);
954    }
955
956    /// 5-atom collection: ALA(2 atoms, polymer), HOH(1 atom, hetero), ZN(1 atom, hetero), WAT(1 atom, hetero)
957    fn make_water_ion_collection() -> AtomCollection {
958        let coords = vec![
959            [1.0, 0.0, 0.0],
960            [2.0, 0.0, 0.0],
961            [3.0, 0.0, 0.0],
962            [4.0, 0.0, 0.0],
963            [5.0, 0.0, 0.0],
964        ];
965        let res_ids = vec![1, 1, 2, 3, 4];
966        let res_names: Vec<String> = vec!["ALA", "ALA", "HOH", "ZN", "WAT"]
967            .into_iter()
968            .map(|s| s.to_string())
969            .collect();
970        let is_hetero = vec![false, false, true, true, true];
971        let elements = vec![Element::N, Element::C, Element::O, Element::Zn, Element::O];
972        let atom_names: Vec<String> = vec!["N", "CA", "O", "ZN", "O"]
973            .into_iter()
974            .map(|s| s.to_string())
975            .collect();
976        let chain_ids: Vec<String> = vec!["A"; 5].into_iter().map(|s| s.to_string()).collect();
977        AtomCollection::new(
978            5, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
979        )
980    }
981
982    #[test]
983    fn test_is_water_hoh() {
984        let ac = make_water_ion_collection();
985        let mut residues = ac.iter_residues();
986        let _ala = residues.next().unwrap();
987        let hoh = residues.next().unwrap();
988        assert!(hoh.is_water());
989    }
990
991    #[test]
992    fn test_is_water_wat() {
993        let ac = make_water_ion_collection();
994        let mut residues = ac.iter_residues();
995        residues.next(); // ALA
996        residues.next(); // HOH
997        residues.next(); // ZN
998        let wat = residues.next().unwrap();
999        assert!(wat.is_water());
1000    }
1001
1002    #[test]
1003    fn test_is_water_negative() {
1004        let ac = make_water_ion_collection();
1005        let ala = ac.iter_residues().next().unwrap();
1006        assert!(!ala.is_water());
1007    }
1008
1009    #[test]
1010    fn test_is_ion_single_hetero_nonwater() {
1011        let ac = make_water_ion_collection();
1012        let mut residues = ac.iter_residues();
1013        residues.next(); // ALA
1014        residues.next(); // HOH
1015        let zn = residues.next().unwrap();
1016        assert!(zn.is_ion());
1017    }
1018
1019    #[test]
1020    fn test_is_ion_water_not_ion() {
1021        let ac = make_water_ion_collection();
1022        let mut residues = ac.iter_residues();
1023        residues.next(); // ALA
1024        let hoh = residues.next().unwrap();
1025        assert!(!hoh.is_ion(), "water should not be classified as ion");
1026    }
1027
1028    #[test]
1029    fn test_is_ion_protein_residue() {
1030        let ac = make_water_ion_collection();
1031        let ala = ac.iter_residues().next().unwrap();
1032        assert!(!ala.is_ion());
1033    }
1034
1035    #[test]
1036    fn test_is_ion_multi_atom_hetero() {
1037        // ALA is multi-atom hetero (2 atoms) — not an ion even if flagged hetero
1038        let coords = vec![[0.0; 3], [1.0; 3]];
1039        let res_ids = vec![1, 1];
1040        let res_names: Vec<String> = vec!["LIG", "LIG"]
1041            .into_iter()
1042            .map(|s| s.to_string())
1043            .collect();
1044        let is_hetero = vec![true, true];
1045        let elements = vec![Element::C, Element::O];
1046        let atom_names: Vec<String> = vec!["C1", "O1"]
1047            .into_iter()
1048            .map(|s| s.to_string())
1049            .collect();
1050        let chain_ids: Vec<String> = vec!["A", "A"].into_iter().map(|s| s.to_string()).collect();
1051        let ac = AtomCollection::new(
1052            2, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
1053        );
1054        let lig = ac.iter_residues().next().unwrap();
1055        assert!(
1056            !lig.is_ion(),
1057            "multi-atom hetero residue should not be an ion"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_select_water_mask() {
1063        let ac = make_water_ion_collection();
1064        let mask = ac.select_water();
1065        // ALA(2), HOH(1), ZN(1), WAT(1)
1066        assert_eq!(mask, vec![false, false, true, false, true]);
1067    }
1068
1069    #[test]
1070    fn test_select_ion_mask() {
1071        let ac = make_water_ion_collection();
1072        let mask = ac.select_ion();
1073        // ZN is at atom index 3
1074        assert_eq!(mask, vec![false, false, false, true, false]);
1075    }
1076
1077    #[test]
1078    fn test_long_names_preserved() {
1079        // mmCIF residue names can be up to 5 chars and label_asym_id can be
1080        // multi-character — the CompactString representation must preserve these
1081        // in full (a fixed [u8; 4] representation would have truncated them).
1082        let coords = vec![[0.0; 3], [1.0; 3]];
1083        let res_ids = vec![1, 1];
1084        let res_names: Vec<String> = vec!["AABCD", "AABCD"]
1085            .into_iter()
1086            .map(String::from)
1087            .collect();
1088        let is_hetero = vec![false, false];
1089        let elements = vec![Element::C, Element::O];
1090        let atom_names: Vec<String> = vec!["C1", "OXT"].into_iter().map(String::from).collect();
1091        let chain_ids: Vec<String> = vec!["AA", "AA"].into_iter().map(String::from).collect();
1092        let ac = AtomCollection::new(
1093            2, coords, res_ids, res_names, is_hetero, elements, atom_names, chain_ids, None,
1094        );
1095
1096        assert_eq!(ac.get_res_name(0), "AABCD");
1097        assert_eq!(ac.get_chain_id(0), "AA");
1098        assert_eq!(ac.get_atom_name(1), "OXT");
1099
1100        // filter carries the CompactString names through without truncation.
1101        let subset = ac.filter(&[false, true]);
1102        assert_eq!(subset.get_size(), 1);
1103        assert_eq!(subset.get_res_name(0), "AABCD");
1104        assert_eq!(subset.get_atom_name(0), "OXT");
1105        assert_eq!(subset.get_chain_id(0), "AA");
1106    }
1107
1108    #[test]
1109    fn test_compact_string_is_inline() {
1110        // Guard the memory-footprint intent: CompactString stores strings of this
1111        // length inline (no heap allocation), which is the whole point of ferritin-2pr.
1112        assert!(!CompactString::from("CA").is_heap_allocated());
1113        assert!(!CompactString::from("AABCD").is_heap_allocated());
1114    }
1115}