Skip to main content

molrs_core/
molgraph.rs

1//! Dynamic molecular graph for editing-oriented CRUD operations.
2//!
3//! [`MolGraph`] stores atoms (or beads), bonds, angles, and dihedrals in
4//! generational arenas ([`slotmap::SlotMap`]), providing O(1) insert / remove /
5//! lookup with stable handles that survive mutations.
6//!
7//! Every entity is a property bag ([`Atom`]): coordinates live as `"x"`, `"y"`,
8//! `"z"` keys, matching the Python `Entity(UserDict)` convention.
9//!
10//! # Examples
11//!
12//! ```
13//! use molrs_core::molgraph::{Atom, MolGraph};
14//!
15//! let mut g = MolGraph::new();
16//!
17//! let o = g.add_atom(Atom::xyz("O", 0.0, 0.0, 0.0));
18//! let h1 = g.add_atom(Atom::xyz("H", 0.96, 0.0, 0.0));
19//! let h2 = g.add_atom(Atom::xyz("H", -0.24, 0.93, 0.0));
20//!
21//! g.add_bond(o, h1).expect("add bond");
22//! g.add_bond(o, h2).expect("add bond");
23//! g.add_angle(h1, o, h2).expect("add angle");
24//!
25//! assert_eq!(g.n_atoms(), 3);
26//! assert_eq!(g.n_bonds(), 2);
27//! assert_eq!(g.n_angles(), 1);
28//!
29//! g.translate([1.0, 0.0, 0.0]);
30//! assert!((g.get_atom(o).expect("get atom").get_f64("x").unwrap() - 1.0).abs() < 1e-12);
31//! ```
32
33use std::collections::HashMap;
34use std::ops::{Index, IndexMut};
35
36use slotmap::{SlotMap, new_key_type};
37
38use super::block::Block;
39use super::frame::Frame;
40use crate::error::MolRsError;
41use crate::types::{F, I, U};
42
43// ---------------------------------------------------------------------------
44// PropValue
45// ---------------------------------------------------------------------------
46
47/// Heterogeneous property value stored in an [`Atom`].
48#[derive(Debug, Clone, PartialEq)]
49pub enum PropValue {
50    F64(f64),
51    Str(String),
52    Int(I),
53}
54
55impl From<f64> for PropValue {
56    fn from(v: f64) -> Self {
57        PropValue::F64(v)
58    }
59}
60impl From<I> for PropValue {
61    fn from(v: I) -> Self {
62        PropValue::Int(v)
63    }
64}
65impl From<&str> for PropValue {
66    fn from(v: &str) -> Self {
67        PropValue::Str(v.to_owned())
68    }
69}
70impl From<String> for PropValue {
71    fn from(v: String) -> Self {
72        PropValue::Str(v)
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Atom  (dynamic prop bag — also used for beads via `type Bead = Atom`)
78// ---------------------------------------------------------------------------
79
80/// A dynamic property bag representing an atom (or bead).
81///
82/// All data — including coordinates (`"x"`, `"y"`, `"z"`), element symbol,
83/// mass, charge, etc. — is stored as key-value pairs.
84#[derive(Debug, Clone, Default, PartialEq)]
85pub struct Atom {
86    props: HashMap<String, PropValue>,
87}
88
89impl Atom {
90    /// Create an empty atom.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Convenience: create an atom with symbol + xyz.
96    pub fn xyz(symbol: &str, x: f64, y: f64, z: f64) -> Self {
97        let mut a = Self::new();
98        a.set("element", symbol);
99        a.set("x", x);
100        a.set("y", y);
101        a.set("z", z);
102        a
103    }
104
105    // ---- dict-like API ----
106
107    /// Insert or update a property.
108    pub fn set(&mut self, key: &str, val: impl Into<PropValue>) {
109        self.props.insert(key.to_owned(), val.into());
110    }
111
112    /// Get a reference to a property value.
113    pub fn get(&self, key: &str) -> Option<&PropValue> {
114        self.props.get(key)
115    }
116
117    /// Get a mutable reference to a property value.
118    pub fn get_mut(&mut self, key: &str) -> Option<&mut PropValue> {
119        self.props.get_mut(key)
120    }
121
122    /// Try to read a property as `f64`.
123    pub fn get_f64(&self, key: &str) -> Option<f64> {
124        match self.props.get(key)? {
125            PropValue::F64(v) => Some(*v),
126            _ => None,
127        }
128    }
129
130    /// Try to read a property as `&str`.
131    pub fn get_str(&self, key: &str) -> Option<&str> {
132        match self.props.get(key)? {
133            PropValue::Str(s) => Some(s.as_str()),
134            _ => None,
135        }
136    }
137
138    /// Try to read a property as `I`.
139    pub fn get_int(&self, key: &str) -> Option<I> {
140        match self.props.get(key)? {
141            PropValue::Int(v) => Some(*v),
142            _ => None,
143        }
144    }
145
146    /// Check whether a key exists.
147    pub fn contains_key(&self, key: &str) -> bool {
148        self.props.contains_key(key)
149    }
150
151    /// Remove a property, returning its value if present.
152    pub fn remove(&mut self, key: &str) -> Option<PropValue> {
153        self.props.remove(key)
154    }
155
156    /// Iterate over all property keys.
157    pub fn keys(&self) -> impl Iterator<Item = &str> {
158        self.props.keys().map(|k| k.as_str())
159    }
160
161    /// Number of properties.
162    pub fn len(&self) -> usize {
163        self.props.len()
164    }
165
166    /// Whether there are no properties.
167    pub fn is_empty(&self) -> bool {
168        self.props.is_empty()
169    }
170}
171
172impl Index<&str> for Atom {
173    type Output = PropValue;
174    fn index(&self, key: &str) -> &Self::Output {
175        self.props
176            .get(key)
177            .unwrap_or_else(|| panic!("Atom does not contain key '{}'", key))
178    }
179}
180
181impl IndexMut<&str> for Atom {
182    fn index_mut(&mut self, key: &str) -> &mut Self::Output {
183        self.props
184            .get_mut(key)
185            .unwrap_or_else(|| panic!("Atom does not contain key '{}'", key))
186    }
187}
188
189/// Alias for coarse-grained usage — same type, different prop keys by convention.
190pub type Bead = Atom;
191
192// ---------------------------------------------------------------------------
193// Key types
194// ---------------------------------------------------------------------------
195
196new_key_type! {
197    /// Stable handle to an atom in a [`MolGraph`].
198    pub struct AtomId;
199    /// Stable handle to a bond in a [`MolGraph`].
200    pub struct BondId;
201    /// Stable handle to an angle in a [`MolGraph`].
202    pub struct AngleId;
203    /// Stable handle to a dihedral in a [`MolGraph`].
204    pub struct DihedralId;
205}
206
207// ---------------------------------------------------------------------------
208// Topology structs
209// ---------------------------------------------------------------------------
210
211/// A bond between two atoms.
212#[derive(Debug, Clone)]
213pub struct Bond {
214    pub atoms: [AtomId; 2],
215    pub props: HashMap<String, PropValue>,
216}
217
218/// An angle between three atoms (j is the central atom).
219#[derive(Debug, Clone)]
220pub struct Angle {
221    pub atoms: [AtomId; 3],
222    pub props: HashMap<String, PropValue>,
223}
224
225/// A dihedral between four atoms.
226#[derive(Debug, Clone)]
227pub struct Dihedral {
228    pub atoms: [AtomId; 4],
229    pub props: HashMap<String, PropValue>,
230}
231
232// ---------------------------------------------------------------------------
233// MolGraph
234// ---------------------------------------------------------------------------
235
236/// A dynamic molecular graph supporting ergonomic CRUD for atoms, bonds,
237/// angles, and dihedrals.
238#[derive(Debug, Clone)]
239pub struct MolGraph {
240    atoms: SlotMap<AtomId, Atom>,
241    bonds: SlotMap<BondId, Bond>,
242    angles: SlotMap<AngleId, Angle>,
243    dihedrals: SlotMap<DihedralId, Dihedral>,
244    adjacency: HashMap<AtomId, Vec<BondId>>,
245}
246
247impl Default for MolGraph {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253impl MolGraph {
254    /// Create an empty molecular graph.
255    pub fn new() -> Self {
256        Self {
257            atoms: SlotMap::with_key(),
258            bonds: SlotMap::with_key(),
259            angles: SlotMap::with_key(),
260            dihedrals: SlotMap::with_key(),
261            adjacency: HashMap::new(),
262        }
263    }
264
265    // =====================================================================
266    // Atom CRUD
267    // =====================================================================
268
269    /// Insert an atom, returning its stable handle.
270    pub fn add_atom(&mut self, atom: Atom) -> AtomId {
271        let id = self.atoms.insert(atom);
272        self.adjacency.insert(id, Vec::new());
273        id
274    }
275
276    /// Remove an atom and all incident bonds / angles / dihedrals.
277    pub fn remove_atom(&mut self, id: AtomId) -> Result<Atom, MolRsError> {
278        let atom = self
279            .atoms
280            .remove(id)
281            .ok_or_else(|| MolRsError::not_found("atom", format!("AtomId {:?}", id)))?;
282
283        // Collect incident bonds.
284        let incident: Vec<BondId> = self.adjacency.remove(&id).unwrap_or_default();
285        for bid in incident {
286            self.remove_bond_inner(bid, id);
287        }
288
289        // Remove angles referencing this atom.
290        let doomed_angles: Vec<AngleId> = self
291            .angles
292            .iter()
293            .filter(|(_, a)| a.atoms.contains(&id))
294            .map(|(aid, _)| aid)
295            .collect();
296        for aid in doomed_angles {
297            self.angles.remove(aid);
298        }
299
300        // Remove dihedrals referencing this atom.
301        let doomed_dihedrals: Vec<DihedralId> = self
302            .dihedrals
303            .iter()
304            .filter(|(_, d)| d.atoms.contains(&id))
305            .map(|(did, _)| did)
306            .collect();
307        for did in doomed_dihedrals {
308            self.dihedrals.remove(did);
309        }
310
311        Ok(atom)
312    }
313
314    /// Get a reference to an atom.
315    pub fn get_atom(&self, id: AtomId) -> Result<&Atom, MolRsError> {
316        self.atoms
317            .get(id)
318            .ok_or_else(|| MolRsError::not_found("atom", format!("AtomId {:?}", id)))
319    }
320
321    /// Get a mutable reference to an atom.
322    pub fn get_atom_mut(&mut self, id: AtomId) -> Result<&mut Atom, MolRsError> {
323        self.atoms
324            .get_mut(id)
325            .ok_or_else(|| MolRsError::not_found("atom", format!("AtomId {:?}", id)))
326    }
327
328    // =====================================================================
329    // Bond CRUD
330    // =====================================================================
331
332    /// Add a bond between two existing atoms.
333    pub fn add_bond(&mut self, a: AtomId, b: AtomId) -> Result<BondId, MolRsError> {
334        if !self.atoms.contains_key(a) {
335            return Err(MolRsError::not_found("atom", format!("AtomId {:?}", a)));
336        }
337        if !self.atoms.contains_key(b) {
338            return Err(MolRsError::not_found("atom", format!("AtomId {:?}", b)));
339        }
340        let bond = Bond {
341            atoms: [a, b],
342            props: HashMap::new(),
343        };
344        let bid = self.bonds.insert(bond);
345        self.adjacency.entry(a).or_default().push(bid);
346        self.adjacency.entry(b).or_default().push(bid);
347        Ok(bid)
348    }
349
350    /// Remove a bond and update the adjacency index.
351    pub fn remove_bond(&mut self, id: BondId) -> Result<Bond, MolRsError> {
352        let bond = self
353            .bonds
354            .remove(id)
355            .ok_or_else(|| MolRsError::not_found("bond", format!("BondId {:?}", id)))?;
356        for &aid in &bond.atoms {
357            if let Some(adj) = self.adjacency.get_mut(&aid) {
358                adj.retain(|bid| *bid != id);
359            }
360        }
361        Ok(bond)
362    }
363
364    /// Get a reference to a bond.
365    pub fn get_bond(&self, id: BondId) -> Result<&Bond, MolRsError> {
366        self.bonds
367            .get(id)
368            .ok_or_else(|| MolRsError::not_found("bond", format!("BondId {:?}", id)))
369    }
370
371    /// Get a mutable reference to a bond.
372    pub fn get_bond_mut(&mut self, id: BondId) -> Result<&mut Bond, MolRsError> {
373        self.bonds
374            .get_mut(id)
375            .ok_or_else(|| MolRsError::not_found("bond", format!("BondId {:?}", id)))
376    }
377
378    // internal: remove bond from adjacency for a specific atom being removed
379    fn remove_bond_inner(&mut self, bid: BondId, removed_atom: AtomId) {
380        if let Some(bond) = self.bonds.remove(bid) {
381            // Update adjacency for the *other* atom (the removed one's list is
382            // already being dropped).
383            for &aid in &bond.atoms {
384                if aid != removed_atom
385                    && let Some(adj) = self.adjacency.get_mut(&aid)
386                {
387                    adj.retain(|b| *b != bid);
388                }
389            }
390        }
391    }
392
393    // =====================================================================
394    // Angle CRUD
395    // =====================================================================
396
397    /// Add an angle (i-j-k, j central).
398    pub fn add_angle(&mut self, i: AtomId, j: AtomId, k: AtomId) -> Result<AngleId, MolRsError> {
399        for &atom_id in &[i, j, k] {
400            if !self.atoms.contains_key(atom_id) {
401                return Err(MolRsError::not_found(
402                    "atom",
403                    format!("AtomId {:?}", atom_id),
404                ));
405            }
406        }
407        let angle = Angle {
408            atoms: [i, j, k],
409            props: HashMap::new(),
410        };
411        Ok(self.angles.insert(angle))
412    }
413
414    /// Remove an angle.
415    pub fn remove_angle(&mut self, id: AngleId) -> Result<Angle, MolRsError> {
416        self.angles
417            .remove(id)
418            .ok_or_else(|| MolRsError::not_found("angle", format!("AngleId {:?}", id)))
419    }
420
421    /// Get a reference to an angle.
422    pub fn get_angle(&self, id: AngleId) -> Result<&Angle, MolRsError> {
423        self.angles
424            .get(id)
425            .ok_or_else(|| MolRsError::not_found("angle", format!("AngleId {:?}", id)))
426    }
427
428    // =====================================================================
429    // Dihedral CRUD
430    // =====================================================================
431
432    /// Add a dihedral (i-j-k-l).
433    pub fn add_dihedral(
434        &mut self,
435        i: AtomId,
436        j: AtomId,
437        k: AtomId,
438        l: AtomId,
439    ) -> Result<DihedralId, MolRsError> {
440        for &atom_id in &[i, j, k, l] {
441            if !self.atoms.contains_key(atom_id) {
442                return Err(MolRsError::not_found(
443                    "atom",
444                    format!("AtomId {:?}", atom_id),
445                ));
446            }
447        }
448        let dih = Dihedral {
449            atoms: [i, j, k, l],
450            props: HashMap::new(),
451        };
452        Ok(self.dihedrals.insert(dih))
453    }
454
455    /// Remove a dihedral.
456    pub fn remove_dihedral(&mut self, id: DihedralId) -> Result<Dihedral, MolRsError> {
457        self.dihedrals
458            .remove(id)
459            .ok_or_else(|| MolRsError::not_found("dihedral", format!("DihedralId {:?}", id)))
460    }
461
462    /// Get a reference to a dihedral.
463    pub fn get_dihedral(&self, id: DihedralId) -> Result<&Dihedral, MolRsError> {
464        self.dihedrals
465            .get(id)
466            .ok_or_else(|| MolRsError::not_found("dihedral", format!("DihedralId {:?}", id)))
467    }
468
469    // =====================================================================
470    // Iteration & Query
471    // =====================================================================
472
473    /// Iterate over all `(AtomId, &Atom)` pairs.
474    pub fn atoms(&self) -> impl Iterator<Item = (AtomId, &Atom)> {
475        self.atoms.iter()
476    }
477
478    /// Iterate over all `(BondId, &Bond)` pairs.
479    pub fn bonds(&self) -> impl Iterator<Item = (BondId, &Bond)> {
480        self.bonds.iter()
481    }
482
483    /// Iterate over all `(AngleId, &Angle)` pairs.
484    pub fn angles(&self) -> impl Iterator<Item = (AngleId, &Angle)> {
485        self.angles.iter()
486    }
487
488    /// Iterate over all `(DihedralId, &Dihedral)` pairs.
489    pub fn dihedrals(&self) -> impl Iterator<Item = (DihedralId, &Dihedral)> {
490        self.dihedrals.iter()
491    }
492
493    /// Number of atoms.
494    pub fn n_atoms(&self) -> usize {
495        self.atoms.len()
496    }
497    /// Number of bonds.
498    pub fn n_bonds(&self) -> usize {
499        self.bonds.len()
500    }
501    /// Number of angles.
502    pub fn n_angles(&self) -> usize {
503        self.angles.len()
504    }
505    /// Number of dihedrals.
506    pub fn n_dihedrals(&self) -> usize {
507        self.dihedrals.len()
508    }
509
510    /// Iterate over neighbor atom IDs of a given atom (via bond connectivity).
511    pub fn neighbors(&self, id: AtomId) -> impl Iterator<Item = AtomId> + '_ {
512        self.adjacency
513            .get(&id)
514            .into_iter()
515            .flat_map(|bonds| bonds.iter())
516            .filter_map(move |&bid| {
517                let bond = self.bonds.get(bid)?;
518                let other = if bond.atoms[0] == id {
519                    bond.atoms[1]
520                } else {
521                    bond.atoms[0]
522                };
523                Some(other)
524            })
525    }
526
527    /// Iterate over `(neighbor_id, bond_order)` for a given atom.
528    ///
529    /// Bond order is read from the `"order"` property (default 1.0).
530    pub fn neighbor_bonds(&self, id: AtomId) -> impl Iterator<Item = (AtomId, f64)> + '_ {
531        self.adjacency
532            .get(&id)
533            .into_iter()
534            .flat_map(|bonds| bonds.iter())
535            .filter_map(move |&bid| {
536                let bond = self.bonds.get(bid)?;
537                let other = if bond.atoms[0] == id {
538                    bond.atoms[1]
539                } else {
540                    bond.atoms[0]
541                };
542                let order = match bond.props.get("order") {
543                    Some(PropValue::F64(v)) => *v,
544                    _ => 1.0,
545                };
546                Some((other, order))
547            })
548    }
549
550    // =====================================================================
551    // Spatial transforms
552    // =====================================================================
553
554    /// Translate all atoms that have `x`/`y`/`z` props.
555    pub fn translate(&mut self, delta: [f64; 3]) {
556        for (_, atom) in self.atoms.iter_mut() {
557            let keys = ["x", "y", "z"];
558            for (i, key) in keys.iter().enumerate() {
559                if let Some(PropValue::F64(v)) = atom.get_mut(key) {
560                    *v += delta[i];
561                }
562            }
563        }
564    }
565
566    /// Rotate all atoms that have `x`/`y`/`z` props around `axis` by `angle`
567    /// (radians), optionally about a center point.
568    pub fn rotate(&mut self, axis: [f64; 3], angle: f64, about: Option<[f64; 3]>) {
569        // Normalize axis
570        let len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
571        if len < 1e-15 {
572            return;
573        }
574        let k = [axis[0] / len, axis[1] / len, axis[2] / len];
575        let cos_a = angle.cos();
576        let sin_a = angle.sin();
577        let origin = about.unwrap_or([0.0, 0.0, 0.0]);
578
579        for (_, atom) in self.atoms.iter_mut() {
580            let (Some(PropValue::F64(x)), Some(PropValue::F64(y)), Some(PropValue::F64(z))) =
581                (atom.get("x"), atom.get("y"), atom.get("z"))
582            else {
583                continue;
584            };
585            let p = [x - origin[0], y - origin[1], z - origin[2]];
586
587            // Rodrigues' rotation formula: v' = v cos θ + (k × v) sin θ + k (k·v)(1 − cos θ)
588            let kdotp = k[0] * p[0] + k[1] * p[1] + k[2] * p[2];
589            let cross = [
590                k[1] * p[2] - k[2] * p[1],
591                k[2] * p[0] - k[0] * p[2],
592                k[0] * p[1] - k[1] * p[0],
593            ];
594            let rotated = [
595                p[0] * cos_a + cross[0] * sin_a + k[0] * kdotp * (1.0 - cos_a) + origin[0],
596                p[1] * cos_a + cross[1] * sin_a + k[1] * kdotp * (1.0 - cos_a) + origin[1],
597                p[2] * cos_a + cross[2] * sin_a + k[2] * kdotp * (1.0 - cos_a) + origin[2],
598            ];
599
600            atom.set("x", rotated[0]);
601            atom.set("y", rotated[1]);
602            atom.set("z", rotated[2]);
603        }
604    }
605
606    // =====================================================================
607    // Composition
608    // =====================================================================
609
610    /// Merge another `MolGraph` into `self`, consuming `other`.
611    /// All IDs in `other` are remapped to new IDs in `self`.
612    pub fn merge(&mut self, other: MolGraph) {
613        let mut atom_map: HashMap<AtomId, AtomId> = HashMap::new();
614
615        // Transfer atoms.
616        for (old_id, atom) in other.atoms {
617            let new_id = self.add_atom(atom);
618            atom_map.insert(old_id, new_id);
619        }
620
621        // Transfer bonds (remap atom IDs).
622        for (_, mut bond) in other.bonds {
623            let a = atom_map[&bond.atoms[0]];
624            let b = atom_map[&bond.atoms[1]];
625            bond.atoms = [a, b];
626            let bid = self.bonds.insert(bond);
627            self.adjacency.entry(a).or_default().push(bid);
628            self.adjacency.entry(b).or_default().push(bid);
629        }
630
631        // Transfer angles.
632        for (_, mut angle) in other.angles {
633            angle.atoms = [
634                atom_map[&angle.atoms[0]],
635                atom_map[&angle.atoms[1]],
636                atom_map[&angle.atoms[2]],
637            ];
638            self.angles.insert(angle);
639        }
640
641        // Transfer dihedrals.
642        for (_, mut dih) in other.dihedrals {
643            dih.atoms = [
644                atom_map[&dih.atoms[0]],
645                atom_map[&dih.atoms[1]],
646                atom_map[&dih.atoms[2]],
647                atom_map[&dih.atoms[3]],
648            ];
649            self.dihedrals.insert(dih);
650        }
651    }
652
653    // =====================================================================
654    // Frame conversion
655    // =====================================================================
656
657    /// Export to a [`Frame`]. Each unique prop key becomes a column in the
658    /// `"atoms"` block. Bonds, angles, dihedrals become separate blocks with
659    /// 0-based indices referencing atom row order.
660    pub fn to_frame(&self) -> Frame {
661        use ndarray::Array1;
662
663        let mut frame = Frame::new();
664
665        // Build stable ordering of atom IDs.
666        let atom_ids: Vec<AtomId> = self.atoms.keys().collect();
667        let n = atom_ids.len();
668        let id_to_row: HashMap<AtomId, usize> = atom_ids
669            .iter()
670            .enumerate()
671            .map(|(i, &id)| (id, i))
672            .collect();
673
674        // Collect all unique prop keys across all atoms.
675        let mut all_keys: Vec<String> = {
676            let mut set = std::collections::BTreeSet::new();
677            for (_, atom) in &self.atoms {
678                for k in atom.keys() {
679                    set.insert(k.to_owned());
680                }
681            }
682            set.into_iter().collect()
683        };
684        all_keys.sort();
685
686        // Build atoms block: one column per key.
687        let mut atoms_block = Block::new();
688        for key in &all_keys {
689            // Determine type from first non-None value.
690            let first_val = atom_ids
691                .iter()
692                .filter_map(|&id| self.atoms.get(id).and_then(|a| a.get(key)))
693                .next();
694
695            match first_val {
696                Some(PropValue::F64(_)) => {
697                    let col: Vec<F> = atom_ids
698                        .iter()
699                        .map(|&id| {
700                            self.atoms
701                                .get(id)
702                                .and_then(|a| a.get_f64(key))
703                                .unwrap_or(0.0) as F
704                        })
705                        .collect();
706                    let _ = atoms_block.insert(key.as_str(), Array1::from_vec(col).into_dyn());
707                }
708                Some(PropValue::Int(_)) => {
709                    let col: Vec<I> = atom_ids
710                        .iter()
711                        .map(|&id| self.atoms.get(id).and_then(|a| a.get_int(key)).unwrap_or(0))
712                        .collect();
713                    let _ = atoms_block.insert(key.as_str(), Array1::from_vec(col).into_dyn());
714                }
715                Some(PropValue::Str(_)) => {
716                    let col: Vec<String> = atom_ids
717                        .iter()
718                        .map(|&id| {
719                            self.atoms
720                                .get(id)
721                                .and_then(|a| a.get_str(key))
722                                .unwrap_or("")
723                                .to_owned()
724                        })
725                        .collect();
726                    let _ = atoms_block.insert(key.as_str(), Array1::from_vec(col).into_dyn());
727                }
728                _ => {}
729            }
730        }
731        if n > 0 {
732            frame.insert("atoms", atoms_block);
733        }
734
735        // Bonds block.
736        if !self.bonds.is_empty() {
737            let mut bonds_block = Block::new();
738            let mut col_i: Vec<U> = Vec::with_capacity(self.bonds.len());
739            let mut col_j: Vec<U> = Vec::with_capacity(self.bonds.len());
740            let mut col_order: Vec<F> = Vec::with_capacity(self.bonds.len());
741            for (_, bond) in &self.bonds {
742                col_i.push(id_to_row[&bond.atoms[0]] as U);
743                col_j.push(id_to_row[&bond.atoms[1]] as U);
744                let order = bond
745                    .props
746                    .get("order")
747                    .and_then(|v| {
748                        if let PropValue::F64(f) = v {
749                            Some(*f as F)
750                        } else {
751                            None
752                        }
753                    })
754                    .unwrap_or(1.0);
755                col_order.push(order);
756            }
757            let _ = bonds_block.insert("atomi", Array1::from_vec(col_i).into_dyn());
758            let _ = bonds_block.insert("atomj", Array1::from_vec(col_j).into_dyn());
759            let _ = bonds_block.insert("order", Array1::from_vec(col_order).into_dyn());
760            frame.insert("bonds", bonds_block);
761        }
762
763        // Angles block.
764        if !self.angles.is_empty() {
765            let mut angles_block = Block::new();
766            let mut ci: Vec<U> = Vec::with_capacity(self.angles.len());
767            let mut cj: Vec<U> = Vec::with_capacity(self.angles.len());
768            let mut ck: Vec<U> = Vec::with_capacity(self.angles.len());
769            for (_, angle) in &self.angles {
770                ci.push(id_to_row[&angle.atoms[0]] as U);
771                cj.push(id_to_row[&angle.atoms[1]] as U);
772                ck.push(id_to_row[&angle.atoms[2]] as U);
773            }
774            let _ = angles_block.insert("atomi", Array1::from_vec(ci).into_dyn());
775            let _ = angles_block.insert("atomj", Array1::from_vec(cj).into_dyn());
776            let _ = angles_block.insert("atomk", Array1::from_vec(ck).into_dyn());
777            frame.insert("angles", angles_block);
778        }
779
780        // Dihedrals block.
781        if !self.dihedrals.is_empty() {
782            let mut dih_block = Block::new();
783            let mut ci: Vec<U> = Vec::with_capacity(self.dihedrals.len());
784            let mut cj: Vec<U> = Vec::with_capacity(self.dihedrals.len());
785            let mut ck: Vec<U> = Vec::with_capacity(self.dihedrals.len());
786            let mut cl: Vec<U> = Vec::with_capacity(self.dihedrals.len());
787            for (_, d) in &self.dihedrals {
788                ci.push(id_to_row[&d.atoms[0]] as U);
789                cj.push(id_to_row[&d.atoms[1]] as U);
790                ck.push(id_to_row[&d.atoms[2]] as U);
791                cl.push(id_to_row[&d.atoms[3]] as U);
792            }
793            let _ = dih_block.insert("atomi", Array1::from_vec(ci).into_dyn());
794            let _ = dih_block.insert("atomj", Array1::from_vec(cj).into_dyn());
795            let _ = dih_block.insert("atomk", Array1::from_vec(ck).into_dyn());
796            let _ = dih_block.insert("atoml", Array1::from_vec(cl).into_dyn());
797            frame.insert("dihedrals", dih_block);
798        }
799
800        frame
801    }
802
803    /// Import from a [`Frame`]. The `"atoms"` block columns become props;
804    /// `"bonds"` block `atomi`/`atomj` columns become bonds.
805    pub fn from_frame(frame: &Frame) -> Result<Self, MolRsError> {
806        let mut g = MolGraph::new();
807
808        let atoms_block = frame
809            .get("atoms")
810            .ok_or_else(|| MolRsError::parse("Frame missing 'atoms' block"))?;
811
812        let nrows = atoms_block.nrows().unwrap_or(0);
813
814        // Collect column keys.
815        let col_keys: Vec<String> = atoms_block.keys().map(|k| k.to_owned()).collect();
816
817        // Pre-read columns by type.
818        let mut float_cols: Vec<(&str, &ndarray::ArrayD<F>)> = Vec::new();
819        let mut i64_cols: Vec<(&str, &ndarray::ArrayD<I>)> = Vec::new();
820        let mut str_cols: Vec<(&str, &ndarray::ArrayD<String>)> = Vec::new();
821
822        for key in &col_keys {
823            if let Some(arr) = atoms_block.get_float(key) {
824                float_cols.push((key.as_str(), arr));
825            } else if let Some(arr) = atoms_block.get_int(key) {
826                i64_cols.push((key.as_str(), arr));
827            } else if let Some(arr) = atoms_block.get_string(key) {
828                str_cols.push((key.as_str(), arr));
829            }
830        }
831
832        // Build atoms row by row.
833        let mut atom_ids: Vec<AtomId> = Vec::with_capacity(nrows);
834        for row in 0..nrows {
835            let mut atom = Atom::new();
836            for &(key, arr) in &float_cols {
837                #[allow(clippy::unnecessary_cast)]
838                atom.set(key, arr[[row]] as f64);
839            }
840            for &(key, arr) in &i64_cols {
841                atom.set(key, PropValue::Int(arr[[row]]));
842            }
843            for &(key, arr) in &str_cols {
844                atom.set(key, PropValue::Str(arr[[row]].clone()));
845            }
846            atom_ids.push(g.add_atom(atom));
847        }
848
849        // Bonds.
850        if let Some(bonds_block) = frame.get("bonds") {
851            let col_i = bonds_block
852                .get_uint("atomi")
853                .ok_or_else(|| MolRsError::parse("bonds block missing 'atomi' column"))?;
854            let col_j = bonds_block
855                .get_uint("atomj")
856                .ok_or_else(|| MolRsError::parse("bonds block missing 'atomj' column"))?;
857
858            // Bond order can be stored as float or uint
859            let col_order_f = bonds_block.get_float("order");
860            let col_order_u = bonds_block.get_uint("order");
861
862            let nb = bonds_block.nrows().unwrap_or(0);
863            for row in 0..nb {
864                let ai = col_i[[row]] as usize;
865                let aj = col_j[[row]] as usize;
866                if ai < atom_ids.len() && aj < atom_ids.len() {
867                    let bid = g.add_bond(atom_ids[ai], atom_ids[aj])?;
868                    #[allow(clippy::unnecessary_cast)]
869                    let order = col_order_f
870                        .map(|c| c[[row]] as f64)
871                        .or_else(|| col_order_u.map(|c| c[[row]] as f64));
872                    if let Some(o) = order
873                        && let Ok(bond) = g.get_bond_mut(bid)
874                    {
875                        bond.props.insert("order".to_string(), PropValue::F64(o));
876                    }
877                }
878            }
879        }
880
881        // Angles.
882        if let Some(angles_block) = frame.get("angles")
883            && let (Some(ci), Some(cj), Some(ck)) = (
884                angles_block.get_uint("atomi"),
885                angles_block.get_uint("atomj"),
886                angles_block.get_uint("atomk"),
887            )
888        {
889            let na = angles_block.nrows().unwrap_or(0);
890            for row in 0..na {
891                let ai = ci[[row]] as usize;
892                let aj = cj[[row]] as usize;
893                let ak = ck[[row]] as usize;
894                if ai < atom_ids.len() && aj < atom_ids.len() && ak < atom_ids.len() {
895                    g.add_angle(atom_ids[ai], atom_ids[aj], atom_ids[ak])?;
896                }
897            }
898        }
899
900        // Dihedrals.
901        if let Some(dih_block) = frame.get("dihedrals")
902            && let (Some(ci), Some(cj), Some(ck), Some(cl)) = (
903                dih_block.get_uint("atomi"),
904                dih_block.get_uint("atomj"),
905                dih_block.get_uint("atomk"),
906                dih_block.get_uint("atoml"),
907            )
908        {
909            let nd = dih_block.nrows().unwrap_or(0);
910            for row in 0..nd {
911                let ai = ci[[row]] as usize;
912                let aj = cj[[row]] as usize;
913                let ak = ck[[row]] as usize;
914                let al = cl[[row]] as usize;
915                if ai < atom_ids.len()
916                    && aj < atom_ids.len()
917                    && ak < atom_ids.len()
918                    && al < atom_ids.len()
919                {
920                    g.add_dihedral(atom_ids[ai], atom_ids[aj], atom_ids[ak], atom_ids[al])?;
921                }
922            }
923        }
924
925        Ok(g)
926    }
927}
928
929// =========================================================================
930// Tests
931// =========================================================================
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    // ----- PropValue & Atom dict-like API -----
938
939    #[test]
940    fn test_propvalue_from() {
941        let v: PropValue = std::f64::consts::PI.into();
942        assert_eq!(v, PropValue::F64(std::f64::consts::PI));
943
944        let v: PropValue = (42 as I).into();
945        assert_eq!(v, PropValue::Int(42 as I));
946
947        let v: PropValue = "H".into();
948        assert_eq!(v, PropValue::Str("H".to_owned()));
949    }
950
951    #[test]
952    fn test_atom_dict_api() {
953        let mut a = Atom::new();
954        a.set("x", 1.5);
955        a.set("element", "C");
956        a.set("type_id", PropValue::Int(3 as I));
957
958        assert_eq!(a.get_f64("x"), Some(1.5));
959        assert_eq!(a.get_str("element"), Some("C"));
960        assert_eq!(a.get_int("type_id"), Some(3 as I));
961        assert_eq!(a.get_f64("missing"), None);
962        assert!(a.contains_key("x"));
963        assert!(!a.contains_key("missing"));
964        assert_eq!(a.len(), 3);
965
966        a.remove("type_id");
967        assert_eq!(a.len(), 2);
968    }
969
970    #[test]
971    fn test_atom_index() {
972        let mut a = Atom::xyz("O", 1.0, 2.0, 3.0);
973        assert_eq!(a["x"], PropValue::F64(1.0));
974
975        // Mutate via IndexMut.
976        a["x"] = PropValue::F64(99.0);
977        assert_eq!(a.get_f64("x"), Some(99.0));
978    }
979
980    #[test]
981    fn test_atom_xyz_constructor() {
982        let a = Atom::xyz("H", 0.96, 0.0, 0.0);
983        assert_eq!(a.get_str("element"), Some("H"));
984        assert_eq!(a.get_f64("x"), Some(0.96));
985        assert_eq!(a.get_f64("y"), Some(0.0));
986        assert_eq!(a.get_f64("z"), Some(0.0));
987    }
988
989    // ----- Atom CRUD -----
990
991    #[test]
992    fn test_add_remove_atom() {
993        let mut g = MolGraph::new();
994        assert_eq!(g.n_atoms(), 0);
995
996        let id = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
997        assert_eq!(g.n_atoms(), 1);
998        assert_eq!(
999            g.get_atom(id).expect("get atom").get_str("element"),
1000            Some("C")
1001        );
1002
1003        g.remove_atom(id).expect("remove atom");
1004        assert_eq!(g.n_atoms(), 0);
1005        assert!(g.get_atom(id).is_err());
1006    }
1007
1008    #[test]
1009    fn test_atom_mut() {
1010        let mut g = MolGraph::new();
1011        let id = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
1012
1013        g.get_atom_mut(id).expect("get atom mut").set("x", 5.0);
1014        assert_eq!(g.get_atom(id).expect("get atom").get_f64("x"), Some(5.0));
1015    }
1016
1017    // ----- Bond CRUD -----
1018
1019    #[test]
1020    fn test_add_remove_bond() {
1021        let mut g = MolGraph::new();
1022        let a = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
1023        let b = g.add_atom(Atom::xyz("O", 1.0, 0.0, 0.0));
1024
1025        let bid = g.add_bond(a, b).expect("add bond");
1026        assert_eq!(g.n_bonds(), 1);
1027
1028        // Neighbors.
1029        let neigh_a: Vec<AtomId> = g.neighbors(a).collect();
1030        assert_eq!(neigh_a, vec![b]);
1031        let neigh_b: Vec<AtomId> = g.neighbors(b).collect();
1032        assert_eq!(neigh_b, vec![a]);
1033
1034        // Remove bond.
1035        g.remove_bond(bid).expect("remove bond");
1036        assert_eq!(g.n_bonds(), 0);
1037        assert_eq!(g.neighbors(a).count(), 0);
1038        assert_eq!(g.neighbors(b).count(), 0);
1039    }
1040
1041    #[test]
1042    fn test_bond_invalid_atoms() {
1043        let mut g = MolGraph::new();
1044        let a = g.add_atom(Atom::new());
1045        let b = g.add_atom(Atom::new());
1046        // Remove b, then try to bond to it.
1047        g.remove_atom(b).expect("remove atom b");
1048        assert!(g.add_bond(a, b).is_err());
1049    }
1050
1051    // ----- Cascading deletion -----
1052
1053    #[test]
1054    fn test_cascading_deletion() {
1055        let mut g = MolGraph::new();
1056        let a = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
1057        let b = g.add_atom(Atom::xyz("H", 1.0, 0.0, 0.0));
1058        let c = g.add_atom(Atom::xyz("H", -1.0, 0.0, 0.0));
1059        let d = g.add_atom(Atom::xyz("H", 0.0, 1.0, 0.0));
1060
1061        g.add_bond(a, b).expect("add bond");
1062        g.add_bond(a, c).expect("add bond");
1063        g.add_bond(a, d).expect("add bond");
1064        g.add_angle(b, a, c).expect("add angle");
1065        g.add_dihedral(b, a, c, d).expect("add dihedral");
1066
1067        assert_eq!(g.n_bonds(), 3);
1068        assert_eq!(g.n_angles(), 1);
1069        assert_eq!(g.n_dihedrals(), 1);
1070
1071        // Remove the central atom — everything incident must go.
1072        g.remove_atom(a).expect("remove atom a");
1073        assert_eq!(g.n_atoms(), 3);
1074        assert_eq!(g.n_bonds(), 0);
1075        assert_eq!(g.n_angles(), 0);
1076        assert_eq!(g.n_dihedrals(), 0);
1077    }
1078
1079    // ----- Angle & Dihedral CRUD -----
1080
1081    #[test]
1082    fn test_angle_crud() {
1083        let mut g = MolGraph::new();
1084        let a = g.add_atom(Atom::new());
1085        let b = g.add_atom(Atom::new());
1086        let c = g.add_atom(Atom::new());
1087
1088        let aid = g.add_angle(a, b, c).expect("add angle");
1089        assert_eq!(g.n_angles(), 1);
1090        assert_eq!(g.get_angle(aid).expect("get angle").atoms, [a, b, c]);
1091
1092        g.remove_angle(aid).expect("remove angle");
1093        assert_eq!(g.n_angles(), 0);
1094    }
1095
1096    #[test]
1097    fn test_dihedral_crud() {
1098        let mut g = MolGraph::new();
1099        let a = g.add_atom(Atom::new());
1100        let b = g.add_atom(Atom::new());
1101        let c = g.add_atom(Atom::new());
1102        let d = g.add_atom(Atom::new());
1103
1104        let did = g.add_dihedral(a, b, c, d).expect("add dihedral");
1105        assert_eq!(g.n_dihedrals(), 1);
1106        assert_eq!(
1107            g.get_dihedral(did).expect("get dihedral").atoms,
1108            [a, b, c, d]
1109        );
1110
1111        g.remove_dihedral(did).expect("remove dihedral");
1112        assert_eq!(g.n_dihedrals(), 0);
1113    }
1114
1115    // ----- Iteration -----
1116
1117    #[test]
1118    fn test_iteration_counts() {
1119        let mut g = MolGraph::new();
1120        let a = g.add_atom(Atom::xyz("H", 0.0, 0.0, 0.0));
1121        let b = g.add_atom(Atom::xyz("O", 1.0, 0.0, 0.0));
1122        let c = g.add_atom(Atom::xyz("H", 2.0, 0.0, 0.0));
1123
1124        g.add_bond(a, b).expect("add bond");
1125        g.add_bond(b, c).expect("add bond");
1126        g.add_angle(a, b, c).expect("add angle");
1127
1128        assert_eq!(g.atoms().count(), 3);
1129        assert_eq!(g.bonds().count(), 2);
1130        assert_eq!(g.angles().count(), 1);
1131        assert_eq!(g.dihedrals().count(), 0);
1132    }
1133
1134    // ----- Neighbor query -----
1135
1136    #[test]
1137    fn test_neighbors() {
1138        let mut g = MolGraph::new();
1139        let a = g.add_atom(Atom::new());
1140        let b = g.add_atom(Atom::new());
1141        let c = g.add_atom(Atom::new());
1142
1143        g.add_bond(a, b).expect("add bond");
1144        g.add_bond(a, c).expect("add bond");
1145
1146        let mut n: Vec<AtomId> = g.neighbors(a).collect();
1147        n.sort_by_key(|id| id.0); // deterministic order
1148        assert_eq!(n.len(), 2);
1149        assert!(n.contains(&b));
1150        assert!(n.contains(&c));
1151
1152        // b's only neighbor is a.
1153        let nb: Vec<AtomId> = g.neighbors(b).collect();
1154        assert_eq!(nb, vec![a]);
1155    }
1156
1157    // ----- Spatial transforms -----
1158
1159    #[test]
1160    fn test_translate() {
1161        let mut g = MolGraph::new();
1162        let id = g.add_atom(Atom::xyz("C", 1.0, 2.0, 3.0));
1163
1164        g.translate([10.0, 20.0, 30.0]);
1165
1166        let a = g.get_atom(id).expect("get atom");
1167        assert!((a.get_f64("x").unwrap() - 11.0).abs() < 1e-12);
1168        assert!((a.get_f64("y").unwrap() - 22.0).abs() < 1e-12);
1169        assert!((a.get_f64("z").unwrap() - 33.0).abs() < 1e-12);
1170    }
1171
1172    #[test]
1173    fn test_translate_skips_missing_xyz() {
1174        let mut g = MolGraph::new();
1175        let id = g.add_atom(Atom::new()); // no x/y/z
1176        g.translate([1.0, 2.0, 3.0]);
1177        // Should not panic.
1178        assert!(g.get_atom(id).expect("get atom").get_f64("x").is_none());
1179    }
1180
1181    #[test]
1182    fn test_rotate_90_deg_z() {
1183        let mut g = MolGraph::new();
1184        let id = g.add_atom(Atom::xyz("C", 1.0, 0.0, 0.0));
1185
1186        let half_pi = std::f64::consts::FRAC_PI_2;
1187        g.rotate([0.0, 0.0, 1.0], half_pi, None);
1188
1189        let a = g.get_atom(id).expect("get atom");
1190        assert!((a.get_f64("x").unwrap()).abs() < 1e-12);
1191        assert!((a.get_f64("y").unwrap() - 1.0).abs() < 1e-12);
1192        assert!((a.get_f64("z").unwrap()).abs() < 1e-12);
1193    }
1194
1195    // ----- Frame conversion round-trip -----
1196
1197    #[test]
1198    fn test_to_from_frame() {
1199        let mut g = MolGraph::new();
1200        let o = g.add_atom(Atom::xyz("O", 0.0, 0.0, 0.0));
1201        let h1 = g.add_atom(Atom::xyz("H", 0.96, 0.0, 0.0));
1202        let h2 = g.add_atom(Atom::xyz("H", -0.24, 0.93, 0.0));
1203
1204        g.add_bond(o, h1).expect("add bond");
1205        g.add_bond(o, h2).expect("add bond");
1206        g.add_angle(h1, o, h2).expect("add angle");
1207
1208        let frame = g.to_frame();
1209        assert!(frame.contains_key("atoms"));
1210        assert!(frame.contains_key("bonds"));
1211        assert!(frame.contains_key("angles"));
1212
1213        assert_eq!(frame["atoms"].nrows(), Some(3));
1214        assert_eq!(frame["bonds"].nrows(), Some(2));
1215        assert_eq!(frame["angles"].nrows(), Some(1));
1216
1217        // Round-trip.
1218        let g2 = MolGraph::from_frame(&frame).expect("from_frame");
1219        assert_eq!(g2.n_atoms(), 3);
1220        assert_eq!(g2.n_bonds(), 2);
1221        assert_eq!(g2.n_angles(), 1);
1222    }
1223
1224    // ----- Merge -----
1225
1226    #[test]
1227    fn test_merge() {
1228        let mut g1 = MolGraph::new();
1229        let a = g1.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
1230        let b = g1.add_atom(Atom::xyz("O", 1.0, 0.0, 0.0));
1231        g1.add_bond(a, b).expect("add bond");
1232
1233        let mut g2 = MolGraph::new();
1234        let c = g2.add_atom(Atom::xyz("N", 5.0, 0.0, 0.0));
1235        let d = g2.add_atom(Atom::xyz("H", 6.0, 0.0, 0.0));
1236        g2.add_bond(c, d).expect("add bond");
1237
1238        g1.merge(g2);
1239
1240        assert_eq!(g1.n_atoms(), 4);
1241        assert_eq!(g1.n_bonds(), 2);
1242    }
1243
1244    // ----- Clone independence -----
1245
1246    #[test]
1247    fn test_clone_independence() {
1248        let mut g = MolGraph::new();
1249        let id = g.add_atom(Atom::xyz("C", 0.0, 0.0, 0.0));
1250        g.add_atom(Atom::xyz("H", 1.0, 0.0, 0.0));
1251
1252        let mut g2 = g.clone();
1253        // Mutate original.
1254        g.get_atom_mut(id).expect("get atom mut").set("x", 99.0);
1255
1256        // Clone should still have original value (slotmap keys carry across clone).
1257        assert_eq!(g2.get_atom(id).expect("get atom").get_f64("x"), Some(0.0));
1258
1259        // Independent counts.
1260        assert_eq!(g2.n_atoms(), 2);
1261        let first_id = {
1262            let (fid, _) = g2.atoms().next().unwrap();
1263            fid
1264        };
1265        g2.remove_atom(first_id).expect("remove atom");
1266        assert_eq!(g2.n_atoms(), 1);
1267        // Original unaffected.
1268        assert_eq!(g.n_atoms(), 2);
1269    }
1270
1271    // ----- Realistic molecule: water -----
1272
1273    #[test]
1274    fn test_water_molecule() {
1275        let mut water = MolGraph::new();
1276        let o = water.add_atom(Atom::xyz("O", 0.0, 0.0, 0.0));
1277        let h1 = water.add_atom(Atom::xyz("H", 0.9572, 0.0, 0.0));
1278        let h2 = water.add_atom(Atom::xyz("H", -0.2399, 0.9266, 0.0));
1279
1280        water.add_bond(o, h1).expect("add bond");
1281        water.add_bond(o, h2).expect("add bond");
1282        water.add_angle(h1, o, h2).expect("add angle");
1283
1284        assert_eq!(water.n_atoms(), 3);
1285        assert_eq!(water.n_bonds(), 2);
1286        assert_eq!(water.n_angles(), 1);
1287
1288        // O should have 2 neighbors.
1289        assert_eq!(water.neighbors(o).count(), 2);
1290        // Each H should have 1 neighbor.
1291        assert_eq!(water.neighbors(h1).count(), 1);
1292        assert_eq!(water.neighbors(h2).count(), 1);
1293    }
1294
1295    // ----- Coarse-grained usage -----
1296
1297    #[test]
1298    fn test_coarse_grained() {
1299        let mut g = MolGraph::new();
1300
1301        // Bead is just Atom alias — use different prop keys.
1302        let mut b1 = Bead::new();
1303        b1.set("name", "W");
1304        b1.set("x", 0.0);
1305        b1.set("y", 0.0);
1306        b1.set("z", 0.0);
1307        b1.set("mass", 72.0);
1308
1309        let mut b2 = Bead::new();
1310        b2.set("name", "W");
1311        b2.set("x", 4.7);
1312        b2.set("y", 0.0);
1313        b2.set("z", 0.0);
1314        b2.set("mass", 72.0);
1315
1316        let id1 = g.add_atom(b1);
1317        let id2 = g.add_atom(b2);
1318        g.add_bond(id1, id2).expect("add bond");
1319
1320        assert_eq!(g.n_atoms(), 2);
1321        assert_eq!(g.n_bonds(), 1);
1322        assert_eq!(
1323            g.get_atom(id1).expect("get atom").get_f64("mass"),
1324            Some(72.0)
1325        );
1326        assert_eq!(
1327            g.get_atom(id1).expect("get atom").get_str("name"),
1328            Some("W")
1329        );
1330    }
1331}