1#![allow(dead_code)]
12
13use crate::{Error, Result};
14use std::collections::HashMap;
15
16#[derive(Debug, Clone)]
22pub struct CrystalAtom {
23 pub element: String,
25 pub fractional_pos: [f64; 3],
27 pub occupancy: f64,
29 pub b_factor: f64,
31}
32
33#[derive(Debug, Clone)]
35pub struct CrystalStructure {
36 pub lattice: [[f64; 3]; 3],
38 pub atoms: Vec<CrystalAtom>,
40 pub space_group: u16,
42}
43
44#[derive(Debug, Clone)]
46pub struct XrdPattern {
47 pub two_theta: Vec<f64>,
49 pub intensity: Vec<f64>,
51}
52
53#[derive(Debug, Default)]
59pub struct CifParser;
60
61impl CifParser {
62 pub fn new() -> Self {
64 Self
65 }
66
67 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 if line.is_empty() || line.starts_with('#') {
82 continue;
83 }
84
85 if line.eq_ignore_ascii_case("loop_") {
86 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 let key = line.split_whitespace().next().unwrap_or("").to_lowercase();
101 loop_headers.push(key);
102 continue;
103 }
104 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 continue;
115 }
116
117 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 kv.insert(tokens[0].to_lowercase(), String::new());
125 }
126 }
127 }
128
129 if in_loop && !current_row.is_empty() {
131 loop_rows.push(current_row);
132 }
133
134 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 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 let atoms = Self::extract_atoms(&loop_headers, &loop_rows);
159
160 Ok(CrystalStructure {
161 lattice,
162 atoms,
163 space_group,
164 })
165 }
166
167 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 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 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(); 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 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 fn strip_su(s: &str) -> &str {
228 if let Some(pos) = s.find('(') {
229 &s[..pos]
230 } else {
231 s
232 }
233 }
234
235 fn extract_atoms(headers: &[String], rows: &[Vec<String>]) -> Vec<CrystalAtom> {
237 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
280impl CrystalStructure {
285 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 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 pub fn volume(&self) -> f64 {
310 let a = self.lattice[0];
311 let b = self.lattice[1];
312 let c = self.lattice[2];
313 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 pub fn density(&self, molar_mass: f64) -> f64 {
327 const NA: f64 = 6.022_140_76e23; 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 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 pub fn d_spacing(&self, h: i32, k: i32, l: i32) -> f64 {
363 let rec = self.reciprocal_lattice();
364 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 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 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 fn approx_scattering_factor(element: &str) -> f64 {
399 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, }
448 }
449}
450
451pub 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 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 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#[derive(Debug, Clone)]
542pub struct PoscarStructure {
543 pub comment: String,
545 pub scale: f64,
547 pub lattice: [[f64; 3]; 3],
549 pub species: Vec<String>,
551 pub counts: Vec<usize>,
553 pub selective_dynamics: bool,
555 pub is_direct: bool,
557 pub positions: Vec<[f64; 3]>,
559 pub sd_flags: Option<Vec<[bool; 3]>>,
562 pub magmom: Option<Vec<f64>>,
565 pub forces: Option<Vec<[f64; 3]>>,
568}
569
570impl PoscarStructure {
571 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 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
662pub fn parse_outcar_forces(content: &str, n_atoms: usize) -> Option<Vec<[f64; 3]>> {
667 let marker = "TOTAL-FORCE (eV/Angst)";
669 let last_pos = content.rfind(marker)?;
670 let after = &content[last_pos..];
671 let mut lines = after.lines().skip(1);
673 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#[derive(Debug, Default)]
698pub struct VaspReader;
699
700impl VaspReader {
701 pub fn new() -> Self {
703 Self
704 }
705
706 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 pub fn parse_poscar_full(&self, content: &str) -> Result<PoscarStructure> {
719 let mut lines = content.lines().peekable();
720
721 let comment = lines.next().unwrap_or("").trim().to_string();
723 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 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 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 (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 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 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 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 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 for line in lines {
819 trailing.push(line.trim().to_string());
820 }
821
822 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 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
861pub fn read_poscar_images(content: &str) -> Result<Vec<PoscarStructure>> {
866 let reader = VaspReader::new();
869
870 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
889impl XrdPattern {
894 pub fn new(two_theta: Vec<f64>, intensity: Vec<f64>) -> Self {
896 Self {
897 two_theta,
898 intensity,
899 }
900 }
901
902 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 pub fn d_spacings(&self) -> Vec<f64> {
925 const LAMBDA: f64 = 1.5406; 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
941pub 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 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 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 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 let (fre, fim) = crystal.structure_factor(h, k, l);
1002 let f2 = fre * fre + fim * fim;
1003
1004 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 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#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[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 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 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 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 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 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); 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); assert!(rho > 0.0, "density must be positive");
1190 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 assert!((crystal.lattice[0][0] - 2.0).abs() < 1e-10);
1236 }
1237
1238 #[test]
1239 fn test_xrd_pattern_find_peaks() {
1240 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 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 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); }
1350
1351 #[test]
1356 fn test_poscar_selective_dynamics_flags_preserved() {
1357 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 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 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 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 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 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 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 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 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}