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
534// ---------------------------------------------------------------------------
535
536/// A richer POSCAR structure that captures VASP-specific extensions.
537///
538/// Unlike [`CrystalStructure`] (which maps to CIF), this struct preserves
539/// VASP-specific fields: selective dynamics flags, per-atom magnetic moments,
540/// and ionic forces parsed from a companion OUTCAR file.
541#[derive(Debug, Clone)]
542pub struct PoscarStructure {
543    /// Comment line (first line of the POSCAR file).
544    pub comment: String,
545    /// Universal scale factor (already applied to `lattice`).
546    pub scale: f64,
547    /// Lattice vectors as row matrix (in Å after scale).
548    pub lattice: [[f64; 3]; 3],
549    /// Element symbols for each species (VASP5+ format; empty for VASP4).
550    pub species: Vec<String>,
551    /// Number of atoms per species.
552    pub counts: Vec<usize>,
553    /// Whether selective dynamics mode is active.
554    pub selective_dynamics: bool,
555    /// Coordinate mode: `true` = Direct (fractional), `false` = Cartesian.
556    pub is_direct: bool,
557    /// Atom fractional (or Cartesian) positions, in species order.
558    pub positions: Vec<[f64; 3]>,
559    /// Per-atom selective-dynamics flags `[x_free, y_free, z_free]`.
560    /// `None` if selective dynamics were not specified.
561    pub sd_flags: Option<Vec<[bool; 3]>>,
562    /// Per-atom magnetic moments parsed from `# MAGMOM = ...` annotation.
563    /// `None` if not present.
564    pub magmom: Option<Vec<f64>>,
565    /// Ionic forces in eV/Å parsed from a companion OUTCAR file.
566    /// `None` if not available.
567    pub forces: Option<Vec<[f64; 3]>>,
568}
569
570impl PoscarStructure {
571    /// Convert to a [`CrystalStructure`] (drops VASP-specific fields).
572    pub fn to_crystal_structure(&self) -> CrystalStructure {
573        let mut element_iter = self.species.iter();
574        let mut count_iter = self.counts.iter();
575        let mut atoms = Vec::with_capacity(self.positions.len());
576        let mut current_element = element_iter
577            .next()
578            .cloned()
579            .unwrap_or_else(|| "X".to_string());
580        let mut remaining = count_iter.next().copied().unwrap_or(0);
581
582        for pos in &self.positions {
583            if remaining == 0 {
584                current_element = element_iter
585                    .next()
586                    .cloned()
587                    .unwrap_or_else(|| "X".to_string());
588                remaining = count_iter.next().copied().unwrap_or(0);
589            }
590            atoms.push(CrystalAtom {
591                element: current_element.clone(),
592                fractional_pos: *pos,
593                occupancy: 1.0,
594                b_factor: 0.0,
595            });
596            remaining = remaining.saturating_sub(1);
597        }
598        CrystalStructure {
599            lattice: self.lattice,
600            atoms,
601            space_group: 1,
602        }
603    }
604
605    /// Write this structure to a POSCAR-formatted string.
606    pub fn to_poscar_string(&self) -> String {
607        let mut out = String::new();
608        out.push_str(&self.comment);
609        out.push('\n');
610        out.push_str("1.0\n");
611        for row in &self.lattice {
612            out.push_str(&format!(
613                "  {:.10}  {:.10}  {:.10}\n",
614                row[0], row[1], row[2]
615            ));
616        }
617        if !self.species.is_empty() {
618            out.push_str(&self.species.join("  "));
619            out.push('\n');
620        }
621        let count_strs: Vec<String> = self.counts.iter().map(|c| c.to_string()).collect();
622        out.push_str(&count_strs.join("  "));
623        out.push('\n');
624        if self.selective_dynamics {
625            out.push_str("Selective dynamics\n");
626        }
627        if self.is_direct {
628            out.push_str("Direct\n");
629        } else {
630            out.push_str("Cartesian\n");
631        }
632        for (i, pos) in self.positions.iter().enumerate() {
633            if self.selective_dynamics {
634                let flags = self
635                    .sd_flags
636                    .as_ref()
637                    .and_then(|f| f.get(i))
638                    .copied()
639                    .unwrap_or([true, true, true]);
640                let fx = if flags[0] { "T" } else { "F" };
641                let fy = if flags[1] { "T" } else { "F" };
642                let fz = if flags[2] { "T" } else { "F" };
643                out.push_str(&format!(
644                    "  {:.10}  {:.10}  {:.10}  {fx}  {fy}  {fz}\n",
645                    pos[0], pos[1], pos[2]
646                ));
647            } else {
648                out.push_str(&format!(
649                    "  {:.10}  {:.10}  {:.10}\n",
650                    pos[0], pos[1], pos[2]
651                ));
652            }
653        }
654        if let Some(ref mm) = self.magmom {
655            let mm_strs: Vec<String> = mm.iter().map(|v| format!("{v:.4}")).collect();
656            out.push_str(&format!("# MAGMOM = {}\n", mm_strs.join(" ")));
657        }
658        out
659    }
660}
661
662/// Parse OUTCAR ionic forces from a VASP OUTCAR file.
663///
664/// Looks for the last `TOTAL-FORCE (eV/Angst)` block and reads the
665/// following `n_atoms` lines as `[fx, fy, fz]` force vectors.
666pub fn parse_outcar_forces(content: &str, n_atoms: usize) -> Option<Vec<[f64; 3]>> {
667    // Find the last occurrence of the TOTAL-FORCE block.
668    let marker = "TOTAL-FORCE (eV/Angst)";
669    let last_pos = content.rfind(marker)?;
670    let after = &content[last_pos..];
671    // Skip the header line and the dashed separator line.
672    let mut lines = after.lines().skip(1);
673    // Skip separator line (contains dashes)
674    let sep = lines.next()?;
675    if !sep.contains('-') {
676        return None;
677    }
678    let mut forces = Vec::with_capacity(n_atoms);
679    for line in lines.take(n_atoms) {
680        let toks: Vec<&str> = line.split_whitespace().collect();
681        if toks.len() < 6 {
682            return None;
683        }
684        let fx: f64 = toks.get(3).and_then(|s| s.parse().ok())?;
685        let fy: f64 = toks.get(4).and_then(|s| s.parse().ok())?;
686        let fz: f64 = toks.get(5).and_then(|s| s.parse().ok())?;
687        forces.push([fx, fy, fz]);
688    }
689    if forces.len() == n_atoms {
690        Some(forces)
691    } else {
692        None
693    }
694}
695
696/// Reader for VASP POSCAR/CONTCAR format.
697#[derive(Debug, Default)]
698pub struct VaspReader;
699
700impl VaspReader {
701    /// Create a new `VaspReader`.
702    pub fn new() -> Self {
703        Self
704    }
705
706    /// Parse a POSCAR/CONTCAR file and return a [`CrystalStructure`].
707    ///
708    /// Handles the header, scale, lattice vectors, optional selective dynamics
709    /// flags, and Cartesian/Direct coordinate blocks.
710    pub fn parse_poscar(&self, content: &str) -> Result<CrystalStructure> {
711        let ps = self.parse_poscar_full(content)?;
712        Ok(ps.to_crystal_structure())
713    }
714
715    /// Parse a POSCAR/CONTCAR file and return a full [`PoscarStructure`],
716    /// preserving selective dynamics flags, magnetic moments, and other
717    /// VASP-specific information.
718    pub fn parse_poscar_full(&self, content: &str) -> Result<PoscarStructure> {
719        let mut lines = content.lines().peekable();
720
721        // Line 1: comment
722        let comment = lines.next().unwrap_or("").trim().to_string();
723        // Line 2: scale factor
724        let scale: f64 = lines
725            .next()
726            .unwrap_or("1.0")
727            .trim()
728            .parse()
729            .map_err(|_| Error::Parse("POSCAR: invalid scale factor".into()))?;
730
731        // Lines 3-5: lattice vectors
732        let mut lattice = [[0.0_f64; 3]; 3];
733        for row in lattice.iter_mut() {
734            let line = lines
735                .next()
736                .ok_or_else(|| Error::Parse("POSCAR: missing lattice vector".into()))?;
737            let vals: Vec<f64> = line
738                .split_whitespace()
739                .filter_map(|s| s.parse().ok())
740                .collect();
741            if vals.len() < 3 {
742                return Err(Error::Parse("POSCAR: bad lattice vector".into()));
743            }
744            row[0] = vals[0] * scale;
745            row[1] = vals[1] * scale;
746            row[2] = vals[2] * scale;
747        }
748
749        // Line 6: element symbols (VASP5+) or direct species count
750        let species_line = lines.next().unwrap_or("").trim().to_string();
751        let (species, count_line) = if species_line
752            .chars()
753            .next()
754            .map(|c| c.is_alphabetic())
755            .unwrap_or(false)
756        {
757            let elems: Vec<String> = species_line
758                .split_whitespace()
759                .map(|s| s.to_string())
760                .collect();
761            let cl = lines.next().unwrap_or("").trim().to_string();
762            (elems, cl)
763        } else {
764            // VASP4: no element names — use placeholder
765            (vec!["X".to_string()], species_line.clone())
766        };
767
768        let counts: Vec<usize> = count_line
769            .split_whitespace()
770            .filter_map(|s| s.parse().ok())
771            .collect();
772        let n_atoms: usize = counts.iter().sum();
773
774        // Next line: optional "Selective dynamics", then coordinate mode
775        let next_line = lines.next().unwrap_or("").trim().to_string();
776        let (selective_dynamics, coord_type_line) = if next_line.to_lowercase().starts_with('s') {
777            let cl = lines.next().unwrap_or("").trim().to_string();
778            (true, cl)
779        } else {
780            (false, next_line)
781        };
782
783        let is_direct = !coord_type_line.to_lowercase().starts_with('c')
784            && !coord_type_line.to_lowercase().starts_with('k');
785
786        // Read atom positions (and optional selective dynamics flags)
787        let mut positions: Vec<[f64; 3]> = Vec::with_capacity(n_atoms);
788        let mut sd_flags: Vec<[bool; 3]> = Vec::with_capacity(n_atoms);
789        // Also capture any trailing lines for MAGMOM
790        let mut trailing: Vec<String> = Vec::new();
791
792        for _ in 0..n_atoms {
793            let line = lines.next().unwrap_or("0 0 0");
794            let toks: Vec<&str> = line.split_whitespace().collect();
795            let x: f64 = toks.first().and_then(|s| s.parse().ok()).unwrap_or(0.0);
796            let y: f64 = toks.get(1).and_then(|s| s.parse().ok()).unwrap_or(0.0);
797            let z: f64 = toks.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);
798            let mut pos = [x, y, z];
799            if !is_direct {
800                // Convert Cartesian → fractional (simplified: assumes orthogonal cell)
801                pos[0] /= lattice[0][0].abs().max(1e-12);
802                pos[1] /= lattice[1][1].abs().max(1e-12);
803                pos[2] /= lattice[2][2].abs().max(1e-12);
804            }
805            positions.push(pos);
806
807            if selective_dynamics && toks.len() >= 6 {
808                let fx = toks[3] == "T";
809                let fy = toks[4] == "T";
810                let fz = toks[5] == "T";
811                sd_flags.push([fx, fy, fz]);
812            } else {
813                sd_flags.push([true, true, true]);
814            }
815        }
816
817        // Consume remaining lines to find MAGMOM annotations.
818        for line in lines {
819            trailing.push(line.trim().to_string());
820        }
821
822        // Parse MAGMOM from trailing comments: `# MAGMOM = 1.0 -1.0 ...`
823        let magmom = trailing
824            .iter()
825            .find(|l| {
826                let lower = l.to_lowercase();
827                lower.contains("magmom") && lower.contains('=')
828            })
829            .and_then(|l| {
830                // Strip leading `#` and everything up to and including `=`
831                let after_eq = l.split('=').nth(1)?;
832                let vals: Vec<f64> = after_eq
833                    .split_whitespace()
834                    .filter_map(|s| s.parse().ok())
835                    .collect();
836                if vals.is_empty() { None } else { Some(vals) }
837            });
838
839        let sd_opt = if selective_dynamics {
840            Some(sd_flags)
841        } else {
842            None
843        };
844
845        Ok(PoscarStructure {
846            comment,
847            scale,
848            lattice,
849            species,
850            counts,
851            selective_dynamics,
852            is_direct,
853            positions,
854            sd_flags: sd_opt,
855            magmom,
856            forces: None,
857        })
858    }
859}
860
861/// Parse a string containing one or more POSCAR images (e.g., VASP NEB chain).
862///
863/// Images are separated by double-newlines or a blank line followed by
864/// a new POSCAR header. Returns a `Vec<PoscarStructure>` with one entry per image.
865pub fn read_poscar_images(content: &str) -> Result<Vec<PoscarStructure>> {
866    // Split on blank lines (double newline) to find image boundaries.
867    // Each image starts with a non-empty comment line followed by scale etc.
868    let reader = VaspReader::new();
869
870    // Split the content on sequences of two or more consecutive newlines.
871    let blocks: Vec<&str> = content
872        .split("\n\n")
873        .map(|b| b.trim())
874        .filter(|b| !b.is_empty())
875        .collect();
876
877    if blocks.is_empty() {
878        return Err(Error::Parse("no POSCAR images found".into()));
879    }
880
881    let mut images = Vec::with_capacity(blocks.len());
882    for block in &blocks {
883        let ps = reader.parse_poscar_full(block)?;
884        images.push(ps);
885    }
886    Ok(images)
887}
888
889// ---------------------------------------------------------------------------
890// XRD pattern analysis
891// ---------------------------------------------------------------------------
892
893impl XrdPattern {
894    /// Create an XRD pattern from parallel 2θ and intensity arrays.
895    pub fn new(two_theta: Vec<f64>, intensity: Vec<f64>) -> Self {
896        Self {
897            two_theta,
898            intensity,
899        }
900    }
901
902    /// Find peak positions (local maxima) using a simple derivative method.
903    ///
904    /// Returns indices into `two_theta`/`intensity` where peaks occur.
905    pub fn find_peaks(&self) -> Vec<usize> {
906        let n = self.intensity.len();
907        if n < 3 {
908            return vec![];
909        }
910        let mut peaks = Vec::new();
911        for i in 1..n - 1 {
912            if self.intensity[i] > self.intensity[i - 1]
913                && self.intensity[i] > self.intensity[i + 1]
914            {
915                peaks.push(i);
916            }
917        }
918        peaks
919    }
920
921    /// Convert 2θ peak positions to d-spacings using Bragg's law: d = λ/(2 sin θ).
922    ///
923    /// Uses Cu Kα radiation (λ = 1.5406 Å) by default.
924    pub fn d_spacings(&self) -> Vec<f64> {
925        const LAMBDA: f64 = 1.5406; // Å, Cu Kα
926        self.find_peaks()
927            .iter()
928            .map(|&i| {
929                let theta = self.two_theta[i].to_radians() / 2.0;
930                let sin_t = theta.sin();
931                if sin_t > 1e-12 {
932                    LAMBDA / (2.0 * sin_t)
933                } else {
934                    f64::INFINITY
935                }
936            })
937            .collect()
938    }
939}
940
941// ---------------------------------------------------------------------------
942// XRD simulation
943// ---------------------------------------------------------------------------
944
945/// Simulate a powder XRD pattern for the given crystal.
946///
947/// `wavelength` in Å, `theta_range` in degrees (2θ_min, 2θ_max), `n_pts` sample points.
948pub fn simulate_xrd(
949    crystal: &CrystalStructure,
950    wavelength: f64,
951    theta_range: (f64, f64),
952    n_pts: usize,
953) -> XrdPattern {
954    let (two_theta_min, two_theta_max) = theta_range;
955    let step = if n_pts > 1 {
956        (two_theta_max - two_theta_min) / (n_pts - 1) as f64
957    } else {
958        0.0
959    };
960
961    let two_theta: Vec<f64> = (0..n_pts)
962        .map(|i| two_theta_min + i as f64 * step)
963        .collect();
964    let mut intensity = vec![0.0_f64; n_pts];
965
966    // Consider reflections up to h,k,l = ±max_hkl
967    let max_hkl = 5_i32;
968    let vol = crystal.volume();
969    if vol < 1e-12 {
970        return XrdPattern::new(two_theta, intensity);
971    }
972
973    for h in -max_hkl..=max_hkl {
974        for k in -max_hkl..=max_hkl {
975            for l in -max_hkl..=max_hkl {
976                if h == 0 && k == 0 && l == 0 {
977                    continue;
978                }
979                let d = crystal.d_spacing(h, k, l);
980                if d.is_infinite() || d < 0.5 {
981                    continue;
982                }
983
984                // Bragg angle
985                let sin_theta = wavelength / (2.0 * d);
986                if sin_theta > 1.0 {
987                    continue;
988                }
989                let two_theta_hkl = 2.0 * sin_theta.asin().to_degrees();
990
991                // Find nearest grid point
992                if two_theta_hkl < two_theta_min || two_theta_hkl > two_theta_max {
993                    continue;
994                }
995                let idx = ((two_theta_hkl - two_theta_min) / step.max(1e-12)) as usize;
996                if idx >= n_pts {
997                    continue;
998                }
999
1000                // Structure factor intensity
1001                let (fre, fim) = crystal.structure_factor(h, k, l);
1002                let f2 = fre * fre + fim * fim;
1003
1004                // Lorentz-polarization factor
1005                let theta = two_theta_hkl.to_radians() / 2.0;
1006                let lp = (1.0 + (2.0 * theta).cos().powi(2))
1007                    / (theta.sin().powi(2) * (2.0 * theta).cos()).abs().max(1e-12);
1008
1009                // Spread over a few points with a Gaussian (FWHM ~ 0.1°)
1010                let sigma_pts = 2.0;
1011                for di in -(3 * sigma_pts as i64)..=(3 * sigma_pts as i64) {
1012                    let j = idx as i64 + di;
1013                    if j < 0 || j >= n_pts as i64 {
1014                        continue;
1015                    }
1016                    let dt = di as f64 / sigma_pts;
1017                    intensity[j as usize] += f2 * lp * (-0.5 * dt * dt).exp();
1018                }
1019            }
1020        }
1021    }
1022
1023    XrdPattern::new(two_theta, intensity)
1024}
1025
1026// ---------------------------------------------------------------------------
1027// Tests
1028// ---------------------------------------------------------------------------
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    // --- CifParser ---
1035
1036    #[test]
1037    fn test_cif_parser_minimal_cubic() {
1038        let cif = r#"
1039data_test
1040_cell_length_a 5.0
1041_cell_length_b 5.0
1042_cell_length_c 5.0
1043_cell_angle_alpha 90.0
1044_cell_angle_beta  90.0
1045_cell_angle_gamma 90.0
1046_symmetry_int_tables_number 225
1047"#;
1048        let parser = CifParser::new();
1049        let crystal = parser.parse(cif).unwrap();
1050        assert!((crystal.volume() - 125.0).abs() < 0.01);
1051        assert_eq!(crystal.space_group, 225);
1052    }
1053
1054    #[test]
1055    fn test_cif_parser_space_group_fallback() {
1056        let cif = "data_x\n_cell_length_a 3.0\n_cell_length_b 3.0\n_cell_length_c 3.0\n";
1057        let parser = CifParser::new();
1058        let crystal = parser.parse(cif).unwrap();
1059        assert_eq!(crystal.space_group, 1);
1060    }
1061
1062    #[test]
1063    fn test_cif_parser_with_standard_uncertainty() {
1064        let cif =
1065            "data_x\n_cell_length_a 4.123(5)\n_cell_length_b 4.123(5)\n_cell_length_c 4.123(5)\n";
1066        let parser = CifParser::new();
1067        let crystal = parser.parse(cif).unwrap();
1068        assert!((crystal.lattice[0][0] - 4.123).abs() < 0.001);
1069    }
1070
1071    #[test]
1072    fn test_volume_cubic() {
1073        let crystal = CrystalStructure::new_cubic(3.0);
1074        assert!((crystal.volume() - 27.0).abs() < 1e-10);
1075    }
1076
1077    #[test]
1078    fn test_volume_orthorhombic() {
1079        let crystal = CrystalStructure {
1080            lattice: [[2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0]],
1081            atoms: vec![],
1082            space_group: 1,
1083        };
1084        assert!((crystal.volume() - 24.0).abs() < 1e-10);
1085    }
1086
1087    #[test]
1088    fn test_to_cartesian_fractional_origin() {
1089        let mut crystal = CrystalStructure::new_cubic(4.0);
1090        crystal.atoms.push(CrystalAtom {
1091            element: "Fe".into(),
1092            fractional_pos: [0.0, 0.0, 0.0],
1093            occupancy: 1.0,
1094            b_factor: 0.0,
1095        });
1096        let cart = crystal.to_cartesian();
1097        assert!((cart[0][0]).abs() < 1e-12);
1098        assert!((cart[0][1]).abs() < 1e-12);
1099        assert!((cart[0][2]).abs() < 1e-12);
1100    }
1101
1102    #[test]
1103    fn test_to_cartesian_fractional_half() {
1104        let mut crystal = CrystalStructure::new_cubic(4.0);
1105        crystal.atoms.push(CrystalAtom {
1106            element: "O".into(),
1107            fractional_pos: [0.5, 0.5, 0.5],
1108            occupancy: 1.0,
1109            b_factor: 0.0,
1110        });
1111        let cart = crystal.to_cartesian();
1112        assert!((cart[0][0] - 2.0).abs() < 1e-10);
1113        assert!((cart[0][1] - 2.0).abs() < 1e-10);
1114        assert!((cart[0][2] - 2.0).abs() < 1e-10);
1115    }
1116
1117    #[test]
1118    fn test_d_spacing_cubic_100() {
1119        // For cubic with a=3: d(1,0,0) = a = 3 Å
1120        let crystal = CrystalStructure::new_cubic(3.0);
1121        let d = crystal.d_spacing(1, 0, 0);
1122        assert!((d - 3.0).abs() < 0.001, "d_100 = {d}");
1123    }
1124
1125    #[test]
1126    fn test_d_spacing_cubic_110() {
1127        // d(1,1,0) = a/√2
1128        let a = 4.0;
1129        let crystal = CrystalStructure::new_cubic(a);
1130        let d = crystal.d_spacing(1, 1, 0);
1131        let expected = a / 2.0_f64.sqrt();
1132        assert!(
1133            (d - expected).abs() < 0.001,
1134            "d_110 = {d}, expected {expected}"
1135        );
1136    }
1137
1138    #[test]
1139    fn test_d_spacing_cubic_111() {
1140        // d(1,1,1) = a/√3
1141        let a = 3.0;
1142        let crystal = CrystalStructure::new_cubic(a);
1143        let d = crystal.d_spacing(1, 1, 1);
1144        let expected = a / 3.0_f64.sqrt();
1145        assert!(
1146            (d - expected).abs() < 0.001,
1147            "d_111 = {d}, expected {expected}"
1148        );
1149    }
1150
1151    #[test]
1152    fn test_reciprocal_lattice_cubic() {
1153        let a = 2.0 * std::f64::consts::PI;
1154        let crystal = CrystalStructure::new_cubic(a);
1155        let rec = crystal.reciprocal_lattice();
1156        // For cubic: b1 = 2π/a * x̂ → magnitude = 1.0
1157        assert!((rec[0][0] - 1.0).abs() < 1e-10);
1158        assert!((rec[1][1] - 1.0).abs() < 1e-10);
1159        assert!((rec[2][2] - 1.0).abs() < 1e-10);
1160    }
1161
1162    #[test]
1163    fn test_structure_factor_single_atom_at_origin() {
1164        let mut crystal = CrystalStructure::new_cubic(4.0);
1165        crystal.atoms.push(CrystalAtom {
1166            element: "Fe".into(),
1167            fractional_pos: [0.0, 0.0, 0.0],
1168            occupancy: 1.0,
1169            b_factor: 0.0,
1170        });
1171        // Any hkl: phase = 0, so F = f(Fe) + 0i
1172        let (re, im) = crystal.structure_factor(1, 0, 0);
1173        assert!(re > 0.0, "real part should be positive");
1174        assert!(im.abs() < 1e-10, "imaginary part should be 0");
1175    }
1176
1177    #[test]
1178    fn test_density_positive() {
1179        let mut crystal = CrystalStructure::new_cubic(3.615); // copper
1180        for _ in 0..4 {
1181            crystal.atoms.push(CrystalAtom {
1182                element: "Cu".into(),
1183                fractional_pos: [0.0, 0.0, 0.0],
1184                occupancy: 1.0,
1185                b_factor: 0.0,
1186            });
1187        }
1188        let rho = crystal.density(63.546); // Cu molar mass
1189        assert!(rho > 0.0, "density must be positive");
1190        // copper density ~8.9 g/cm³
1191        assert!(
1192            rho > 5.0 && rho < 15.0,
1193            "density {rho} out of expected range"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_write_cif_roundtrip_cell() {
1199        let crystal = CrystalStructure::new_cubic(5.0);
1200        let cif = write_cif(&crystal);
1201        assert!(cif.contains("_cell_length_a"));
1202        assert!(cif.contains("5.000000"));
1203    }
1204
1205    #[test]
1206    fn test_write_cif_contains_atom_loop() {
1207        let mut crystal = CrystalStructure::new_cubic(4.0);
1208        crystal.atoms.push(CrystalAtom {
1209            element: "Na".into(),
1210            fractional_pos: [0.0, 0.0, 0.0],
1211            occupancy: 1.0,
1212            b_factor: 0.5,
1213        });
1214        let cif = write_cif(&crystal);
1215        assert!(cif.contains("loop_"));
1216        assert!(cif.contains("Na"));
1217    }
1218
1219    #[test]
1220    fn test_vasp_reader_simple_poscar() {
1221        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";
1222        let reader = VaspReader::new();
1223        let crystal = reader.parse_poscar(poscar).unwrap();
1224        assert_eq!(crystal.atoms.len(), 2);
1225        assert_eq!(crystal.atoms[0].element, "Fe");
1226    }
1227
1228    #[test]
1229    fn test_vasp_reader_lattice_scale() {
1230        let poscar =
1231            "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";
1232        let reader = VaspReader::new();
1233        let crystal = reader.parse_poscar(poscar).unwrap();
1234        // Scale=2 applied to lattice
1235        assert!((crystal.lattice[0][0] - 2.0).abs() < 1e-10);
1236    }
1237
1238    #[test]
1239    fn test_xrd_pattern_find_peaks() {
1240        // Manually create a pattern with clear peaks
1241        let two_theta: Vec<f64> = (0..20).map(|i| i as f64).collect();
1242        let mut intensity = vec![0.0; 20];
1243        intensity[5] = 100.0;
1244        intensity[10] = 200.0;
1245        let pattern = XrdPattern::new(two_theta, intensity);
1246        let peaks = pattern.find_peaks();
1247        assert!(peaks.contains(&5), "peak at index 5");
1248        assert!(peaks.contains(&10), "peak at index 10");
1249    }
1250
1251    #[test]
1252    fn test_xrd_d_spacings_bragg() {
1253        // 2θ = 44.5° for Fe(110) with Cu Kα → d ~ 2.03 Å
1254        let two_theta = vec![40.0, 44.5, 50.0];
1255        let intensity = vec![5.0, 100.0, 5.0];
1256        let pattern = XrdPattern::new(two_theta, intensity);
1257        let ds = pattern.d_spacings();
1258        assert!(!ds.is_empty());
1259        // d for 2θ=44.5° ≈ 2.03 Å
1260        assert!(ds[0] > 1.5 && ds[0] < 3.0, "d = {}", ds[0]);
1261    }
1262
1263    #[test]
1264    fn test_simulate_xrd_returns_correct_size() {
1265        let crystal = CrystalStructure::new_cubic(3.0);
1266        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 100);
1267        assert_eq!(pattern.two_theta.len(), 100);
1268        assert_eq!(pattern.intensity.len(), 100);
1269    }
1270
1271    #[test]
1272    fn test_simulate_xrd_non_negative_intensity() {
1273        let mut crystal = CrystalStructure::new_cubic(3.615);
1274        crystal.atoms.push(CrystalAtom {
1275            element: "Cu".into(),
1276            fractional_pos: [0.0, 0.0, 0.0],
1277            occupancy: 1.0,
1278            b_factor: 0.0,
1279        });
1280        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 200);
1281        for &i in &pattern.intensity {
1282            assert!(i >= 0.0, "negative intensity: {i}");
1283        }
1284    }
1285
1286    #[test]
1287    fn test_simulate_xrd_has_peaks() {
1288        let mut crystal = CrystalStructure::new_cubic(3.615);
1289        crystal.atoms.push(CrystalAtom {
1290            element: "Cu".into(),
1291            fractional_pos: [0.0, 0.0, 0.0],
1292            occupancy: 1.0,
1293            b_factor: 0.0,
1294        });
1295        let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 100.0), 500);
1296        let peaks = pattern.find_peaks();
1297        assert!(!peaks.is_empty(), "simulated XRD should have peaks");
1298    }
1299
1300    #[test]
1301    fn test_approx_scattering_factor_known() {
1302        assert!((CrystalStructure::approx_scattering_factor("Fe") - 26.0).abs() < 0.5);
1303        assert!((CrystalStructure::approx_scattering_factor("O") - 8.0).abs() < 0.5);
1304        assert!((CrystalStructure::approx_scattering_factor("Cu") - 29.0).abs() < 0.5);
1305    }
1306
1307    #[test]
1308    fn test_build_lattice_cubic_is_diagonal() {
1309        let a = 4.0;
1310        let pi2 = std::f64::consts::PI / 2.0;
1311        let lat = CifParser::build_lattice(a, a, a, pi2, pi2, pi2);
1312        assert!((lat[0][0] - a).abs() < 1e-10);
1313        assert!(lat[0][1].abs() < 1e-10);
1314        assert!(lat[0][2].abs() < 1e-10);
1315        assert!((lat[1][1] - a).abs() < 1e-8, "b[1] = {}", lat[1][1]);
1316    }
1317
1318    #[test]
1319    fn test_cif_tokenize_quoted() {
1320        let tokens = CifParser::tokenize_line("'hello world' 3.14");
1321        assert_eq!(tokens[0], "hello world");
1322        assert_eq!(tokens[1], "3.14");
1323    }
1324
1325    #[test]
1326    fn test_strip_su() {
1327        assert_eq!(CifParser::strip_su("1.234(5)"), "1.234");
1328        assert_eq!(CifParser::strip_su("5.000"), "5.000");
1329    }
1330
1331    #[test]
1332    fn test_d_spacing_000_is_infinity() {
1333        let crystal = CrystalStructure::new_cubic(3.0);
1334        let d = crystal.d_spacing(0, 0, 0);
1335        assert!(d.is_infinite());
1336    }
1337
1338    #[test]
1339    fn test_crystal_structure_no_atoms_cartesian() {
1340        let crystal = CrystalStructure::new_cubic(5.0);
1341        assert!(crystal.to_cartesian().is_empty());
1342    }
1343
1344    #[test]
1345    fn test_cif_parser_empty_returns_default() {
1346        let parser = CifParser::new();
1347        let crystal = parser.parse("").unwrap();
1348        assert!((crystal.volume() - 1.0).abs() < 1e-10); // 1×1×1
1349    }
1350
1351    // -----------------------------------------------------------------------
1352    // J2: Extended POSCAR reader tests
1353    // -----------------------------------------------------------------------
1354
1355    #[test]
1356    fn test_poscar_selective_dynamics_flags_preserved() {
1357        // POSCAR with selective dynamics: atom 0 is fully free, atom 1 has z fixed.
1358        let poscar = concat!(
1359            "BCC Fe\n",
1360            "1.0\n",
1361            "2.87 0.0 0.0\n",
1362            "0.0 2.87 0.0\n",
1363            "0.0 0.0 2.87\n",
1364            "Fe\n",
1365            "2\n",
1366            "Selective dynamics\n",
1367            "Direct\n",
1368            "0.0 0.0 0.0 T T T\n",
1369            "0.5 0.5 0.5 T T F\n",
1370        );
1371        let reader = VaspReader::new();
1372        let ps = reader.parse_poscar_full(poscar).expect("parse failed");
1373
1374        assert!(ps.selective_dynamics, "selective_dynamics flag must be set");
1375        let flags = ps.sd_flags.as_ref().expect("sd_flags must be Some");
1376        assert_eq!(flags.len(), 2, "must have 2 flag entries");
1377
1378        // Atom 0: all free
1379        assert!(flags[0][0], "atom0 x should be free (T)");
1380        assert!(flags[0][1], "atom0 y should be free (T)");
1381        assert!(flags[0][2], "atom0 z should be free (T)");
1382
1383        // Atom 1: z fixed
1384        assert!(flags[1][0], "atom1 x should be free (T)");
1385        assert!(flags[1][1], "atom1 y should be free (T)");
1386        assert!(!flags[1][2], "atom1 z should be fixed (F)");
1387    }
1388
1389    #[test]
1390    fn test_poscar_without_selective_dynamics() {
1391        let poscar = concat!(
1392            "Simple\n",
1393            "1.0\n",
1394            "3.0 0.0 0.0\n",
1395            "0.0 3.0 0.0\n",
1396            "0.0 0.0 3.0\n",
1397            "H\n",
1398            "1\n",
1399            "Direct\n",
1400            "0.0 0.0 0.0\n",
1401        );
1402        let reader = VaspReader::new();
1403        let ps = reader.parse_poscar_full(poscar).expect("parse failed");
1404        assert!(!ps.selective_dynamics, "selective_dynamics must be false");
1405        assert!(
1406            ps.sd_flags.is_none(),
1407            "sd_flags must be None when not specified"
1408        );
1409    }
1410
1411    #[test]
1412    fn test_poscar_multi_image_vec_length() {
1413        // Two POSCAR images separated by double newline.
1414        let img1 = concat!(
1415            "Image1\n",
1416            "1.0\n",
1417            "3.0 0.0 0.0\n",
1418            "0.0 3.0 0.0\n",
1419            "0.0 0.0 3.0\n",
1420            "H\n",
1421            "1\n",
1422            "Direct\n",
1423            "0.0 0.0 0.0\n",
1424        );
1425        let img2 = concat!(
1426            "Image2\n",
1427            "1.0\n",
1428            "4.0 0.0 0.0\n",
1429            "0.0 4.0 0.0\n",
1430            "0.0 0.0 4.0\n",
1431            "O\n",
1432            "2\n",
1433            "Direct\n",
1434            "0.0 0.0 0.0\n",
1435            "0.5 0.5 0.5\n",
1436        );
1437        let combined = format!("{img1}\n{img2}");
1438        let images = read_poscar_images(&combined).expect("read_poscar_images failed");
1439        assert_eq!(images.len(), 2, "must parse 2 images");
1440        // First image has lattice 3.0, second 4.0
1441        assert!(
1442            (images[0].lattice[0][0] - 3.0).abs() < 1e-10,
1443            "image0 lattice a=3"
1444        );
1445        assert!(
1446            (images[1].lattice[0][0] - 4.0).abs() < 1e-10,
1447            "image1 lattice a=4"
1448        );
1449        // Species and counts
1450        assert_eq!(images[0].species[0], "H");
1451        assert_eq!(images[1].species[0], "O");
1452        assert_eq!(images[1].positions.len(), 2, "image1 should have 2 atoms");
1453    }
1454
1455    #[test]
1456    fn test_poscar_multi_image_different_lattices() {
1457        // Verify fractional positions are preserved correctly across images.
1458        let img1 = concat!(
1459            "Img A\n",
1460            "1.0\n",
1461            "5.0 0.0 0.0\n",
1462            "0.0 5.0 0.0\n",
1463            "0.0 0.0 5.0\n",
1464            "Al\n",
1465            "1\n",
1466            "Direct\n",
1467            "0.25 0.25 0.25\n",
1468        );
1469        let img2 = concat!(
1470            "Img B\n",
1471            "1.0\n",
1472            "6.0 0.0 0.0\n",
1473            "0.0 6.0 0.0\n",
1474            "0.0 0.0 6.0\n",
1475            "Al\n",
1476            "1\n",
1477            "Direct\n",
1478            "0.75 0.75 0.75\n",
1479        );
1480        let combined = format!("{img1}\n{img2}");
1481        let images = read_poscar_images(&combined).expect("read_poscar_images failed");
1482        assert_eq!(images.len(), 2);
1483        assert!((images[0].positions[0][0] - 0.25).abs() < 1e-10);
1484        assert!((images[1].positions[0][0] - 0.75).abs() < 1e-10);
1485    }
1486
1487    #[test]
1488    fn test_poscar_roundtrip_atom_positions() {
1489        // Build a PoscarStructure, write it, read it back, compare positions.
1490        let original = PoscarStructure {
1491            comment: "Test roundtrip".into(),
1492            scale: 1.0,
1493            lattice: [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]],
1494            species: vec!["Fe".into(), "O".into()],
1495            counts: vec![1, 2],
1496            selective_dynamics: false,
1497            is_direct: true,
1498            positions: vec![[0.0, 0.0, 0.0], [0.5, 0.5, 0.0], [0.5, 0.0, 0.5]],
1499            sd_flags: None,
1500            magmom: None,
1501            forces: None,
1502        };
1503
1504        let poscar_str = original.to_poscar_string();
1505        let reader = VaspReader::new();
1506        let recovered = reader
1507            .parse_poscar_full(&poscar_str)
1508            .expect("roundtrip parse failed");
1509
1510        assert_eq!(recovered.positions.len(), 3, "must recover 3 atoms");
1511        for i in 0..3 {
1512            for j in 0..3 {
1513                assert!(
1514                    (recovered.positions[i][j] - original.positions[i][j]).abs() < 1e-8,
1515                    "position[{i}][{j}] mismatch: {} vs {}",
1516                    recovered.positions[i][j],
1517                    original.positions[i][j]
1518                );
1519            }
1520        }
1521    }
1522
1523    #[test]
1524    fn test_poscar_magmom_parsing() {
1525        let poscar = concat!(
1526            "Fe NiO\n",
1527            "1.0\n",
1528            "4.2 0.0 0.0\n",
1529            "0.0 4.2 0.0\n",
1530            "0.0 0.0 4.2\n",
1531            "Fe Ni\n",
1532            "1 1\n",
1533            "Direct\n",
1534            "0.0 0.0 0.0\n",
1535            "0.5 0.5 0.5\n",
1536            "# MAGMOM = 4.0 -4.0\n",
1537        );
1538        let reader = VaspReader::new();
1539        let ps = reader.parse_poscar_full(poscar).expect("parse failed");
1540        let mm = ps.magmom.as_ref().expect("magmom must be Some");
1541        assert_eq!(mm.len(), 2, "should have 2 magnetic moments");
1542        assert!((mm[0] - 4.0).abs() < 1e-9, "mm[0] should be 4.0");
1543        assert!((mm[1] - (-4.0)).abs() < 1e-9, "mm[1] should be -4.0");
1544    }
1545
1546    #[test]
1547    fn test_parse_outcar_forces_basic() {
1548        // Simulate a minimal OUTCAR excerpt with force data.
1549        let outcar = concat!(
1550            " some header\n",
1551            " TOTAL-FORCE (eV/Angst)\n",
1552            " ---------------\n",
1553            "  0.0  0.0  0.0  0.1  0.2  0.3\n",
1554            "  0.5  0.5  0.5 -0.1 -0.2 -0.3\n",
1555        );
1556        let forces = parse_outcar_forces(outcar, 2).expect("parse_outcar_forces failed");
1557        assert_eq!(forces.len(), 2, "must parse 2 force entries");
1558        assert!((forces[0][0] - 0.1).abs() < 1e-10, "fx[0] mismatch");
1559        assert!((forces[0][1] - 0.2).abs() < 1e-10, "fy[0] mismatch");
1560        assert!((forces[0][2] - 0.3).abs() < 1e-10, "fz[0] mismatch");
1561        assert!((forces[1][0] - (-0.1)).abs() < 1e-10, "fx[1] mismatch");
1562        assert!((forces[1][1] - (-0.2)).abs() < 1e-10, "fy[1] mismatch");
1563        assert!((forces[1][2] - (-0.3)).abs() < 1e-10, "fz[1] mismatch");
1564    }
1565}