Skip to main content

sci_form/materials/
cell.rs

1//! Unit cell definition with lattice vectors and periodic boundary conditions.
2//!
3//! A crystallographic unit cell is defined by three lattice vectors **a**, **b**, **c**
4//! (or equivalently six parameters: a, b, c, α, β, γ).
5//!
6//! Fractional ↔ Cartesian conversions:
7//!   r_cart = M · r_frac    where M = [a | b | c] column matrix
8//!   r_frac = M⁻¹ · r_cart
9
10use serde::{Deserialize, Serialize};
11
12/// A 3D periodic unit cell defined by lattice vectors.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UnitCell {
15    /// Lattice vectors as rows: \[\[ax,ay,az\], \[bx,by,bz\], \[cx,cy,cz\]\].
16    pub lattice: [[f64; 3]; 3],
17}
18
19/// Cell parameters in crystallographic notation (a, b, c, α, β, γ).
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CellParameters {
22    /// Lattice lengths (Å).
23    pub a: f64,
24    pub b: f64,
25    pub c: f64,
26    /// Lattice angles (degrees).
27    pub alpha: f64,
28    pub beta: f64,
29    pub gamma: f64,
30}
31
32impl UnitCell {
33    /// Create a unit cell from three lattice vectors (row vectors).
34    pub fn new(lattice: [[f64; 3]; 3]) -> Self {
35        Self { lattice }
36    }
37
38    /// Create a cubic unit cell with edge length `a`.
39    pub fn cubic(a: f64) -> Self {
40        Self {
41            lattice: [[a, 0.0, 0.0], [0.0, a, 0.0], [0.0, 0.0, a]],
42        }
43    }
44
45    /// Create a cell from crystallographic parameters (a, b, c, α, β, γ in degrees).
46    pub fn from_parameters(params: &CellParameters) -> Self {
47        let alpha = params.alpha.to_radians();
48        let beta = params.beta.to_radians();
49        let gamma = params.gamma.to_radians();
50
51        let cos_alpha = alpha.cos();
52        let cos_beta = beta.cos();
53        let cos_gamma = gamma.cos();
54        let sin_gamma = gamma.sin();
55
56        // Standard crystallographic convention: a along x, b in xy-plane
57        let ax = params.a;
58        let bx = params.b * cos_gamma;
59        let by = params.b * sin_gamma;
60        let cx = params.c * cos_beta;
61        let cy = params.c * (cos_alpha - cos_beta * cos_gamma) / sin_gamma;
62        let cz = (params.c * params.c - cx * cx - cy * cy).sqrt();
63
64        Self {
65            lattice: [[ax, 0.0, 0.0], [bx, by, 0.0], [cx, cy, cz]],
66        }
67    }
68
69    /// Extract crystallographic parameters from lattice vectors.
70    pub fn parameters(&self) -> CellParameters {
71        let a_vec = self.lattice[0];
72        let b_vec = self.lattice[1];
73        let c_vec = self.lattice[2];
74
75        let a = norm3(a_vec);
76        let b = norm3(b_vec);
77        let c = norm3(c_vec);
78
79        let alpha = angle_between(b_vec, c_vec).to_degrees();
80        let beta = angle_between(a_vec, c_vec).to_degrees();
81        let gamma = angle_between(a_vec, b_vec).to_degrees();
82
83        CellParameters {
84            a,
85            b,
86            c,
87            alpha,
88            beta,
89            gamma,
90        }
91    }
92
93    /// Unit cell volume: |a · (b × c)|.
94    pub fn volume(&self) -> f64 {
95        let a = self.lattice[0];
96        let b = self.lattice[1];
97        let c = self.lattice[2];
98        // b × c
99        let bxc = cross3(b, c);
100        dot3(a, bxc).abs()
101    }
102
103    /// Convert fractional coordinates to Cartesian (Å).
104    /// r_cart = f0 * a + f1 * b + f2 * c
105    pub fn frac_to_cart(&self, frac: [f64; 3]) -> [f64; 3] {
106        let a = self.lattice[0];
107        let b = self.lattice[1];
108        let c = self.lattice[2];
109        [
110            frac[0] * a[0] + frac[1] * b[0] + frac[2] * c[0],
111            frac[0] * a[1] + frac[1] * b[1] + frac[2] * c[1],
112            frac[0] * a[2] + frac[1] * b[2] + frac[2] * c[2],
113        ]
114    }
115
116    /// Convert Cartesian coordinates (Å) to fractional.
117    /// frac_to_cart computes: r = f[0]*a + f[1]*b + f[2]*c = M^T · f
118    /// So: f = (M^T)^{-1} · r = (M^{-1})^T · r
119    pub fn cart_to_frac(&self, cart: [f64; 3]) -> [f64; 3] {
120        let inv = self.inverse_matrix();
121        // We need (M^{-1})^T applied to cart, so use columns of inv as rows
122        [
123            inv[0][0] * cart[0] + inv[1][0] * cart[1] + inv[2][0] * cart[2],
124            inv[0][1] * cart[0] + inv[1][1] * cart[1] + inv[2][1] * cart[2],
125            inv[0][2] * cart[0] + inv[1][2] * cart[1] + inv[2][2] * cart[2],
126        ]
127    }
128
129    /// Wrap fractional coordinates into [0, 1) — periodic boundary conditions.
130    pub fn wrap_frac(frac: [f64; 3]) -> [f64; 3] {
131        [
132            frac[0] - frac[0].floor(),
133            frac[1] - frac[1].floor(),
134            frac[2] - frac[2].floor(),
135        ]
136    }
137
138    /// Wrap Cartesian coordinates into the unit cell.
139    pub fn wrap_cart(&self, cart: [f64; 3]) -> [f64; 3] {
140        let frac = self.cart_to_frac(cart);
141        let wrapped = Self::wrap_frac(frac);
142        self.frac_to_cart(wrapped)
143    }
144
145    /// Minimum-image distance between two Cartesian points under PBC.
146    pub fn minimum_image_distance(&self, a: [f64; 3], b: [f64; 3]) -> f64 {
147        let fa = self.cart_to_frac(a);
148        let fb = self.cart_to_frac(b);
149        let mut df = [fa[0] - fb[0], fa[1] - fb[1], fa[2] - fb[2]];
150        // Apply minimum image convention
151        for d in &mut df {
152            *d -= d.round();
153        }
154        let dc = self.frac_to_cart(df);
155        norm3(dc)
156    }
157
158    /// Build a supercell by replicating (na × nb × nc) times.
159    /// Returns new lattice and list of fractional offsets for each image.
160    pub fn supercell(&self, na: usize, nb: usize, nc: usize) -> (UnitCell, Vec<[f64; 3]>) {
161        let new_lattice = [
162            [
163                self.lattice[0][0] * na as f64,
164                self.lattice[0][1] * na as f64,
165                self.lattice[0][2] * na as f64,
166            ],
167            [
168                self.lattice[1][0] * nb as f64,
169                self.lattice[1][1] * nb as f64,
170                self.lattice[1][2] * nb as f64,
171            ],
172            [
173                self.lattice[2][0] * nc as f64,
174                self.lattice[2][1] * nc as f64,
175                self.lattice[2][2] * nc as f64,
176            ],
177        ];
178
179        let mut offsets = Vec::with_capacity(na * nb * nc);
180        for ia in 0..na {
181            for ib in 0..nb {
182                for ic in 0..nc {
183                    offsets.push([ia as f64, ib as f64, ic as f64]);
184                }
185            }
186        }
187
188        (UnitCell::new(new_lattice), offsets)
189    }
190
191    /// Inverse of the 3×3 matrix whose rows are the lattice vectors.
192    fn inverse_matrix(&self) -> [[f64; 3]; 3] {
193        let m = self.lattice;
194        let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
195            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
196            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
197
198        let inv_det = 1.0 / det;
199
200        [
201            [
202                (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
203                (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
204                (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
205            ],
206            [
207                (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
208                (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
209                (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
210            ],
211            [
212                (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
213                (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
214                (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
215            ],
216        ]
217    }
218}
219
220fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
221    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
222}
223
224fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
225    [
226        a[1] * b[2] - a[2] * b[1],
227        a[2] * b[0] - a[0] * b[2],
228        a[0] * b[1] - a[1] * b[0],
229    ]
230}
231
232fn norm3(v: [f64; 3]) -> f64 {
233    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
234}
235
236fn angle_between(a: [f64; 3], b: [f64; 3]) -> f64 {
237    let cos_angle = dot3(a, b) / (norm3(a) * norm3(b));
238    cos_angle.clamp(-1.0, 1.0).acos()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_cubic_cell_volume() {
247        let cell = UnitCell::cubic(10.0);
248        assert!((cell.volume() - 1000.0).abs() < 1e-10);
249    }
250
251    #[test]
252    fn test_cubic_parameters() {
253        let cell = UnitCell::cubic(5.0);
254        let p = cell.parameters();
255        assert!((p.a - 5.0).abs() < 1e-10);
256        assert!((p.b - 5.0).abs() < 1e-10);
257        assert!((p.c - 5.0).abs() < 1e-10);
258        assert!((p.alpha - 90.0).abs() < 1e-10);
259        assert!((p.beta - 90.0).abs() < 1e-10);
260        assert!((p.gamma - 90.0).abs() < 1e-10);
261    }
262
263    #[test]
264    fn test_frac_cart_roundtrip() {
265        let cell = UnitCell::from_parameters(&CellParameters {
266            a: 10.0,
267            b: 12.0,
268            c: 8.0,
269            alpha: 90.0,
270            beta: 90.0,
271            gamma: 120.0,
272        });
273        let frac = [0.3, 0.4, 0.7];
274        let cart = cell.frac_to_cart(frac);
275        let back = cell.cart_to_frac(cart);
276        for i in 0..3 {
277            assert!(
278                (frac[i] - back[i]).abs() < 1e-10,
279                "Roundtrip failed at {i}: {:.6} vs {:.6}",
280                frac[i],
281                back[i]
282            );
283        }
284    }
285
286    #[test]
287    fn test_wrap_frac() {
288        let wrapped = UnitCell::wrap_frac([1.3, -0.2, 2.7]);
289        assert!((wrapped[0] - 0.3).abs() < 1e-10);
290        assert!((wrapped[1] - 0.8).abs() < 1e-10);
291        assert!((wrapped[2] - 0.7).abs() < 1e-10);
292    }
293
294    #[test]
295    fn test_minimum_image_distance_cubic() {
296        let cell = UnitCell::cubic(10.0);
297        // Two points: one at (1,0,0) and one at (9,0,0)
298        // Under PBC, minimum distance should be 2.0 Å (not 8.0)
299        let dist = cell.minimum_image_distance([1.0, 0.0, 0.0], [9.0, 0.0, 0.0]);
300        assert!(
301            (dist - 2.0).abs() < 1e-10,
302            "Minimum image distance should be 2.0, got {dist:.6}"
303        );
304    }
305
306    #[test]
307    fn test_supercell_offsets() {
308        let cell = UnitCell::cubic(5.0);
309        let (super_cell, offsets) = cell.supercell(2, 2, 2);
310        assert_eq!(offsets.len(), 8);
311        assert!((super_cell.lattice[0][0] - 10.0).abs() < 1e-10);
312        assert!((super_cell.volume() - 5.0f64.powi(3) * 8.0).abs() < 1e-6);
313    }
314
315    #[test]
316    fn test_from_parameters_orthorhombic() {
317        let p = CellParameters {
318            a: 5.0,
319            b: 7.0,
320            c: 9.0,
321            alpha: 90.0,
322            beta: 90.0,
323            gamma: 90.0,
324        };
325        let cell = UnitCell::from_parameters(&p);
326        assert!((cell.volume() - 315.0).abs() < 1e-6);
327        let back = cell.parameters();
328        assert!((back.a - 5.0).abs() < 1e-6);
329        assert!((back.b - 7.0).abs() < 1e-6);
330        assert!((back.c - 9.0).abs() < 1e-6);
331    }
332}