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
85impl CrystalStructure {
86    /// Number of atoms in the structure.
87    pub fn num_atoms(&self) -> usize {
88        self.atoms.len()
89    }
90
91    /// Get all Cartesian coordinates (Å).
92    pub fn cartesian_coords(&self) -> Vec<[f64; 3]> {
93        self.atoms
94            .iter()
95            .map(|a| self.cell.frac_to_cart(a.frac_coords))
96            .collect()
97    }
98
99    /// Get all atomic numbers.
100    pub fn elements(&self) -> Vec<u8> {
101        self.atoms.iter().map(|a| a.element).collect()
102    }
103
104    /// Generate a supercell by replicating the structure.
105    pub fn make_supercell(&self, na: usize, nb: usize, nc: usize) -> CrystalStructure {
106        let (new_cell, offsets) = self.cell.supercell(na, nb, nc);
107        let mut new_atoms = Vec::new();
108        let mut new_labels = Vec::new();
109
110        let na_f = na as f64;
111        let nb_f = nb as f64;
112        let nc_f = nc as f64;
113
114        for offset in &offsets {
115            for (i, atom) in self.atoms.iter().enumerate() {
116                new_atoms.push(CrystalAtom {
117                    element: atom.element,
118                    frac_coords: [
119                        (atom.frac_coords[0] + offset[0]) / na_f,
120                        (atom.frac_coords[1] + offset[1]) / nb_f,
121                        (atom.frac_coords[2] + offset[2]) / nc_f,
122                    ],
123                });
124                new_labels.push(self.labels[i].clone());
125            }
126        }
127
128        CrystalStructure {
129            cell: new_cell,
130            atoms: new_atoms,
131            labels: new_labels,
132        }
133    }
134}
135
136/// Assemble a framework by placing SBUs on a topology.
137///
138/// `node_sbu`: the SBU placed at each topology node
139/// `linker_sbu`: the SBU placed along each topology edge
140/// `topology`: the periodic net topology
141/// `cell`: the unit cell for the framework
142///
143/// Returns the assembled crystal structure.
144pub fn assemble_framework(
145    node_sbu: &Sbu,
146    linker_sbu: &Sbu,
147    topology: &Topology,
148    cell: &UnitCell,
149) -> CrystalStructure {
150    let mut atoms = Vec::new();
151    let mut labels = Vec::new();
152
153    // 1. Place node SBUs at each topology node position
154    for (ni, node_frac) in topology.nodes.iter().enumerate() {
155        let node_cart = cell.frac_to_cart(*node_frac);
156
157        for (ai, &elem) in node_sbu.elements.iter().enumerate() {
158            let atom_cart = [
159                node_cart[0] + node_sbu.positions[ai][0],
160                node_cart[1] + node_sbu.positions[ai][1],
161                node_cart[2] + node_sbu.positions[ai][2],
162            ];
163            let frac = cell.cart_to_frac(atom_cart);
164            atoms.push(CrystalAtom {
165                element: elem,
166                frac_coords: frac,
167            });
168            labels.push(format!("node_{}", ni));
169        }
170    }
171
172    // 2. Place linker SBUs along each topology edge
173    for (ei, &(ni, nj, offset)) in topology.edges.iter().enumerate() {
174        let node_i_frac = topology.nodes[ni];
175        let node_j_frac = [
176            topology.nodes[nj][0] + offset[0] as f64,
177            topology.nodes[nj][1] + offset[1] as f64,
178            topology.nodes[nj][2] + offset[2] as f64,
179        ];
180
181        let node_i_cart = cell.frac_to_cart(node_i_frac);
182        let node_j_cart = cell.frac_to_cart(node_j_frac);
183
184        // Midpoint
185        let mid = [
186            (node_i_cart[0] + node_j_cart[0]) / 2.0,
187            (node_i_cart[1] + node_j_cart[1]) / 2.0,
188            (node_i_cart[2] + node_j_cart[2]) / 2.0,
189        ];
190
191        // Direction from node_i to node_j
192        let dx = node_j_cart[0] - node_i_cart[0];
193        let dy = node_j_cart[1] - node_i_cart[1];
194        let dz = node_j_cart[2] - node_i_cart[2];
195        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
196
197        if dist < 1e-12 {
198            continue;
199        }
200
201        let dir = [dx / dist, dy / dist, dz / dist];
202
203        // Rotate linker atoms: default linker is along x-axis,
204        // rotate to align with the edge direction
205        let rot = rotation_from_x_to(dir);
206
207        for (ai, &elem) in linker_sbu.elements.iter().enumerate() {
208            let p = linker_sbu.positions[ai];
209            // Apply rotation then translate to midpoint
210            let rotated = apply_rotation(&rot, p);
211            let atom_cart = [
212                mid[0] + rotated[0],
213                mid[1] + rotated[1],
214                mid[2] + rotated[2],
215            ];
216            let frac = cell.cart_to_frac(atom_cart);
217            atoms.push(CrystalAtom {
218                element: elem,
219                frac_coords: frac,
220            });
221            labels.push(format!("linker_{}", ei));
222        }
223    }
224
225    CrystalStructure {
226        cell: cell.clone(),
227        atoms,
228        labels,
229    }
230}
231
232/// Compute the rotation matrix that maps [1,0,0] to the target direction.
233fn rotation_from_x_to(target: [f64; 3]) -> [[f64; 3]; 3] {
234    let x = [1.0, 0.0, 0.0];
235    let dot = x[0] * target[0] + x[1] * target[1] + x[2] * target[2];
236
237    // If nearly parallel
238    if dot > 1.0 - 1e-12 {
239        return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
240    }
241    // If nearly anti-parallel, rotate 180° around z
242    if dot < -1.0 + 1e-12 {
243        return [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]];
244    }
245
246    // Rodrigues' rotation formula
247    let cross = [
248        x[1] * target[2] - x[2] * target[1],
249        x[2] * target[0] - x[0] * target[2],
250        x[0] * target[1] - x[1] * target[0],
251    ];
252    let s = (cross[0].powi(2) + cross[1].powi(2) + cross[2].powi(2)).sqrt();
253    let c = dot;
254
255    // Skew-symmetric cross-product matrix K
256    // K = [  0   -v3   v2 ]
257    //     [  v3   0   -v1 ]
258    //     [ -v2   v1   0  ]
259    let v = cross;
260    let k = [[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]];
261
262    // K²
263    let k2 = mat_mul_3x3(k, k);
264
265    // R = I + K + K² * (1 - c) / s²
266    let factor = (1.0 - c) / (s * s);
267    [
268        [
269            1.0 + k[0][0] + k2[0][0] * factor,
270            k[0][1] + k2[0][1] * factor,
271            k[0][2] + k2[0][2] * factor,
272        ],
273        [
274            k[1][0] + k2[1][0] * factor,
275            1.0 + k[1][1] + k2[1][1] * factor,
276            k[1][2] + k2[1][2] * factor,
277        ],
278        [
279            k[2][0] + k2[2][0] * factor,
280            k[2][1] + k2[2][1] * factor,
281            1.0 + k[2][2] + k2[2][2] * factor,
282        ],
283    ]
284}
285
286fn mat_mul_3x3(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
287    let mut r = [[0.0; 3]; 3];
288    for i in 0..3 {
289        for j in 0..3 {
290            r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
291        }
292    }
293    r
294}
295
296fn apply_rotation(rot: &[[f64; 3]; 3], p: [f64; 3]) -> [f64; 3] {
297    [
298        rot[0][0] * p[0] + rot[0][1] * p[1] + rot[0][2] * p[2],
299        rot[1][0] * p[0] + rot[1][1] * p[1] + rot[1][2] * p[2],
300        rot[2][0] * p[0] + rot[2][1] * p[1] + rot[2][2] * p[2],
301    ]
302}
303
304#[cfg(test)]
305mod tests {
306    use super::super::sbu::{CoordinationGeometry, Sbu};
307    use super::*;
308
309    #[test]
310    fn test_assemble_simple_cubic_framework() {
311        let node = Sbu::metal_node(30, 0.0, CoordinationGeometry::Octahedral); // Zn at origin
312        let linker = Sbu::linear_linker(&[6, 6], 1.4, "carboxylate");
313        let topo = Topology::pcu();
314        let cell = UnitCell::cubic(10.0);
315
316        let structure = assemble_framework(&node, &linker, &topo, &cell);
317
318        // 1 node with 1 atom + 3 edges with 2 atoms each = 7
319        assert_eq!(structure.num_atoms(), 7);
320        assert!(structure
321            .atoms
322            .iter()
323            .all(|a| a.element == 30 || a.element == 6));
324    }
325
326    #[test]
327    fn test_assemble_diamond_topology() {
328        let node = Sbu::metal_node(14, 0.0, CoordinationGeometry::Tetrahedral); // Si
329        let linker = Sbu::linear_linker(&[8], 1.0, "bridge"); // O bridge
330        let topo = Topology::dia();
331        let cell = UnitCell::cubic(5.43); // silicon lattice constant
332
333        let structure = assemble_framework(&node, &linker, &topo, &cell);
334
335        // 2 nodes + 4 edges = 2 × 1 + 4 × 1 = 6 atoms
336        assert_eq!(structure.num_atoms(), 6);
337    }
338
339    #[test]
340    fn test_supercell_atom_count() {
341        let node = Sbu::metal_node(30, 0.0, CoordinationGeometry::Octahedral);
342        let linker = Sbu::linear_linker(&[6, 6], 1.4, "carboxylate");
343        let topo = Topology::pcu();
344        let cell = UnitCell::cubic(10.0);
345
346        let structure = assemble_framework(&node, &linker, &topo, &cell);
347        let n_base = structure.num_atoms();
348
349        let super_struct = structure.make_supercell(2, 2, 2);
350        assert_eq!(super_struct.num_atoms(), n_base * 8);
351    }
352
353    #[test]
354    fn test_topology_pcu() {
355        let t = Topology::pcu();
356        assert_eq!(t.nodes.len(), 1);
357        assert_eq!(t.edges.len(), 3);
358        assert_eq!(t.name, "pcu");
359    }
360
361    #[test]
362    fn test_topology_dia() {
363        let t = Topology::dia();
364        assert_eq!(t.nodes.len(), 2);
365        assert_eq!(t.edges.len(), 4);
366    }
367
368    #[test]
369    fn test_rotation_identity() {
370        let rot = rotation_from_x_to([1.0, 0.0, 0.0]);
371        let p = [1.0, 2.0, 3.0];
372        let rp = apply_rotation(&rot, p);
373        for i in 0..3 {
374            assert!((rp[i] - p[i]).abs() < 1e-10);
375        }
376    }
377
378    #[test]
379    fn test_rotation_to_y() {
380        let rot = rotation_from_x_to([0.0, 1.0, 0.0]);
381        let p = [1.0, 0.0, 0.0];
382        let rp = apply_rotation(&rot, p);
383        assert!((rp[0]).abs() < 1e-10, "x should be ~0, got {:.6}", rp[0]);
384        assert!(
385            (rp[1] - 1.0).abs() < 1e-10,
386            "y should be ~1, got {:.6}",
387            rp[1]
388        );
389        assert!((rp[2]).abs() < 1e-10, "z should be ~0, got {:.6}", rp[2]);
390    }
391
392    #[test]
393    fn test_crystal_cartesian_coords() {
394        let cell = UnitCell::cubic(10.0);
395        let structure = CrystalStructure {
396            cell,
397            atoms: vec![
398                CrystalAtom {
399                    element: 6,
400                    frac_coords: [0.5, 0.0, 0.0],
401                },
402                CrystalAtom {
403                    element: 8,
404                    frac_coords: [0.0, 0.5, 0.0],
405                },
406            ],
407            labels: vec!["a".into(), "b".into()],
408        };
409
410        let coords = structure.cartesian_coords();
411        assert!((coords[0][0] - 5.0).abs() < 1e-10);
412        assert!((coords[1][1] - 5.0).abs() < 1e-10);
413    }
414}