Skip to main content

oxiphysics_io/
crystallography_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Crystallography file format I/O.
5//!
6//! Provides parsers and writers for common crystallography formats:
7//! - CIF (Crystallographic Information File)
8//! - VASP POSCAR/CONTCAR (stub)
9//! - XRD pattern simulation and analysis
10
11#![allow(dead_code)]
12
13use crate::{Error, Result};
14use std::collections::HashMap;
15
16// ---------------------------------------------------------------------------
17// Data structures
18// ---------------------------------------------------------------------------
19
20/// A single atom site in a crystal structure.
21#[derive(Debug, Clone)]
22pub struct CrystalAtom {
23    /// Chemical element symbol (e.g. "Fe", "O").
24    pub element: String,
25    /// Fractional coordinates within the unit cell \[a, b, c\].
26    pub fractional_pos: [f64; 3],
27    /// Site occupancy (0.0 – 1.0).
28    pub occupancy: f64,
29    /// Isotropic displacement parameter (B-factor) in Ų.
30    pub b_factor: f64,
31}
32
33/// A periodic crystal structure described by its lattice and atom list.
34#[derive(Debug, Clone)]
35pub struct CrystalStructure {
36    /// Lattice vectors as row matrix: `lattice[i]` is the i-th basis vector in Å.
37    pub lattice: [[f64; 3]; 3],
38    /// List of atoms in the asymmetric unit (or full unit cell).
39    pub atoms: Vec<CrystalAtom>,
40    /// International Tables space group number (1–230).
41    pub space_group: u16,
42}
43
44/// Parsed X-ray diffraction pattern.
45#[derive(Debug, Clone)]
46pub struct XrdPattern {
47    /// 2θ angles in degrees.
48    pub two_theta: Vec<f64>,
49    /// Intensity values (arbitrary units).
50    pub intensity: Vec<f64>,
51}
52
53// ---------------------------------------------------------------------------
54// CIF parser
55// ---------------------------------------------------------------------------
56
57/// Parser for CIF (Crystallographic Information File) format.
58#[derive(Debug, Default)]
59pub struct CifParser;
60
61impl CifParser {
62    /// Create a new `CifParser`.
63    pub fn new() -> Self {
64        Self
65    }
66
67    /// Parse a CIF file `content` and return a [`CrystalStructure`].
68    ///
69    /// Handles `_cell_length_a/b/c`, `_cell_angle_alpha/beta/gamma`, and
70    /// `_atom_site_*` loop blocks.
71    pub fn parse(&self, content: &str) -> Result<CrystalStructure> {
72        let mut kv: HashMap<String, String> = HashMap::new();
73        let mut in_loop = false;
74        let mut loop_headers: Vec<String> = Vec::new();
75        let mut loop_rows: Vec<Vec<String>> = Vec::new();
76        let mut current_row: Vec<String> = Vec::new();
77
78        for raw_line in content.lines() {
79            let line = raw_line.trim();
80            // Skip comments and empty lines
81            if line.is_empty() || line.starts_with('#') {
82                continue;
83            }
84
85            if line.eq_ignore_ascii_case("loop_") {
86                // Flush any previous loop
87                if in_loop && !current_row.is_empty() {
88                    loop_rows.push(current_row.clone());
89                    current_row.clear();
90                }
91                in_loop = true;
92                loop_headers.clear();
93                loop_rows.clear();
94                continue;
95            }
96
97            if in_loop {
98                if line.starts_with('_') {
99                    // Still collecting header names
100                    let key = line.split_whitespace().next().unwrap_or("").to_lowercase();
101                    loop_headers.push(key);
102                    continue;
103                }
104                // Data row
105                let tokens = Self::tokenize_line(line);
106                for tok in tokens {
107                    current_row.push(tok);
108                    if current_row.len() == loop_headers.len() {
109                        loop_rows.push(current_row.clone());
110                        current_row.clear();
111                    }
112                }
113                // Check if the next line starts a new block
114                continue;
115            }
116
117            // Key-value pair
118            if line.starts_with('_') {
119                let tokens = Self::tokenize_line(line);
120                if tokens.len() >= 2 {
121                    kv.insert(tokens[0].to_lowercase(), tokens[1].clone());
122                } else if tokens.len() == 1 {
123                    // Value on next line — not handled fully; store empty
124                    kv.insert(tokens[0].to_lowercase(), String::new());
125                }
126            }
127        }
128
129        // Flush last loop row
130        if in_loop && !current_row.is_empty() {
131            loop_rows.push(current_row);
132        }
133
134        // Extract cell parameters
135        let a = Self::parse_float_kv(&kv, "_cell_length_a").unwrap_or(1.0);
136        let b = Self::parse_float_kv(&kv, "_cell_length_b").unwrap_or(1.0);
137        let c = Self::parse_float_kv(&kv, "_cell_length_c").unwrap_or(1.0);
138        let alpha = Self::parse_float_kv(&kv, "_cell_angle_alpha")
139            .unwrap_or(90.0)
140            .to_radians();
141        let beta = Self::parse_float_kv(&kv, "_cell_angle_beta")
142            .unwrap_or(90.0)
143            .to_radians();
144        let gamma = Self::parse_float_kv(&kv, "_cell_angle_gamma")
145            .unwrap_or(90.0)
146            .to_radians();
147
148        let lattice = Self::build_lattice(a, b, c, alpha, beta, gamma);
149
150        // Space group
151        let space_group = kv
152            .get("_space_group_it_number")
153            .or_else(|| kv.get("_symmetry_int_tables_number"))
154            .and_then(|s| s.parse::<u16>().ok())
155            .unwrap_or(1);
156
157        // Extract atoms from loop
158        let atoms = Self::extract_atoms(&loop_headers, &loop_rows);
159
160        Ok(CrystalStructure {
161            lattice,
162            atoms,
163            space_group,
164        })
165    }
166
167    /// Build a lattice matrix from cell parameters (conventional setting).
168    fn build_lattice(a: f64, b: f64, c: f64, alpha: f64, beta: f64, gamma: f64) -> [[f64; 3]; 3] {
169        let cos_a = alpha.cos();
170        let cos_b = beta.cos();
171        let cos_g = gamma.cos();
172        let sin_g = gamma.sin();
173
174        // Standard triclinic convention
175        let cx = cos_b;
176        let cy = (cos_a - cos_b * cos_g) / sin_g;
177        let cz = (1.0 - cx * cx - cy * cy).max(0.0).sqrt();
178
179        [
180            [a, 0.0, 0.0],
181            [b * cos_g, b * sin_g, 0.0],
182            [c * cx, c * cy, c * cz],
183        ]
184    }
185
186    /// Tokenize a CIF line, respecting quoted strings.
187    fn tokenize_line(line: &str) -> Vec<String> {
188        let mut tokens = Vec::new();
189        let mut chars = line.char_indices().peekable();
190        while let Some((_, c)) = chars.peek().copied() {
191            if c.is_whitespace() {
192                chars.next();
193                continue;
194            }
195            if c == '\'' || c == '"' {
196                chars.next(); // consume opening quote
197                let mut tok = String::new();
198                for (_, ch) in chars.by_ref() {
199                    if ch == c {
200                        break;
201                    }
202                    tok.push(ch);
203                }
204                tokens.push(tok);
205            } else {
206                let mut tok = String::new();
207                while let Some(&(_, ch)) = chars.peek() {
208                    if ch.is_whitespace() {
209                        break;
210                    }
211                    tok.push(ch);
212                    chars.next();
213                }
214                tokens.push(tok);
215            }
216        }
217        tokens
218    }
219
220    /// Parse a float from a key-value map entry, ignoring standard uncertainties like "1.234(5)".
221    fn parse_float_kv(kv: &HashMap<String, String>, key: &str) -> Option<f64> {
222        let s = kv.get(key)?;
223        Self::strip_su(s).parse().ok()
224    }
225
226    /// Strip standard uncertainty "(n)" from a numeric string.
227    fn strip_su(s: &str) -> &str {
228        if let Some(pos) = s.find('(') {
229            &s[..pos]
230        } else {
231            s
232        }
233    }
234
235    /// Extract atom list from loop data.
236    fn extract_atoms(headers: &[String], rows: &[Vec<String>]) -> Vec<CrystalAtom> {
237        // Find column indices
238        let idx = |tag: &str| headers.iter().position(|h| h.contains(tag));
239
240        let i_type_symbol = idx("type_symbol").or_else(|| idx("label"));
241        let i_x = idx("fract_x").or_else(|| idx("x_fract"));
242        let i_y = idx("fract_y").or_else(|| idx("y_fract"));
243        let i_z = idx("fract_z").or_else(|| idx("z_fract"));
244        let i_occ = idx("occupancy");
245        let i_b = idx("b_iso").or_else(|| idx("u_iso"));
246
247        let mut atoms = Vec::new();
248        for row in rows {
249            let get = |idx: Option<usize>| -> &str {
250                idx.and_then(|i| row.get(i).map(|s| s.as_str()))
251                    .unwrap_or("0")
252            };
253
254            let element = i_type_symbol
255                .and_then(|i| row.get(i))
256                .cloned()
257                .unwrap_or_default();
258
259            if element.is_empty() {
260                continue;
261            }
262
263            let x: f64 = Self::strip_su(get(i_x)).parse().unwrap_or(0.0);
264            let y: f64 = Self::strip_su(get(i_y)).parse().unwrap_or(0.0);
265            let z: f64 = Self::strip_su(get(i_z)).parse().unwrap_or(0.0);
266            let occ: f64 = Self::strip_su(get(i_occ)).parse().unwrap_or(1.0);
267            let b: f64 = Self::strip_su(get(i_b)).parse().unwrap_or(0.0);
268
269            atoms.push(CrystalAtom {
270                element,
271                fractional_pos: [x, y, z],
272                occupancy: occ,
273                b_factor: b,
274            });
275        }
276        atoms
277    }
278}
279
280// ---------------------------------------------------------------------------
281// CrystalStructure methods
282// ---------------------------------------------------------------------------
283
284impl CrystalStructure {
285    /// Create a simple cubic structure with given lattice parameter.
286    pub fn new_cubic(a: f64) -> Self {
287        Self {
288            lattice: [[a, 0.0, 0.0], [0.0, a, 0.0], [0.0, 0.0, a]],
289            atoms: Vec::new(),
290            space_group: 1,
291        }
292    }
293
294    /// Convert all atom fractional coordinates to Cartesian coordinates (Å).
295    pub fn to_cartesian(&self) -> Vec<[f64; 3]> {
296        self.atoms
297            .iter()
298            .map(|atom| {
299                let [u, v, w] = atom.fractional_pos;
300                let x = u * self.lattice[0][0] + v * self.lattice[1][0] + w * self.lattice[2][0];
301                let y = u * self.lattice[0][1] + v * self.lattice[1][1] + w * self.lattice[2][1];
302                let z = u * self.lattice[0][2] + v * self.lattice[1][2] + w * self.lattice[2][2];
303                [x, y, z]
304            })
305            .collect()
306    }
307
308    /// Volume of the unit cell in ų (scalar triple product of basis vectors).
309    pub fn volume(&self) -> f64 {
310        let a = self.lattice[0];
311        let b = self.lattice[1];
312        let c = self.lattice[2];
313        // a · (b × c)
314        let bxc = [
315            b[1] * c[2] - b[2] * c[1],
316            b[2] * c[0] - b[0] * c[2],
317            b[0] * c[1] - b[1] * c[0],
318        ];
319        (a[0] * bxc[0] + a[1] * bxc[1] + a[2] * bxc[2]).abs()
320    }
321
322    /// Crystal density in g/cm³.
323    ///
324    /// `molar_mass` is the molar mass of one formula unit in g/mol.
325    /// The number of formula units Z is taken as the atom count.
326    pub fn density(&self, molar_mass: f64) -> f64 {
327        const NA: f64 = 6.022_140_76e23; // Avogadro
328        const ANG3_TO_CM3: f64 = 1e-24;
329        let z = self.atoms.len() as f64;
330        let vol_cm3 = self.volume() * ANG3_TO_CM3;
331        (z * molar_mass) / (NA * vol_cm3)
332    }
333
334    /// Compute the reciprocal lattice vectors (2π convention).
335    ///
336    /// Returns `[[b1x,b1y,b1z\],[b2x,b2y,b2z],[b3x,b3y,b3z]]` in Å⁻¹.
337    pub fn reciprocal_lattice(&self) -> [[f64; 3]; 3] {
338        let a1 = self.lattice[0];
339        let a2 = self.lattice[1];
340        let a3 = self.lattice[2];
341        let vol = self.volume();
342
343        let cross = |u: [f64; 3], v: [f64; 3]| -> [f64; 3] {
344            [
345                u[1] * v[2] - u[2] * v[1],
346                u[2] * v[0] - u[0] * v[2],
347                u[0] * v[1] - u[1] * v[0],
348            ]
349        };
350        let scale = |s: f64, u: [f64; 3]| -> [f64; 3] { [s * u[0], s * u[1], s * u[2]] };
351
352        let b1 = scale(2.0 * std::f64::consts::PI / vol, cross(a2, a3));
353        let b2 = scale(2.0 * std::f64::consts::PI / vol, cross(a3, a1));
354        let b3 = scale(2.0 * std::f64::consts::PI / vol, cross(a1, a2));
355
356        [b1, b2, b3]
357    }
358
359    /// Interplanar d-spacing for Miller indices (h,k,l) in Å.
360    ///
361    /// Uses the general triclinic formula via the reciprocal lattice.
362    pub fn d_spacing(&self, h: i32, k: i32, l: i32) -> f64 {
363        let rec = self.reciprocal_lattice();
364        // G = h*b1 + k*b2 + l*b3
365        let hf = h as f64;
366        let kf = k as f64;
367        let lf = l as f64;
368        let gx = hf * rec[0][0] + kf * rec[1][0] + lf * rec[2][0];
369        let gy = hf * rec[0][1] + kf * rec[1][1] + lf * rec[2][1];
370        let gz = hf * rec[0][2] + kf * rec[1][2] + lf * rec[2][2];
371        let g_mag = (gx * gx + gy * gy + gz * gz).sqrt();
372        if g_mag < 1e-12 {
373            f64::INFINITY
374        } else {
375            2.0 * std::f64::consts::PI / g_mag
376        }
377    }
378
379    /// Compute the kinematic structure factor F(h,k,l) = (Re, Im).
380    ///
381    /// Uses atomic scattering factors approximated by atomic number (simplified).
382    pub fn structure_factor(&self, h: i32, k: i32, l: i32) -> (f64, f64) {
383        let mut re = 0.0_f64;
384        let mut im = 0.0_f64;
385
386        for atom in &self.atoms {
387            let [u, v, w] = atom.fractional_pos;
388            let phase = 2.0 * std::f64::consts::PI * (h as f64 * u + k as f64 * v + l as f64 * w);
389            // Simplified scattering factor: f ≈ atomic_number(element) * exp(-B*sin²θ/λ²)
390            let f = Self::approx_scattering_factor(&atom.element) * atom.occupancy;
391            re += f * phase.cos();
392            im += f * phase.sin();
393        }
394        (re, im)
395    }
396
397    /// Approximate atomic scattering factor by element symbol (f(0)).
398    fn approx_scattering_factor(element: &str) -> f64 {
399        // Very simplified: use atomic number as proxy for f(0)
400        match element.trim_end_matches(|c: char| c.is_ascii_digit()) {
401            "H" => 1.0,
402            "He" => 2.0,
403            "Li" => 3.0,
404            "Be" => 4.0,
405            "B" => 5.0,
406            "C" => 6.0,
407            "N" => 7.0,
408            "O" => 8.0,
409            "F" => 9.0,
410            "Na" => 11.0,
411            "Mg" => 12.0,
412            "Al" => 13.0,
413            "Si" => 14.0,
414            "P" => 15.0,
415            "S" => 16.0,
416            "Cl" => 17.0,
417            "Ar" => 18.0,
418            "K" => 19.0,
419            "Ca" => 20.0,
420            "Ti" => 22.0,
421            "V" => 23.0,
422            "Cr" => 24.0,
423            "Mn" => 25.0,
424            "Fe" => 26.0,
425            "Co" => 27.0,
426            "Ni" => 28.0,
427            "Cu" => 29.0,
428            "Zn" => 30.0,
429            "Ga" => 31.0,
430            "Ge" => 32.0,
431            "As" => 33.0,
432            "Se" => 34.0,
433            "Br" => 35.0,
434            "Sr" => 38.0,
435            "Mo" => 42.0,
436            "Ag" => 47.0,
437            "Sn" => 50.0,
438            "Ba" => 56.0,
439            "La" => 57.0,
440            "W" => 74.0,
441            "Pt" => 78.0,
442            "Au" => 79.0,
443            "Hg" => 80.0,
444            "Pb" => 82.0,
445            "U" => 92.0,
446            _ => 10.0, // fallback
447        }
448    }
449}
450
451// ---------------------------------------------------------------------------
452// CIF writer
453// ---------------------------------------------------------------------------
454
455/// Write a [`CrystalStructure`] to a CIF-formatted string.
456pub fn write_cif(crystal: &CrystalStructure) -> String {
457    let mut out = String::new();
458
459    out.push_str("# CIF written by oxiphysics-io\n");
460    out.push_str("data_oxiphysics\n\n");
461
462    // Cell parameters
463    let a = crystal.lattice[0];
464    let b = crystal.lattice[1];
465    let c = crystal.lattice[2];
466
467    let a_len = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
468    let b_len = (b[0] * b[0] + b[1] * b[1] + b[2] * b[2]).sqrt();
469    let c_len = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
470
471    let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
472    let alpha = if b_len > 0.0 && c_len > 0.0 {
473        (dot(b, c) / (b_len * c_len))
474            .clamp(-1.0, 1.0)
475            .acos()
476            .to_degrees()
477    } else {
478        90.0
479    };
480    let beta = if a_len > 0.0 && c_len > 0.0 {
481        (dot(a, c) / (a_len * c_len))
482            .clamp(-1.0, 1.0)
483            .acos()
484            .to_degrees()
485    } else {
486        90.0
487    };
488    let gamma = if a_len > 0.0 && b_len > 0.0 {
489        (dot(a, b) / (a_len * b_len))
490            .clamp(-1.0, 1.0)
491            .acos()
492            .to_degrees()
493    } else {
494        90.0
495    };
496
497    out.push_str(&format!("_cell_length_a    {a_len:.6}\n"));
498    out.push_str(&format!("_cell_length_b    {b_len:.6}\n"));
499    out.push_str(&format!("_cell_length_c    {c_len:.6}\n"));
500    out.push_str(&format!("_cell_angle_alpha {alpha:.4}\n"));
501    out.push_str(&format!("_cell_angle_beta  {beta:.4}\n"));
502    out.push_str(&format!("_cell_angle_gamma {gamma:.4}\n"));
503    out.push_str(&format!(
504        "_symmetry_int_tables_number {}\n\n",
505        crystal.space_group
506    ));
507
508    // Atom loop
509    out.push_str("loop_\n");
510    out.push_str("  _atom_site_type_symbol\n");
511    out.push_str("  _atom_site_fract_x\n");
512    out.push_str("  _atom_site_fract_y\n");
513    out.push_str("  _atom_site_fract_z\n");
514    out.push_str("  _atom_site_occupancy\n");
515    out.push_str("  _atom_site_b_iso\n");
516
517    for atom in &crystal.atoms {
518        out.push_str(&format!(
519            "  {:4} {:10.6} {:10.6} {:10.6} {:6.4} {:8.4}\n",
520            atom.element,
521            atom.fractional_pos[0],
522            atom.fractional_pos[1],
523            atom.fractional_pos[2],
524            atom.occupancy,
525            atom.b_factor,
526        ));
527    }
528
529    out
530}
531
532// ---------------------------------------------------------------------------
533// VASP POSCAR reader (stub)
534// ---------------------------------------------------------------------------
535
536/// Reader for VASP POSCAR/CONTCAR format.
537#[derive(Debug, Default)]
538pub struct VaspReader;
539
540impl VaspReader {
541    /// Create a new `VaspReader`.
542    pub fn new() -> Self {
543        Self
544    }
545
546    /// Parse a POSCAR/CONTCAR file and return a [`CrystalStructure`].
547    ///
548    /// Currently a stub — handles the header, scale, lattice vectors, and
549    /// Cartesian/Direct coordinate blocks.
550    pub fn parse_poscar(&self, content: &str) -> Result<CrystalStructure> {
551        let mut lines = content.lines().peekable();
552
553        // Line 1: comment
554        let _comment = lines.next().unwrap_or("").trim().to_string();
555        // Line 2: scale factor
556        let scale: f64 = lines
557            .next()
558            .unwrap_or("1.0")
559            .trim()
560            .parse()
561            .map_err(|_| Error::Parse("POSCAR: invalid scale factor".into()))?;
562
563        // Lines 3-5: lattice vectors
564        let mut lattice = [[0.0_f64; 3]; 3];
565        for row in lattice.iter_mut() {
566            let line = lines
567                .next()
568                .ok_or_else(|| Error::Parse("POSCAR: missing lattice vector".into()))?;
569            let vals: Vec<f64> = line
570                .split_whitespace()
571                .filter_map(|s| s.parse().ok())
572                .collect();
573            if vals.len() < 3 {
574                return Err(Error::Parse("POSCAR: bad lattice vector".into()));
575            }
576            row[0] = vals[0] * scale;
577            row[1] = vals[1] * scale;
578            row[2] = vals[2] * scale;
579        }
580
581        // Line 6: element symbols (VASP5+) or direct species count
582        let species_line = lines.next().unwrap_or("").trim().to_string();
583        let (elements, count_line) = if species_line
584            .chars()
585            .next()
586            .map(|c| c.is_alphabetic())
587            .unwrap_or(false)
588        {
589            let elems: Vec<String> = species_line
590                .split_whitespace()
591                .map(|s| s.to_string())
592                .collect();
593            let cl = lines.next().unwrap_or("").trim().to_string();
594            (elems, cl)
595        } else {
596            // VASP4: no element names
597            (vec!["X".to_string()], species_line.clone())
598        };
599
600        let counts: Vec<usize> = count_line
601            .split_whitespace()
602            .filter_map(|s| s.parse().ok())
603            .collect();
604
605        // Line 7: Selective dynamics or coordinate type
606        let coord_type_line = lines.next().unwrap_or("").trim().to_string();
607        let coord_type_line = if coord_type_line.to_lowercase().starts_with('s') {
608            lines.next().unwrap_or("").trim().to_string()
609        } else {
610            coord_type_line
611        };
612        let is_cartesian = coord_type_line.to_lowercase().starts_with('c')
613            || coord_type_line.to_lowercase().starts_with('k');
614
615        // Atoms
616        let mut atoms = Vec::new();
617        for (ei, &count) in counts.iter().enumerate() {
618            let element = elements.get(ei).cloned().unwrap_or_else(|| "X".to_string());
619            for _ in 0..count {
620                let line = lines.next().unwrap_or("0 0 0");
621                let vals: Vec<f64> = line
622                    .split_whitespace()
623                    .take(3)
624                    .filter_map(|s| s.parse().ok())
625                    .collect();
626                let mut pos = [
627                    vals.first().copied().unwrap_or(0.0),
628                    vals.get(1).copied().unwrap_or(0.0),
629                    vals.get(2).copied().unwrap_or(0.0),
630                ];
631                if is_cartesian {
632                    // Convert Cartesian to fractional (simplified — assumes orthogonal)
633                    pos[0] /= lattice[0][0].abs().max(1e-12);
634                    pos[1] /= lattice[1][1].abs().max(1e-12);
635                    pos[2] /= lattice[2][2].abs().max(1e-12);
636                }
637                atoms.push(CrystalAtom {
638                    element: element.clone(),
639                    fractional_pos: pos,
640                    occupancy: 1.0,
641                    b_factor: 0.0,
642                });
643            }
644        }
645
646        Ok(CrystalStructure {
647            lattice,
648            atoms,
649            space_group: 1,
650        })
651    }
652}
653
654// ---------------------------------------------------------------------------
655// XRD pattern analysis
656// ---------------------------------------------------------------------------
657
658impl XrdPattern {
659    /// Create an XRD pattern from parallel 2θ and intensity arrays.
660    pub fn new(two_theta: Vec<f64>, intensity: Vec<f64>) -> Self {
661        Self {
662            two_theta,
663            intensity,
664        }
665    }
666
667    /// Find peak positions (local maxima) using a simple derivative method.
668    ///
669    /// Returns indices into `two_theta`/`intensity` where peaks occur.
670    pub fn find_peaks(&self) -> Vec<usize> {
671        let n = self.intensity.len();
672        if n < 3 {
673            return vec![];
674        }
675        let mut peaks = Vec::new();
676        for i in 1..n - 1 {
677            if self.intensity[i] > self.intensity[i - 1]
678                && self.intensity[i] > self.intensity[i + 1]
679            {
680                peaks.push(i);
681            }
682        }
683        peaks
684    }
685
686    /// Convert 2θ peak positions to d-spacings using Bragg's law: d = λ/(2 sin θ).
687    ///
688    /// Uses Cu Kα radiation (λ = 1.5406 Å) by default.
689    pub fn d_spacings(&self) -> Vec<f64> {
690        const LAMBDA: f64 = 1.5406; // Å, Cu Kα
691        self.find_peaks()
692            .iter()
693            .map(|&i| {
694                let theta = self.two_theta[i].to_radians() / 2.0;
695                let sin_t = theta.sin();
696                if sin_t > 1e-12 {
697                    LAMBDA / (2.0 * sin_t)
698                } else {
699                    f64::INFINITY
700                }
701            })
702            .collect()
703    }
704}
705
706// ---------------------------------------------------------------------------
707// XRD simulation
708// ---------------------------------------------------------------------------
709
710/// Simulate a powder XRD pattern for the given crystal.
711///
712/// `wavelength` in Å, `theta_range` in degrees (2θ_min, 2θ_max), `n_pts` sample points.
713pub fn simulate_xrd(
714    crystal: &CrystalStructure,
715    wavelength: f64,
716    theta_range: (f64, f64),
717    n_pts: usize,
718) -> XrdPattern {
719    let (two_theta_min, two_theta_max) = theta_range;
720    let step = if n_pts > 1 {
721        (two_theta_max - two_theta_min) / (n_pts - 1) as f64
722    } else {
723        0.0
724    };
725
726    let two_theta: Vec<f64> = (0..n_pts)
727        .map(|i| two_theta_min + i as f64 * step)
728        .collect();
729    let mut intensity = vec![0.0_f64; n_pts];
730
731    // Consider reflections up to h,k,l = ±max_hkl
732    let max_hkl = 5_i32;
733    let vol = crystal.volume();
734    if vol < 1e-12 {
735        return XrdPattern::new(two_theta, intensity);
736    }
737
738    for h in -max_hkl..=max_hkl {
739        for k in -max_hkl..=max_hkl {
740            for l in -max_hkl..=max_hkl {
741                if h == 0 && k == 0 && l == 0 {
742                    continue;
743                }
744                let d = crystal.d_spacing(h, k, l);
745                if d.is_infinite() || d < 0.5 {
746                    continue;
747                }
748
749                // Bragg angle
750                let sin_theta = wavelength / (2.0 * d);
751                if sin_theta > 1.0 {
752                    continue;
753                }
754                let two_theta_hkl = 2.0 * sin_theta.asin().to_degrees();
755
756                // Find nearest grid point
757                if two_theta_hkl < two_theta_min || two_theta_hkl > two_theta_max {
758                    continue;
759                }
760                let idx = ((two_theta_hkl - two_theta_min) / step.max(1e-12)) as usize;
761                if idx >= n_pts {
762                    continue;
763                }
764
765                // Structure factor intensity
766                let (fre, fim) = crystal.structure_factor(h, k, l);
767                let f2 = fre * fre + fim * fim;
768
769                // Lorentz-polarization factor
770                let theta = two_theta_hkl.to_radians() / 2.0;
771                let lp = (1.0 + (2.0 * theta).cos().powi(2))
772                    / (theta.sin().powi(2) * (2.0 * theta).cos()).abs().max(1e-12);
773
774                // Spread over a few points with a Gaussian (FWHM ~ 0.1°)
775                let sigma_pts = 2.0;
776                for di in -(3 * sigma_pts as i64)..=(3 * sigma_pts as i64) {
777                    let j = idx as i64 + di;
778                    if j < 0 || j >= n_pts as i64 {
779                        continue;
780                    }
781                    let dt = di as f64 / sigma_pts;
782                    intensity[j as usize] += f2 * lp * (-0.5 * dt * dt).exp();
783                }
784            }
785        }
786    }
787
788    XrdPattern::new(two_theta, intensity)
789}
790
791// ---------------------------------------------------------------------------
792// Tests
793// ---------------------------------------------------------------------------
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    // --- CifParser ---
800
801    #[test]
802    fn test_cif_parser_minimal_cubic() {
803        let cif = r#"
804data_test
805_cell_length_a 5.0
806_cell_length_b 5.0
807_cell_length_c 5.0
808_cell_angle_alpha 90.0
809_cell_angle_beta  90.0
810_cell_angle_gamma 90.0
811_symmetry_int_tables_number 225
812"#;
813        let parser = CifParser::new();
814        let crystal = parser.parse(cif).unwrap();
815        assert!((crystal.volume() - 125.0).abs() < 0.01);
816        assert_eq!(crystal.space_group, 225);
817    }
818
819    #[test]
820    fn test_cif_parser_space_group_fallback() {
821        let cif = "data_x\n_cell_length_a 3.0\n_cell_length_b 3.0\n_cell_length_c 3.0\n";
822        let parser = CifParser::new();
823        let crystal = parser.parse(cif).unwrap();
824        assert_eq!(crystal.space_group, 1);
825    }
826
827    #[test]
828    fn test_cif_parser_with_standard_uncertainty() {
829        let cif =
830            "data_x\n_cell_length_a 4.123(5)\n_cell_length_b 4.123(5)\n_cell_length_c 4.123(5)\n";
831        let parser = CifParser::new();
832        let crystal = parser.parse(cif).unwrap();
833        assert!((crystal.lattice[0][0] - 4.123).abs() < 0.001);
834    }
835
836    #[test]
837    fn test_volume_cubic() {
838        let crystal = CrystalStructure::new_cubic(3.0);
839        assert!((crystal.volume() - 27.0).abs() < 1e-10);
840    }
841
842    #[test]
843    fn test_volume_orthorhombic() {
844        let crystal = CrystalStructure {
845            lattice: [[2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0]],
846            atoms: vec![],
847            space_group: 1,
848        };
849        assert!((crystal.volume() - 24.0).abs() < 1e-10);
850    }
851
852    #[test]
853    fn test_to_cartesian_fractional_origin() {
854        let mut crystal = CrystalStructure::new_cubic(4.0);
855        crystal.atoms.push(CrystalAtom {
856            element: "Fe".into(),
857            fractional_pos: [0.0, 0.0, 0.0],
858            occupancy: 1.0,
859            b_factor: 0.0,
860        });
861        let cart = crystal.to_cartesian();
862        assert!((cart[0][0]).abs() < 1e-12);
863        assert!((cart[0][1]).abs() < 1e-12);
864        assert!((cart[0][2]).abs() < 1e-12);
865    }
866
867    #[test]
868    fn test_to_cartesian_fractional_half() {
869        let mut crystal = CrystalStructure::new_cubic(4.0);
870        crystal.atoms.push(CrystalAtom {
871            element: "O".into(),
872            fractional_pos: [0.5, 0.5, 0.5],
873            occupancy: 1.0,
874            b_factor: 0.0,
875        });
876        let cart = crystal.to_cartesian();
877        assert!((cart[0][0] - 2.0).abs() < 1e-10);
878        assert!((cart[0][1] - 2.0).abs() < 1e-10);
879        assert!((cart[0][2] - 2.0).abs() < 1e-10);
880    }
881
882    #[test]
883    fn test_d_spacing_cubic_100() {
884        // For cubic with a=3: d(1,0,0) = a = 3 Å
885        let crystal = CrystalStructure::new_cubic(3.0);
886        let d = crystal.d_spacing(1, 0, 0);
887        assert!((d - 3.0).abs() < 0.001, "d_100 = {d}");
888    }
889
890    #[test]
891    fn test_d_spacing_cubic_110() {
892        // d(1,1,0) = a/√2
893        let a = 4.0;
894        let crystal = CrystalStructure::new_cubic(a);
895        let d = crystal.d_spacing(1, 1, 0);
896        let expected = a / 2.0_f64.sqrt();
897        assert!(
898            (d - expected).abs() < 0.001,
899            "d_110 = {d}, expected {expected}"
900        );
901    }
902
903    #[test]
904    fn test_d_spacing_cubic_111() {
905        // d(1,1,1) = a/√3
906        let a = 3.0;
907        let crystal = CrystalStructure::new_cubic(a);
908        let d = crystal.d_spacing(1, 1, 1);
909        let expected = a / 3.0_f64.sqrt();
910        assert!(
911            (d - expected).abs() < 0.001,
912            "d_111 = {d}, expected {expected}"
913        );
914    }
915
916    #[test]
917    fn test_reciprocal_lattice_cubic() {
918        let a = 2.0 * std::f64::consts::PI;
919        let crystal = CrystalStructure::new_cubic(a);
920        let rec = crystal.reciprocal_lattice();
921        // For cubic: b1 = 2π/a * x̂ → magnitude = 1.0
922        assert!((rec[0][0] - 1.0).abs() < 1e-10);
923        assert!((rec[1][1] - 1.0).abs() < 1e-10);
924        assert!((rec[2][2] - 1.0).abs() < 1e-10);
925    }
926
927    #[test]
928    fn test_structure_factor_single_atom_at_origin() {
929        let mut crystal = CrystalStructure::new_cubic(4.0);
930        crystal.atoms.push(CrystalAtom {
931            element: "Fe".into(),
932            fractional_pos: [0.0, 0.0, 0.0],
933            occupancy: 1.0,
934            b_factor: 0.0,
935        });
936        // Any hkl: phase = 0, so F = f(Fe) + 0i
937        let (re, im) = crystal.structure_factor(1, 0, 0);
938        assert!(re > 0.0, "real part should be positive");
939        assert!(im.abs() < 1e-10, "imaginary part should be 0");
940    }
941
942    #[test]
943    fn test_density_positive() {
944        let mut crystal = CrystalStructure::new_cubic(3.615); // copper
945        for _ in 0..4 {
946            crystal.atoms.push(CrystalAtom {
947                element: "Cu".into(),
948                fractional_pos: [0.0, 0.0, 0.0],
949                occupancy: 1.0,
950                b_factor: 0.0,
951            });
952        }
953        let rho = crystal.density(63.546); // Cu molar mass
954        assert!(rho > 0.0, "density must be positive");
955        // copper density ~8.9 g/cm³
956        assert!(
957            rho > 5.0 && rho < 15.0,
958            "density {rho} out of expected range"
959        );
960    }
961
962    #[test]
963    fn test_write_cif_roundtrip_cell() {
964        let crystal = CrystalStructure::new_cubic(5.0);
965        let cif = write_cif(&crystal);
966        assert!(cif.contains("_cell_length_a"));
967        assert!(cif.contains("5.000000"));
968    }
969
970    #[test]
971    fn test_write_cif_contains_atom_loop() {
972        let mut crystal = CrystalStructure::new_cubic(4.0);
973        crystal.atoms.push(CrystalAtom {
974            element: "Na".into(),
975            fractional_pos: [0.0, 0.0, 0.0],
976            occupancy: 1.0,
977            b_factor: 0.5,
978        });
979        let cif = write_cif(&crystal);
980        assert!(cif.contains("loop_"));
981        assert!(cif.contains("Na"));
982    }
983
984    #[test]
985    fn test_vasp_reader_simple_poscar() {
986        let poscar = "Fe BCC\n1.0\n2.87 0.0 0.0\n0.0 2.87 0.0\n0.0 0.0 2.87\nFe\n2\nDirect\n0.0 0.0 0.0\n0.5 0.5 0.5\n";
987        let reader = VaspReader::new();
988        let crystal = reader.parse_poscar(poscar).unwrap();
989        assert_eq!(crystal.atoms.len(), 2);
990        assert_eq!(crystal.atoms[0].element, "Fe");
991    }
992
993    #[test]
994    fn test_vasp_reader_lattice_scale() {
995        let poscar =
996            "Test\n2.0\n1.0 0.0 0.0\n0.0 1.0 0.0\n0.0 0.0 1.0\nH\n1\nDirect\n0.0 0.0 0.0\n";
997        let reader = VaspReader::new();
998        let crystal = reader.parse_poscar(poscar).unwrap();
999        // Scale=2 applied to lattice
1000        assert!((crystal.lattice[0][0] - 2.0).abs() < 1e-10);
1001    }
1002
1003    #[test]
1004    fn test_xrd_pattern_find_peaks() {
1005        // Manually create a pattern with clear peaks
1006        let two_theta: Vec<f64> = (0..20).map(|i| i as f64).collect();
1007        let mut intensity = vec![0.0; 20];
1008        intensity[5] = 100.0;
1009        intensity[10] = 200.0;
1010        let pattern = XrdPattern::new(two_theta, intensity);
1011        let peaks = pattern.find_peaks();
1012        assert!(peaks.contains(&5), "peak at index 5");
1013        assert!(peaks.contains(&10), "peak at index 10");
1014    }
1015
1016    #[test]
1017    fn test_xrd_d_spacings_bragg() {
1018        // 2θ = 44.5° for Fe(110) with Cu Kα → d ~ 2.03 Å
1019        let two_theta = vec![40.0, 44.5, 50.0];
1020        let intensity = vec![5.0, 100.0, 5.0];
1021        let pattern = XrdPattern::new(two_theta, intensity);
1022        let ds = pattern.d_spacings();
1023        assert!(!ds.is_empty());
1024        // d for 2θ=44.5° ≈ 2.03 Å
1025        assert!(ds[0] > 1.5 && ds[0] < 3.0, "d = {}", ds[0]);
1026    }
1027
1028    #[test]
1029    fn test_simulate_xrd_returns_correct_size() {
1030        let crystal = CrystalStructure::new_cubic(3.0);
1031        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 100);
1032        assert_eq!(pattern.two_theta.len(), 100);
1033        assert_eq!(pattern.intensity.len(), 100);
1034    }
1035
1036    #[test]
1037    fn test_simulate_xrd_non_negative_intensity() {
1038        let mut crystal = CrystalStructure::new_cubic(3.615);
1039        crystal.atoms.push(CrystalAtom {
1040            element: "Cu".into(),
1041            fractional_pos: [0.0, 0.0, 0.0],
1042            occupancy: 1.0,
1043            b_factor: 0.0,
1044        });
1045        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 200);
1046        for &i in &pattern.intensity {
1047            assert!(i >= 0.0, "negative intensity: {i}");
1048        }
1049    }
1050
1051    #[test]
1052    fn test_simulate_xrd_has_peaks() {
1053        let mut crystal = CrystalStructure::new_cubic(3.615);
1054        crystal.atoms.push(CrystalAtom {
1055            element: "Cu".into(),
1056            fractional_pos: [0.0, 0.0, 0.0],
1057            occupancy: 1.0,
1058            b_factor: 0.0,
1059        });
1060        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 100.0), 500);
1061        let peaks = pattern.find_peaks();
1062        assert!(!peaks.is_empty(), "simulated XRD should have peaks");
1063    }
1064
1065    #[test]
1066    fn test_approx_scattering_factor_known() {
1067        assert!((CrystalStructure::approx_scattering_factor("Fe") - 26.0).abs() < 0.5);
1068        assert!((CrystalStructure::approx_scattering_factor("O") - 8.0).abs() < 0.5);
1069        assert!((CrystalStructure::approx_scattering_factor("Cu") - 29.0).abs() < 0.5);
1070    }
1071
1072    #[test]
1073    fn test_build_lattice_cubic_is_diagonal() {
1074        let a = 4.0;
1075        let pi2 = std::f64::consts::PI / 2.0;
1076        let lat = CifParser::build_lattice(a, a, a, pi2, pi2, pi2);
1077        assert!((lat[0][0] - a).abs() < 1e-10);
1078        assert!(lat[0][1].abs() < 1e-10);
1079        assert!(lat[0][2].abs() < 1e-10);
1080        assert!((lat[1][1] - a).abs() < 1e-8, "b[1] = {}", lat[1][1]);
1081    }
1082
1083    #[test]
1084    fn test_cif_tokenize_quoted() {
1085        let tokens = CifParser::tokenize_line("'hello world' 3.14");
1086        assert_eq!(tokens[0], "hello world");
1087        assert_eq!(tokens[1], "3.14");
1088    }
1089
1090    #[test]
1091    fn test_strip_su() {
1092        assert_eq!(CifParser::strip_su("1.234(5)"), "1.234");
1093        assert_eq!(CifParser::strip_su("5.000"), "5.000");
1094    }
1095
1096    #[test]
1097    fn test_d_spacing_000_is_infinity() {
1098        let crystal = CrystalStructure::new_cubic(3.0);
1099        let d = crystal.d_spacing(0, 0, 0);
1100        assert!(d.is_infinite());
1101    }
1102
1103    #[test]
1104    fn test_crystal_structure_no_atoms_cartesian() {
1105        let crystal = CrystalStructure::new_cubic(5.0);
1106        assert!(crystal.to_cartesian().is_empty());
1107    }
1108
1109    #[test]
1110    fn test_cif_parser_empty_returns_default() {
1111        let parser = CifParser::new();
1112        let crystal = parser.parse("").unwrap();
1113        assert!((crystal.volume() - 1.0).abs() < 1e-10); // 1×1×1
1114    }
1115}