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    ///
47    /// Returns a degenerate cell if angles do not produce positive volume.
48    pub fn from_parameters(params: &CellParameters) -> Self {
49        let alpha = params.alpha.to_radians();
50        let beta = params.beta.to_radians();
51        let gamma = params.gamma.to_radians();
52
53        let cos_alpha = alpha.cos();
54        let cos_beta = beta.cos();
55        let cos_gamma = gamma.cos();
56        let sin_gamma = gamma.sin();
57
58        // Validate: angles must produce positive volume.
59        // The volume factor is: 1 - cos²α - cos²β - cos²γ + 2·cosα·cosβ·cosγ > 0
60        let vol_factor = 1.0 - cos_alpha * cos_alpha - cos_beta * cos_beta - cos_gamma * cos_gamma
61            + 2.0 * cos_alpha * cos_beta * cos_gamma;
62        if vol_factor <= 0.0 {
63            eprintln!(
64                "Warning: cell angles α={:.1}° β={:.1}° γ={:.1}° produce non-positive volume",
65                params.alpha, params.beta, params.gamma
66            );
67        }
68
69        // γ must be in (0°, 180°) for a valid cell
70        if sin_gamma.abs() < 1e-12 {
71            return Self {
72                lattice: [
73                    [params.a, 0.0, 0.0],
74                    [0.0, params.b, 0.0],
75                    [0.0, 0.0, params.c],
76                ],
77            };
78        }
79
80        // Standard crystallographic convention: a along x, b in xy-plane
81        let ax = params.a;
82        let bx = params.b * cos_gamma;
83        let by = params.b * sin_gamma;
84        let cx = params.c * cos_beta;
85        let cy = params.c * (cos_alpha - cos_beta * cos_gamma) / sin_gamma;
86        let cz_sq = params.c * params.c - cx * cx - cy * cy;
87
88        // Negative cz² means angles are incompatible — clamp to avoid NaN
89        let cz = if cz_sq > 0.0 { cz_sq.sqrt() } else { 0.0 };
90
91        Self {
92            lattice: [[ax, 0.0, 0.0], [bx, by, 0.0], [cx, cy, cz]],
93        }
94    }
95
96    /// Extract crystallographic parameters from lattice vectors.
97    pub fn parameters(&self) -> CellParameters {
98        let a_vec = self.lattice[0];
99        let b_vec = self.lattice[1];
100        let c_vec = self.lattice[2];
101
102        let a = norm3(a_vec);
103        let b = norm3(b_vec);
104        let c = norm3(c_vec);
105
106        let alpha = angle_between(b_vec, c_vec).to_degrees();
107        let beta = angle_between(a_vec, c_vec).to_degrees();
108        let gamma = angle_between(a_vec, b_vec).to_degrees();
109
110        CellParameters {
111            a,
112            b,
113            c,
114            alpha,
115            beta,
116            gamma,
117        }
118    }
119
120    /// Unit cell volume: |a · (b × c)|.
121    pub fn volume(&self) -> f64 {
122        let a = self.lattice[0];
123        let b = self.lattice[1];
124        let c = self.lattice[2];
125        // b × c
126        let bxc = cross3(b, c);
127        dot3(a, bxc).abs()
128    }
129
130    /// Convert fractional coordinates to Cartesian (Å).
131    /// r_cart = f0 * a + f1 * b + f2 * c
132    pub fn frac_to_cart(&self, frac: [f64; 3]) -> [f64; 3] {
133        let a = self.lattice[0];
134        let b = self.lattice[1];
135        let c = self.lattice[2];
136        [
137            frac[0] * a[0] + frac[1] * b[0] + frac[2] * c[0],
138            frac[0] * a[1] + frac[1] * b[1] + frac[2] * c[1],
139            frac[0] * a[2] + frac[1] * b[2] + frac[2] * c[2],
140        ]
141    }
142
143    /// Convert Cartesian coordinates (Å) to fractional.
144    /// frac_to_cart computes: r = f[0]*a + f[1]*b + f[2]*c = M^T · f
145    /// So: f = (M^T)^{-1} · r = (M^{-1})^T · r
146    pub fn cart_to_frac(&self, cart: [f64; 3]) -> [f64; 3] {
147        let inv = self.inverse_matrix();
148        // We need (M^{-1})^T applied to cart, so use columns of inv as rows
149        [
150            inv[0][0] * cart[0] + inv[1][0] * cart[1] + inv[2][0] * cart[2],
151            inv[0][1] * cart[0] + inv[1][1] * cart[1] + inv[2][1] * cart[2],
152            inv[0][2] * cart[0] + inv[1][2] * cart[1] + inv[2][2] * cart[2],
153        ]
154    }
155
156    /// Wrap fractional coordinates into [0, 1) — periodic boundary conditions.
157    pub fn wrap_frac(frac: [f64; 3]) -> [f64; 3] {
158        [
159            frac[0] - frac[0].floor(),
160            frac[1] - frac[1].floor(),
161            frac[2] - frac[2].floor(),
162        ]
163    }
164
165    /// Wrap Cartesian coordinates into the unit cell.
166    pub fn wrap_cart(&self, cart: [f64; 3]) -> [f64; 3] {
167        let frac = self.cart_to_frac(cart);
168        let wrapped = Self::wrap_frac(frac);
169        self.frac_to_cart(wrapped)
170    }
171
172    /// Minimum-image distance between two Cartesian points under PBC.
173    pub fn minimum_image_distance(&self, a: [f64; 3], b: [f64; 3]) -> f64 {
174        let fa = self.cart_to_frac(a);
175        let fb = self.cart_to_frac(b);
176        let mut df = [fa[0] - fb[0], fa[1] - fb[1], fa[2] - fb[2]];
177        // Apply minimum image convention
178        for d in &mut df {
179            *d -= d.round();
180        }
181        let dc = self.frac_to_cart(df);
182        norm3(dc)
183    }
184
185    /// Build a supercell by replicating (na × nb × nc) times.
186    /// Returns new lattice and list of fractional offsets for each image.
187    pub fn supercell(&self, na: usize, nb: usize, nc: usize) -> (UnitCell, Vec<[f64; 3]>) {
188        let new_lattice = [
189            [
190                self.lattice[0][0] * na as f64,
191                self.lattice[0][1] * na as f64,
192                self.lattice[0][2] * na as f64,
193            ],
194            [
195                self.lattice[1][0] * nb as f64,
196                self.lattice[1][1] * nb as f64,
197                self.lattice[1][2] * nb as f64,
198            ],
199            [
200                self.lattice[2][0] * nc as f64,
201                self.lattice[2][1] * nc as f64,
202                self.lattice[2][2] * nc as f64,
203            ],
204        ];
205
206        let mut offsets = Vec::with_capacity(na * nb * nc);
207        for ia in 0..na {
208            for ib in 0..nb {
209                for ic in 0..nc {
210                    offsets.push([ia as f64, ib as f64, ic as f64]);
211                }
212            }
213        }
214
215        (UnitCell::new(new_lattice), offsets)
216    }
217
218    /// Inverse of the 3×3 matrix whose rows are the lattice vectors.
219    pub(crate) fn inverse_matrix(&self) -> [[f64; 3]; 3] {
220        let m = self.lattice;
221        let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
222            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
223            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
224
225        let inv_det = 1.0 / det;
226
227        [
228            [
229                (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
230                (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
231                (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
232            ],
233            [
234                (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
235                (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
236                (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
237            ],
238            [
239                (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
240                (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
241                (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
242            ],
243        ]
244    }
245}
246
247fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
248    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
249}
250
251fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
252    [
253        a[1] * b[2] - a[2] * b[1],
254        a[2] * b[0] - a[0] * b[2],
255        a[0] * b[1] - a[1] * b[0],
256    ]
257}
258
259fn norm3(v: [f64; 3]) -> f64 {
260    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
261}
262
263fn angle_between(a: [f64; 3], b: [f64; 3]) -> f64 {
264    let cos_angle = dot3(a, b) / (norm3(a) * norm3(b));
265    cos_angle.clamp(-1.0, 1.0).acos()
266}
267
268/// Result of powder XRD simulation.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct PowderXrdResult {
271    /// 2θ angles (degrees) for each reflection.
272    pub two_theta: Vec<f64>,
273    /// Relative intensities (0–100).
274    pub intensities: Vec<f64>,
275    /// Miller indices (h, k, l) for each reflection.
276    pub miller_indices: Vec<[i32; 3]>,
277    /// d-spacings (Å) for each reflection.
278    pub d_spacings: Vec<f64>,
279}
280
281/// Simulate powder X-ray diffraction pattern from unit cell and atoms.
282///
283/// Uses Bragg's law: $n\lambda = 2d\sin\theta$ with Cu-Kα radiation (λ=1.5406 Å).
284///
285/// `cell`: unit cell definition.
286/// `elements`: atomic numbers of all atoms.
287/// `frac_coords`: fractional coordinates of all atoms.
288/// `two_theta_max`: maximum 2θ angle in degrees (default 90°).
289pub fn simulate_powder_xrd(
290    cell: &UnitCell,
291    elements: &[u8],
292    frac_coords: &[[f64; 3]],
293    two_theta_max: f64,
294) -> PowderXrdResult {
295    let lambda = 1.5406; // Cu-Kα wavelength in Å
296
297    // Max h, k, l to consider
298    let params = cell.parameters();
299    let d_min = lambda / (2.0 * (two_theta_max.to_radians() / 2.0).sin());
300    let h_max = (params.a / d_min).ceil() as i32 + 1;
301    let k_max = (params.b / d_min).ceil() as i32 + 1;
302    let l_max = (params.c / d_min).ceil() as i32 + 1;
303
304    let mut reflections = Vec::new();
305
306    for h in -h_max..=h_max {
307        for k in -k_max..=k_max {
308            for l in -l_max..=l_max {
309                if h == 0 && k == 0 && l == 0 {
310                    continue;
311                }
312
313                // d-spacing from reciprocal lattice
314                let g = [
315                    h as f64 * cell.inverse_matrix()[0][0]
316                        + k as f64 * cell.inverse_matrix()[1][0]
317                        + l as f64 * cell.inverse_matrix()[2][0],
318                    h as f64 * cell.inverse_matrix()[0][1]
319                        + k as f64 * cell.inverse_matrix()[1][1]
320                        + l as f64 * cell.inverse_matrix()[2][1],
321                    h as f64 * cell.inverse_matrix()[0][2]
322                        + k as f64 * cell.inverse_matrix()[1][2]
323                        + l as f64 * cell.inverse_matrix()[2][2],
324                ];
325                let g_len = norm3(g);
326                let d = 1.0 / g_len;
327
328                // Bragg: 2d sin(θ) = λ → sin(θ) = λ/(2d)
329                let sin_theta = lambda / (2.0 * d);
330                if !(0.0..=1.0).contains(&sin_theta) {
331                    continue;
332                }
333                let two_theta_val = 2.0 * sin_theta.asin().to_degrees();
334                if !(1.0..=two_theta_max).contains(&two_theta_val) {
335                    continue;
336                }
337
338                // Structure factor (simplified — uses atomic number as scattering factor)
339                let mut f_real = 0.0f64;
340                let mut f_imag = 0.0f64;
341                for (i, &elem) in elements.iter().enumerate() {
342                    let f_atom = elem as f64; // Approximation: f ≈ Z for low angles
343                    let phase = 2.0
344                        * std::f64::consts::PI
345                        * (h as f64 * frac_coords[i][0]
346                            + k as f64 * frac_coords[i][1]
347                            + l as f64 * frac_coords[i][2]);
348                    f_real += f_atom * phase.cos();
349                    f_imag += f_atom * phase.sin();
350                }
351                let intensity = f_real * f_real + f_imag * f_imag;
352
353                if intensity > 1e-6 {
354                    reflections.push((two_theta_val, intensity, [h, k, l], d));
355                }
356            }
357        }
358    }
359
360    // Merge equivalent reflections (within 0.01° of 2θ)
361    reflections.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
362
363    let mut merged_theta: Vec<f64> = Vec::new();
364    let mut merged_intensity = Vec::new();
365    let mut merged_hkl = Vec::new();
366    let mut merged_d = Vec::new();
367
368    for (tt, intens, hkl, d) in &reflections {
369        if let Some(last_tt) = merged_theta.last() {
370            if (*tt - *last_tt).abs() < 0.01 {
371                let idx = merged_intensity.len() - 1;
372                merged_intensity[idx] += intens;
373                continue;
374            }
375        }
376        merged_theta.push(*tt);
377        merged_intensity.push(*intens);
378        merged_hkl.push(*hkl);
379        merged_d.push(*d);
380    }
381
382    // Normalize intensities to 0–100
383    let max_i = merged_intensity
384        .iter()
385        .cloned()
386        .fold(0.0f64, f64::max)
387        .max(1e-10);
388    for i in merged_intensity.iter_mut() {
389        *i = *i / max_i * 100.0;
390    }
391
392    PowderXrdResult {
393        two_theta: merged_theta,
394        intensities: merged_intensity,
395        miller_indices: merged_hkl,
396        d_spacings: merged_d,
397    }
398}
399
400/// A symmetry operation (rotation + translation in fractional coords).
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct SymmetryOperation {
403    /// 3×3 rotation matrix (integer in fractional space).
404    pub rotation: [[i32; 3]; 3],
405    /// Translation vector in fractional coordinates.
406    pub translation: [f64; 3],
407    /// Human-readable label (e.g., "x,y,z" or "-x,-y,z").
408    pub label: String,
409}
410
411/// Space group definition with symmetry operations.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct SpaceGroup {
414    /// International Tables number (1–230).
415    pub number: u16,
416    /// Hermann-Mauguin symbol.
417    pub symbol: String,
418    /// Crystal system.
419    pub crystal_system: String,
420    /// Symmetry operations (general positions).
421    pub operations: Vec<SymmetryOperation>,
422}
423
424/// Get symmetry operations for common space groups.
425pub fn get_space_group(number: u16) -> Option<SpaceGroup> {
426    match number {
427        1 => Some(SpaceGroup {
428            number: 1,
429            symbol: "P1".to_string(),
430            crystal_system: "triclinic".to_string(),
431            operations: vec![SymmetryOperation {
432                rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
433                translation: [0.0, 0.0, 0.0],
434                label: "x,y,z".to_string(),
435            }],
436        }),
437        2 => Some(SpaceGroup {
438            number: 2,
439            symbol: "P-1".to_string(),
440            crystal_system: "triclinic".to_string(),
441            operations: vec![
442                SymmetryOperation {
443                    rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
444                    translation: [0.0, 0.0, 0.0],
445                    label: "x,y,z".to_string(),
446                },
447                SymmetryOperation {
448                    rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, -1]],
449                    translation: [0.0, 0.0, 0.0],
450                    label: "-x,-y,-z".to_string(),
451                },
452            ],
453        }),
454        14 => Some(SpaceGroup {
455            number: 14,
456            symbol: "P2_1/c".to_string(),
457            crystal_system: "monoclinic".to_string(),
458            operations: vec![
459                SymmetryOperation {
460                    rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
461                    translation: [0.0, 0.0, 0.0],
462                    label: "x,y,z".to_string(),
463                },
464                SymmetryOperation {
465                    rotation: [[-1, 0, 0], [0, 1, 0], [0, 0, -1]],
466                    translation: [0.0, 0.5, 0.5],
467                    label: "-x,y+1/2,-z+1/2".to_string(),
468                },
469                SymmetryOperation {
470                    rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, -1]],
471                    translation: [0.0, 0.0, 0.0],
472                    label: "-x,-y,-z".to_string(),
473                },
474                SymmetryOperation {
475                    rotation: [[1, 0, 0], [0, -1, 0], [0, 0, 1]],
476                    translation: [0.0, 0.5, 0.5],
477                    label: "x,-y+1/2,z+1/2".to_string(),
478                },
479            ],
480        }),
481        225 => Some(SpaceGroup {
482            number: 225,
483            symbol: "Fm-3m".to_string(),
484            crystal_system: "cubic".to_string(),
485            operations: vec![
486                SymmetryOperation {
487                    rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
488                    translation: [0.0, 0.0, 0.0],
489                    label: "x,y,z".to_string(),
490                },
491                SymmetryOperation {
492                    rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, 1]],
493                    translation: [0.0, 0.0, 0.0],
494                    label: "-x,-y,z".to_string(),
495                },
496                SymmetryOperation {
497                    rotation: [[-1, 0, 0], [0, 1, 0], [0, 0, -1]],
498                    translation: [0.0, 0.0, 0.0],
499                    label: "-x,y,-z".to_string(),
500                },
501                SymmetryOperation {
502                    rotation: [[1, 0, 0], [0, -1, 0], [0, 0, -1]],
503                    translation: [0.0, 0.0, 0.0],
504                    label: "x,-y,-z".to_string(),
505                },
506            ],
507        }),
508        _ => None,
509    }
510}
511
512/// Apply a symmetry operation to a fractional coordinate.
513pub fn apply_symmetry(op: &SymmetryOperation, frac: [f64; 3]) -> [f64; 3] {
514    let r = op.rotation;
515    let t = op.translation;
516    [
517        r[0][0] as f64 * frac[0] + r[0][1] as f64 * frac[1] + r[0][2] as f64 * frac[2] + t[0],
518        r[1][0] as f64 * frac[0] + r[1][1] as f64 * frac[1] + r[1][2] as f64 * frac[2] + t[1],
519        r[2][0] as f64 * frac[0] + r[2][1] as f64 * frac[1] + r[2][2] as f64 * frac[2] + t[2],
520    ]
521}
522
523/// Generate all symmetry-equivalent positions from a set of asymmetric unit atoms.
524pub fn expand_by_symmetry(
525    space_group: &SpaceGroup,
526    frac_coords: &[[f64; 3]],
527    elements: &[u8],
528) -> (Vec<[f64; 3]>, Vec<u8>) {
529    let mut all_coords = Vec::new();
530    let mut all_elements = Vec::new();
531
532    for (i, &fc) in frac_coords.iter().enumerate() {
533        for op in &space_group.operations {
534            let new_fc = apply_symmetry(op, fc);
535            let wrapped = UnitCell::wrap_frac(new_fc);
536
537            // Check for duplicate (within tolerance)
538            let is_dup = all_coords.iter().any(|existing: &[f64; 3]| {
539                let dx = (existing[0] - wrapped[0]).abs();
540                let dy = (existing[1] - wrapped[1]).abs();
541                let dz = (existing[2] - wrapped[2]).abs();
542                // Handle PBC wrap
543                let dx = dx.min(1.0 - dx);
544                let dy = dy.min(1.0 - dy);
545                let dz = dz.min(1.0 - dz);
546                dx < 0.01 && dy < 0.01 && dz < 0.01
547            });
548
549            if !is_dup {
550                all_coords.push(wrapped);
551                all_elements.push(elements[i]);
552            }
553        }
554    }
555
556    (all_coords, all_elements)
557}
558
559/// Result of geometric pore analysis.
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct PorosityResult {
562    /// Geometric pore volume (ų).
563    pub pore_volume: f64,
564    /// Porosity fraction (void / total volume).
565    pub porosity: f64,
566    /// Largest cavity diameter (Å) — largest sphere that fits in a pore.
567    pub largest_cavity_diameter: f64,
568    /// Pore-limiting diameter (Å) — largest sphere that can pass through.
569    pub pore_limiting_diameter: f64,
570}
571
572/// Compute geometric porosity using grid-based cavity detection.
573///
574/// Probes the unit cell with a grid and marks points as "void" if they are
575/// farther than any atom's van der Waals radius + probe radius.
576pub fn compute_porosity(
577    cell: &UnitCell,
578    elements: &[u8],
579    frac_coords: &[[f64; 3]],
580    probe_radius: f64,
581    grid_spacing: f64,
582) -> PorosityResult {
583    let params = cell.parameters();
584    let nx = (params.a / grid_spacing).ceil() as usize;
585    let ny = (params.b / grid_spacing).ceil() as usize;
586    let nz = (params.c / grid_spacing).ceil() as usize;
587
588    let total_points = nx * ny * nz;
589    let mut void_count = 0usize;
590    let mut max_void_dist = 0.0f64;
591    let mut min_void_dist = f64::INFINITY;
592
593    // Precompute Cartesian atom positions
594    let atom_positions: Vec<[f64; 3]> = frac_coords
595        .iter()
596        .map(|&fc| cell.frac_to_cart(fc))
597        .collect();
598    let vdw_radii: Vec<f64> = elements.iter().map(|&z| vdw_radius(z)).collect();
599
600    for ix in 0..nx {
601        for iy in 0..ny {
602            for iz in 0..nz {
603                let frac = [
604                    (ix as f64 + 0.5) / nx as f64,
605                    (iy as f64 + 0.5) / ny as f64,
606                    (iz as f64 + 0.5) / nz as f64,
607                ];
608                let cart = cell.frac_to_cart(frac);
609
610                // Find minimum distance to any atom surface
611                let mut min_surface_dist = f64::INFINITY;
612                for (j, &pos) in atom_positions.iter().enumerate() {
613                    let d = cell.minimum_image_distance(cart, pos);
614                    let surface_d = d - vdw_radii[j];
615                    min_surface_dist = min_surface_dist.min(surface_d);
616                }
617
618                if min_surface_dist > probe_radius {
619                    void_count += 1;
620                    max_void_dist = max_void_dist.max(min_surface_dist);
621                    min_void_dist = min_void_dist.min(min_surface_dist);
622                }
623            }
624        }
625    }
626
627    let vol = cell.volume();
628    let voxel_vol = vol / total_points as f64;
629    let pore_volume = void_count as f64 * voxel_vol;
630    let porosity = void_count as f64 / total_points as f64;
631
632    PorosityResult {
633        pore_volume,
634        porosity,
635        largest_cavity_diameter: if max_void_dist > 0.0 {
636            2.0 * max_void_dist
637        } else {
638            0.0
639        },
640        pore_limiting_diameter: if min_void_dist < f64::INFINITY && void_count > 0 {
641            2.0 * min_void_dist
642        } else {
643            0.0
644        },
645    }
646}
647
648/// Van der Waals radius for common elements (Å).
649fn vdw_radius(z: u8) -> f64 {
650    match z {
651        1 => 1.20,
652        6 => 1.70,
653        7 => 1.55,
654        8 => 1.52,
655        9 => 1.47,
656        15 => 1.80,
657        16 => 1.80,
658        17 => 1.75,
659        22 => 2.00, // Ti
660        26 => 2.00, // Fe
661        29 => 1.40, // Cu
662        30 => 1.39, // Zn
663        35 => 1.85,
664        53 => 1.98,
665        _ => 1.80,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn test_cubic_cell_volume() {
675        let cell = UnitCell::cubic(10.0);
676        assert!((cell.volume() - 1000.0).abs() < 1e-10);
677    }
678
679    #[test]
680    fn test_cubic_parameters() {
681        let cell = UnitCell::cubic(5.0);
682        let p = cell.parameters();
683        assert!((p.a - 5.0).abs() < 1e-10);
684        assert!((p.b - 5.0).abs() < 1e-10);
685        assert!((p.c - 5.0).abs() < 1e-10);
686        assert!((p.alpha - 90.0).abs() < 1e-10);
687        assert!((p.beta - 90.0).abs() < 1e-10);
688        assert!((p.gamma - 90.0).abs() < 1e-10);
689    }
690
691    #[test]
692    fn test_frac_cart_roundtrip() {
693        let cell = UnitCell::from_parameters(&CellParameters {
694            a: 10.0,
695            b: 12.0,
696            c: 8.0,
697            alpha: 90.0,
698            beta: 90.0,
699            gamma: 120.0,
700        });
701        let frac = [0.3, 0.4, 0.7];
702        let cart = cell.frac_to_cart(frac);
703        let back = cell.cart_to_frac(cart);
704        for i in 0..3 {
705            assert!(
706                (frac[i] - back[i]).abs() < 1e-10,
707                "Roundtrip failed at {i}: {:.6} vs {:.6}",
708                frac[i],
709                back[i]
710            );
711        }
712    }
713
714    #[test]
715    fn test_wrap_frac() {
716        let wrapped = UnitCell::wrap_frac([1.3, -0.2, 2.7]);
717        assert!((wrapped[0] - 0.3).abs() < 1e-10);
718        assert!((wrapped[1] - 0.8).abs() < 1e-10);
719        assert!((wrapped[2] - 0.7).abs() < 1e-10);
720    }
721
722    #[test]
723    fn test_minimum_image_distance_cubic() {
724        let cell = UnitCell::cubic(10.0);
725        // Two points: one at (1,0,0) and one at (9,0,0)
726        // Under PBC, minimum distance should be 2.0 Å (not 8.0)
727        let dist = cell.minimum_image_distance([1.0, 0.0, 0.0], [9.0, 0.0, 0.0]);
728        assert!(
729            (dist - 2.0).abs() < 1e-10,
730            "Minimum image distance should be 2.0, got {dist:.6}"
731        );
732    }
733
734    #[test]
735    fn test_supercell_offsets() {
736        let cell = UnitCell::cubic(5.0);
737        let (super_cell, offsets) = cell.supercell(2, 2, 2);
738        assert_eq!(offsets.len(), 8);
739        assert!((super_cell.lattice[0][0] - 10.0).abs() < 1e-10);
740        assert!((super_cell.volume() - 5.0f64.powi(3) * 8.0).abs() < 1e-6);
741    }
742
743    #[test]
744    fn test_from_parameters_orthorhombic() {
745        let p = CellParameters {
746            a: 5.0,
747            b: 7.0,
748            c: 9.0,
749            alpha: 90.0,
750            beta: 90.0,
751            gamma: 90.0,
752        };
753        let cell = UnitCell::from_parameters(&p);
754        assert!((cell.volume() - 315.0).abs() < 1e-6);
755        let back = cell.parameters();
756        assert!((back.a - 5.0).abs() < 1e-6);
757        assert!((back.b - 7.0).abs() < 1e-6);
758        assert!((back.c - 9.0).abs() < 1e-6);
759    }
760}