Skip to main content

sci_form/materials/
assembly.rs

1//! Framework assembly: connecting SBUs into periodic crystal structures.
2//!
3//! The assembly process:
4//! 1. Define metal-node SBUs and organic-linker SBUs
5//! 2. Specify a target topology (e.g., cubic pcu for MOF-5)
6//! 3. Place nodes at predefined positions within the unit cell
7//! 4. Connect them via linkers along edges of the topology net
8//! 5. Output the assembled periodic structure
9
10use super::cell::UnitCell;
11use super::sbu::Sbu;
12use serde::{Deserialize, Serialize};
13
14/// A placed atom in the assembled crystal structure.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CrystalAtom {
17    /// Atomic number.
18    pub element: u8,
19    /// Fractional coordinates in the unit cell.
20    pub frac_coords: [f64; 3],
21}
22
23/// An assembled periodic crystal structure.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CrystalStructure {
26    /// Unit cell definition.
27    pub cell: UnitCell,
28    /// All atoms in the asymmetric unit (fractional coordinates).
29    pub atoms: Vec<CrystalAtom>,
30    /// Labels for tracking which SBU each atom came from.
31    pub labels: Vec<String>,
32}
33
34/// Topology definition: positions and edges for a network.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Topology {
37    /// Name of the topology (e.g., "pcu", "dia", "sql").
38    pub name: String,
39    /// Node positions in fractional coordinates.
40    pub nodes: Vec<[f64; 3]>,
41    /// Edges as (node_i, node_j, cell_offset_j).
42    /// cell_offset_j = [da, db, dc] for the periodic image of node_j.
43    pub edges: Vec<(usize, usize, [i32; 3])>,
44}
45
46impl Topology {
47    /// Primitive cubic (pcu) topology: single node at origin with 6 edges.
48    /// Common for MOF-5 type structures.
49    pub fn pcu() -> Self {
50        Self {
51            name: "pcu".to_string(),
52            nodes: vec![[0.0, 0.0, 0.0]],
53            edges: vec![
54                (0, 0, [1, 0, 0]), // +a
55                (0, 0, [0, 1, 0]), // +b
56                (0, 0, [0, 0, 1]), // +c
57            ],
58        }
59    }
60
61    /// Diamond (dia) topology: two nodes with tetrahedral connectivity.
62    pub fn dia() -> Self {
63        Self {
64            name: "dia".to_string(),
65            nodes: vec![[0.0, 0.0, 0.0], [0.25, 0.25, 0.25]],
66            edges: vec![
67                (0, 1, [0, 0, 0]),
68                (0, 1, [-1, 0, 0]),
69                (0, 1, [0, -1, 0]),
70                (0, 1, [0, 0, -1]),
71            ],
72        }
73    }
74
75    /// Square lattice (sql) for 2D frameworks.
76    pub fn sql() -> Self {
77        Self {
78            name: "sql".to_string(),
79            nodes: vec![[0.0, 0.0, 0.0]],
80            edges: vec![(0, 0, [1, 0, 0]), (0, 0, [0, 1, 0])],
81        }
82    }
83
84    /// Body-centered cubic (bcu) topology: 2 nodes at (0,0,0) and (0.5,0.5,0.5).
85    /// 8-connected, found in UiO-66 and related Zr-MOFs.
86    pub fn bcu() -> Self {
87        Self {
88            name: "bcu".to_string(),
89            nodes: vec![[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
90            edges: vec![
91                (0, 1, [0, 0, 0]),
92                (0, 1, [-1, 0, 0]),
93                (0, 1, [0, -1, 0]),
94                (0, 1, [0, 0, -1]),
95            ],
96        }
97    }
98
99    /// Face-centered cubic (fcu) topology: 4 nodes with 12-connectivity.
100    /// Common in UiO-66 type structures.
101    pub fn fcu() -> Self {
102        Self {
103            name: "fcu".to_string(),
104            nodes: vec![
105                [0.0, 0.0, 0.0],
106                [0.5, 0.5, 0.0],
107                [0.5, 0.0, 0.5],
108                [0.0, 0.5, 0.5],
109            ],
110            edges: vec![
111                // Node 0 <-> Node 1
112                (0, 1, [0, 0, 0]),
113                (0, 1, [0, -1, 0]),
114                (0, 1, [-1, 0, 0]),
115                // Node 0 <-> Node 2
116                (0, 2, [0, 0, 0]),
117                (0, 2, [0, 0, -1]),
118                (0, 2, [-1, 0, 0]),
119                // Node 0 <-> Node 3
120                (0, 3, [0, 0, 0]),
121                (0, 3, [0, -1, 0]),
122                (0, 3, [0, 0, -1]),
123                // Node 1 <-> Node 2
124                (1, 2, [0, 0, 0]),
125                (1, 2, [0, 0, -1]),
126                // Node 1 <-> Node 3
127                (1, 3, [0, 0, 0]),
128                (1, 3, [-1, 0, 0]),
129                // Node 2 <-> Node 3
130                (2, 3, [0, 0, 0]),
131                (2, 3, [0, -1, 0]),
132            ],
133        }
134    }
135
136    /// NbO (nbo) topology: 2 nodes with square-planar 4-connectivity.
137    /// Found in MOF-101 and HKUST-1 (partially).
138    pub fn nbo() -> Self {
139        Self {
140            name: "nbo".to_string(),
141            nodes: vec![[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
142            edges: vec![
143                (0, 1, [0, 0, 0]),
144                (0, 1, [-1, 0, 0]),
145                (0, 1, [0, -1, 0]),
146                (0, 1, [0, 0, -1]),
147            ],
148        }
149    }
150
151    /// PtS (pts) topology: 2 nodes — one tetrahedral (4-conn) and one square-planar (4-conn).
152    /// Found in MOF-11 and related pillared structures.
153    pub fn pts() -> Self {
154        Self {
155            name: "pts".to_string(),
156            nodes: vec![
157                [0.0, 0.0, 0.0], // tetrahedral node
158                [0.5, 0.5, 0.0], // square-planar node
159            ],
160            edges: vec![
161                (0, 1, [0, 0, 0]),
162                (0, 1, [0, -1, 0]),
163                (0, 1, [0, 0, 1]),
164                (0, 1, [0, -1, 1]),
165            ],
166        }
167    }
168
169    /// Kagomé (kgm) topology: 3 nodes in 2D hexagonal Kagomé lattice.
170    /// 4-connected, found in many layered MOFs.
171    pub fn kgm() -> Self {
172        Self {
173            name: "kgm".to_string(),
174            nodes: vec![[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.5, 0.5, 0.0]],
175            edges: vec![
176                (0, 2, [0, 0, 0]),
177                (1, 2, [0, 0, 0]),
178                (0, 1, [1, 0, 0]),
179                (0, 1, [0, -1, 0]),
180                (1, 2, [-1, 1, 0]),
181                (0, 2, [0, -1, 0]),
182            ],
183        }
184    }
185
186    /// Hexagonal (hxl) topology: single node with 6-connected hexagonal lattice.
187    /// 2D honeycomb-like, found in layered coordination polymers.
188    pub fn hxl() -> Self {
189        Self {
190            name: "hxl".to_string(),
191            nodes: vec![[0.0, 0.0, 0.0]],
192            edges: vec![(0, 0, [1, 0, 0]), (0, 0, [0, 1, 0]), (0, 0, [1, -1, 0])],
193        }
194    }
195
196    /// Simple cubic with pillars (pcu-pillar): pcu with additional vertical pillaring.
197    /// Used for pillared-layer MOFs.
198    pub fn pillared_sql() -> Self {
199        Self {
200            name: "pillared_sql".to_string(),
201            nodes: vec![[0.0, 0.0, 0.0]],
202            edges: vec![(0, 0, [1, 0, 0]), (0, 0, [0, 1, 0]), (0, 0, [0, 0, 1])],
203        }
204    }
205
206    /// Look up a topology by name string.
207    pub fn from_name(name: &str) -> Option<Self> {
208        match name {
209            "pcu" => Some(Self::pcu()),
210            "dia" => Some(Self::dia()),
211            "sql" => Some(Self::sql()),
212            "bcu" => Some(Self::bcu()),
213            "fcu" => Some(Self::fcu()),
214            "nbo" => Some(Self::nbo()),
215            "pts" => Some(Self::pts()),
216            "kgm" => Some(Self::kgm()),
217            "hxl" => Some(Self::hxl()),
218            "pillared_sql" => Some(Self::pillared_sql()),
219            _ => None,
220        }
221    }
222}
223
224impl CrystalStructure {
225    /// Number of atoms in the structure.
226    pub fn num_atoms(&self) -> usize {
227        self.atoms.len()
228    }
229
230    /// Get all Cartesian coordinates (Å).
231    pub fn cartesian_coords(&self) -> Vec<[f64; 3]> {
232        self.atoms
233            .iter()
234            .map(|a| self.cell.frac_to_cart(a.frac_coords))
235            .collect()
236    }
237
238    /// Get all atomic numbers.
239    pub fn elements(&self) -> Vec<u8> {
240        self.atoms.iter().map(|a| a.element).collect()
241    }
242
243    /// Generate a supercell by replicating the structure.
244    pub fn make_supercell(&self, na: usize, nb: usize, nc: usize) -> CrystalStructure {
245        let (new_cell, offsets) = self.cell.supercell(na, nb, nc);
246        let mut new_atoms = Vec::new();
247        let mut new_labels = Vec::new();
248
249        let na_f = na as f64;
250        let nb_f = nb as f64;
251        let nc_f = nc as f64;
252
253        for offset in &offsets {
254            for (i, atom) in self.atoms.iter().enumerate() {
255                new_atoms.push(CrystalAtom {
256                    element: atom.element,
257                    frac_coords: [
258                        (atom.frac_coords[0] + offset[0]) / na_f,
259                        (atom.frac_coords[1] + offset[1]) / nb_f,
260                        (atom.frac_coords[2] + offset[2]) / nc_f,
261                    ],
262                });
263                new_labels.push(self.labels[i].clone());
264            }
265        }
266
267        CrystalStructure {
268            cell: new_cell,
269            atoms: new_atoms,
270            labels: new_labels,
271        }
272    }
273}
274
275/// Assemble a framework by placing SBUs on a topology.
276///
277/// `node_sbu`: the SBU placed at each topology node
278/// `linker_sbu`: the SBU placed along each topology edge
279/// `topology`: the periodic net topology
280/// `cell`: the unit cell for the framework
281///
282/// Returns the assembled crystal structure.
283pub fn assemble_framework(
284    node_sbu: &Sbu,
285    linker_sbu: &Sbu,
286    topology: &Topology,
287    cell: &UnitCell,
288) -> CrystalStructure {
289    let mut atoms = Vec::new();
290    let mut labels = Vec::new();
291
292    // 1. Place node SBUs at each topology node position
293    for (ni, node_frac) in topology.nodes.iter().enumerate() {
294        let node_cart = cell.frac_to_cart(*node_frac);
295
296        for (ai, &elem) in node_sbu.elements.iter().enumerate() {
297            let atom_cart = [
298                node_cart[0] + node_sbu.positions[ai][0],
299                node_cart[1] + node_sbu.positions[ai][1],
300                node_cart[2] + node_sbu.positions[ai][2],
301            ];
302            let frac = cell.cart_to_frac(atom_cart);
303            atoms.push(CrystalAtom {
304                element: elem,
305                frac_coords: frac,
306            });
307            labels.push(format!("node_{}", ni));
308        }
309    }
310
311    // 2. Place linker SBUs along each topology edge
312    for (ei, &(ni, nj, offset)) in topology.edges.iter().enumerate() {
313        let node_i_frac = topology.nodes[ni];
314        let node_j_frac = [
315            topology.nodes[nj][0] + offset[0] as f64,
316            topology.nodes[nj][1] + offset[1] as f64,
317            topology.nodes[nj][2] + offset[2] as f64,
318        ];
319
320        let node_i_cart = cell.frac_to_cart(node_i_frac);
321        let node_j_cart = cell.frac_to_cart(node_j_frac);
322
323        // Midpoint
324        let mid = [
325            (node_i_cart[0] + node_j_cart[0]) / 2.0,
326            (node_i_cart[1] + node_j_cart[1]) / 2.0,
327            (node_i_cart[2] + node_j_cart[2]) / 2.0,
328        ];
329
330        // Direction from node_i to node_j
331        let dx = node_j_cart[0] - node_i_cart[0];
332        let dy = node_j_cart[1] - node_i_cart[1];
333        let dz = node_j_cart[2] - node_i_cart[2];
334        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
335
336        if dist < 1e-12 {
337            continue;
338        }
339
340        let dir = [dx / dist, dy / dist, dz / dist];
341
342        // Rotate linker atoms: default linker is along x-axis,
343        // rotate to align with the edge direction
344        let rot = rotation_from_x_to(dir);
345
346        for (ai, &elem) in linker_sbu.elements.iter().enumerate() {
347            let p = linker_sbu.positions[ai];
348            // Apply rotation then translate to midpoint
349            let rotated = apply_rotation(&rot, p);
350            let atom_cart = [
351                mid[0] + rotated[0],
352                mid[1] + rotated[1],
353                mid[2] + rotated[2],
354            ];
355            let frac = cell.cart_to_frac(atom_cart);
356            atoms.push(CrystalAtom {
357                element: elem,
358                frac_coords: frac,
359            });
360            labels.push(format!("linker_{}", ei));
361        }
362    }
363
364    CrystalStructure {
365        cell: cell.clone(),
366        atoms,
367        labels,
368    }
369}
370
371/// Compute the rotation matrix that maps [1,0,0] to the target direction.
372fn rotation_from_x_to(target: [f64; 3]) -> [[f64; 3]; 3] {
373    let x = [1.0, 0.0, 0.0];
374    let dot = x[0] * target[0] + x[1] * target[1] + x[2] * target[2];
375
376    // If nearly parallel
377    if dot > 1.0 - 1e-12 {
378        return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
379    }
380    // If nearly anti-parallel, rotate 180° around z
381    if dot < -1.0 + 1e-12 {
382        return [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]];
383    }
384
385    // Rodrigues' rotation formula
386    let cross = [
387        x[1] * target[2] - x[2] * target[1],
388        x[2] * target[0] - x[0] * target[2],
389        x[0] * target[1] - x[1] * target[0],
390    ];
391    let s = (cross[0].powi(2) + cross[1].powi(2) + cross[2].powi(2)).sqrt();
392    let c = dot;
393
394    // Skew-symmetric cross-product matrix K
395    // K = [  0   -v3   v2 ]
396    //     [  v3   0   -v1 ]
397    //     [ -v2   v1   0  ]
398    let v = cross;
399    let k = [[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]];
400
401    // K²
402    let k2 = mat_mul_3x3(k, k);
403
404    // R = I + K + K² * (1 - c) / s²
405    let factor = (1.0 - c) / (s * s);
406    [
407        [
408            1.0 + k[0][0] + k2[0][0] * factor,
409            k[0][1] + k2[0][1] * factor,
410            k[0][2] + k2[0][2] * factor,
411        ],
412        [
413            k[1][0] + k2[1][0] * factor,
414            1.0 + k[1][1] + k2[1][1] * factor,
415            k[1][2] + k2[1][2] * factor,
416        ],
417        [
418            k[2][0] + k2[2][0] * factor,
419            k[2][1] + k2[2][1] * factor,
420            1.0 + k[2][2] + k2[2][2] * factor,
421        ],
422    ]
423}
424
425fn mat_mul_3x3(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
426    let mut r = [[0.0; 3]; 3];
427    for i in 0..3 {
428        for j in 0..3 {
429            r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
430        }
431    }
432    r
433}
434
435fn apply_rotation(rot: &[[f64; 3]; 3], p: [f64; 3]) -> [f64; 3] {
436    [
437        rot[0][0] * p[0] + rot[0][1] * p[1] + rot[0][2] * p[2],
438        rot[1][0] * p[0] + rot[1][1] * p[1] + rot[1][2] * p[2],
439        rot[2][0] * p[0] + rot[2][1] * p[1] + rot[2][2] * p[2],
440    ]
441}
442
443#[cfg(test)]
444mod tests {
445    use super::super::sbu::{CoordinationGeometry, Sbu};
446    use super::*;
447
448    #[test]
449    fn test_assemble_simple_cubic_framework() {
450        let node = Sbu::metal_node(30, 0.0, CoordinationGeometry::Octahedral); // Zn at origin
451        let linker = Sbu::linear_linker(&[6, 6], 1.4, "carboxylate");
452        let topo = Topology::pcu();
453        let cell = UnitCell::cubic(10.0);
454
455        let structure = assemble_framework(&node, &linker, &topo, &cell);
456
457        // 1 node with 1 atom + 3 edges with 2 atoms each = 7
458        assert_eq!(structure.num_atoms(), 7);
459        assert!(structure
460            .atoms
461            .iter()
462            .all(|a| a.element == 30 || a.element == 6));
463    }
464
465    #[test]
466    fn test_assemble_diamond_topology() {
467        let node = Sbu::metal_node(14, 0.0, CoordinationGeometry::Tetrahedral); // Si
468        let linker = Sbu::linear_linker(&[8], 1.0, "bridge"); // O bridge
469        let topo = Topology::dia();
470        let cell = UnitCell::cubic(5.43); // silicon lattice constant
471
472        let structure = assemble_framework(&node, &linker, &topo, &cell);
473
474        // 2 nodes + 4 edges = 2 × 1 + 4 × 1 = 6 atoms
475        assert_eq!(structure.num_atoms(), 6);
476    }
477
478    #[test]
479    fn test_supercell_atom_count() {
480        let node = Sbu::metal_node(30, 0.0, CoordinationGeometry::Octahedral);
481        let linker = Sbu::linear_linker(&[6, 6], 1.4, "carboxylate");
482        let topo = Topology::pcu();
483        let cell = UnitCell::cubic(10.0);
484
485        let structure = assemble_framework(&node, &linker, &topo, &cell);
486        let n_base = structure.num_atoms();
487
488        let super_struct = structure.make_supercell(2, 2, 2);
489        assert_eq!(super_struct.num_atoms(), n_base * 8);
490    }
491
492    #[test]
493    fn test_topology_pcu() {
494        let t = Topology::pcu();
495        assert_eq!(t.nodes.len(), 1);
496        assert_eq!(t.edges.len(), 3);
497        assert_eq!(t.name, "pcu");
498    }
499
500    #[test]
501    fn test_topology_dia() {
502        let t = Topology::dia();
503        assert_eq!(t.nodes.len(), 2);
504        assert_eq!(t.edges.len(), 4);
505    }
506
507    #[test]
508    fn test_rotation_identity() {
509        let rot = rotation_from_x_to([1.0, 0.0, 0.0]);
510        let p = [1.0, 2.0, 3.0];
511        let rp = apply_rotation(&rot, p);
512        for i in 0..3 {
513            assert!((rp[i] - p[i]).abs() < 1e-10);
514        }
515    }
516
517    #[test]
518    fn test_rotation_to_y() {
519        let rot = rotation_from_x_to([0.0, 1.0, 0.0]);
520        let p = [1.0, 0.0, 0.0];
521        let rp = apply_rotation(&rot, p);
522        assert!((rp[0]).abs() < 1e-10, "x should be ~0, got {:.6}", rp[0]);
523        assert!(
524            (rp[1] - 1.0).abs() < 1e-10,
525            "y should be ~1, got {:.6}",
526            rp[1]
527        );
528        assert!((rp[2]).abs() < 1e-10, "z should be ~0, got {:.6}", rp[2]);
529    }
530
531    #[test]
532    fn test_crystal_cartesian_coords() {
533        let cell = UnitCell::cubic(10.0);
534        let structure = CrystalStructure {
535            cell,
536            atoms: vec![
537                CrystalAtom {
538                    element: 6,
539                    frac_coords: [0.5, 0.0, 0.0],
540                },
541                CrystalAtom {
542                    element: 8,
543                    frac_coords: [0.0, 0.5, 0.0],
544                },
545            ],
546            labels: vec!["a".into(), "b".into()],
547        };
548
549        let coords = structure.cartesian_coords();
550        assert!((coords[0][0] - 5.0).abs() < 1e-10);
551        assert!((coords[1][1] - 5.0).abs() < 1e-10);
552    }
553
554    #[test]
555    fn test_topology_bcu() {
556        let t = Topology::bcu();
557        assert_eq!(t.nodes.len(), 2);
558        assert_eq!(t.edges.len(), 4);
559        assert_eq!(t.name, "bcu");
560    }
561
562    #[test]
563    fn test_topology_fcu() {
564        let t = Topology::fcu();
565        assert_eq!(t.nodes.len(), 4);
566        assert_eq!(t.edges.len(), 15);
567        assert_eq!(t.name, "fcu");
568    }
569
570    #[test]
571    fn test_topology_nbo() {
572        let t = Topology::nbo();
573        assert_eq!(t.nodes.len(), 2);
574        assert_eq!(t.edges.len(), 4);
575        assert_eq!(t.name, "nbo");
576    }
577
578    #[test]
579    fn test_topology_pts() {
580        let t = Topology::pts();
581        assert_eq!(t.nodes.len(), 2);
582        assert_eq!(t.edges.len(), 4);
583        assert_eq!(t.name, "pts");
584    }
585
586    #[test]
587    fn test_topology_kgm() {
588        let t = Topology::kgm();
589        assert_eq!(t.nodes.len(), 3);
590        assert_eq!(t.edges.len(), 6);
591        assert_eq!(t.name, "kgm");
592    }
593
594    #[test]
595    fn test_topology_from_name() {
596        assert!(Topology::from_name("pcu").is_some());
597        assert!(Topology::from_name("dia").is_some());
598        assert!(Topology::from_name("fcu").is_some());
599        assert!(Topology::from_name("bcu").is_some());
600        assert!(Topology::from_name("nbo").is_some());
601        assert!(Topology::from_name("pts").is_some());
602        assert!(Topology::from_name("kgm").is_some());
603        assert!(Topology::from_name("hxl").is_some());
604        assert!(Topology::from_name("pillared_sql").is_some());
605        assert!(Topology::from_name("unknown").is_none());
606    }
607
608    #[test]
609    fn test_assemble_fcu_framework() {
610        let node = Sbu::metal_node(40, 0.0, CoordinationGeometry::Octahedral); // Zr
611        let linker = Sbu::linear_linker(&[6, 6], 1.4, "bdc");
612        let topo = Topology::fcu();
613        let cell = UnitCell::cubic(20.0);
614
615        let structure = assemble_framework(&node, &linker, &topo, &cell);
616        // 4 nodes × 1 atom + 15 edges × 2 atoms = 34
617        assert_eq!(structure.num_atoms(), 34);
618    }
619
620    #[test]
621    fn test_assemble_bcu_framework() {
622        let node = Sbu::metal_node(40, 0.0, CoordinationGeometry::Octahedral);
623        let linker = Sbu::linear_linker(&[6], 1.0, "bridge");
624        let topo = Topology::bcu();
625        let cell = UnitCell::cubic(15.0);
626
627        let structure = assemble_framework(&node, &linker, &topo, &cell);
628        // 2 nodes × 1 atom + 4 edges × 1 atom = 6
629        assert_eq!(structure.num_atoms(), 6);
630    }
631}