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, Default)]
538pub struct VaspReader;
539
540impl VaspReader {
541 pub fn new() -> Self {
543 Self
544 }
545
546 pub fn parse_poscar(&self, content: &str) -> Result<CrystalStructure> {
551 let mut lines = content.lines().peekable();
552
553 let _comment = lines.next().unwrap_or("").trim().to_string();
555 let scale: f64 = lines
557 .next()
558 .unwrap_or("1.0")
559 .trim()
560 .parse()
561 .map_err(|_| Error::Parse("POSCAR: invalid scale factor".into()))?;
562
563 let mut lattice = [[0.0_f64; 3]; 3];
565 for row in lattice.iter_mut() {
566 let line = lines
567 .next()
568 .ok_or_else(|| Error::Parse("POSCAR: missing lattice vector".into()))?;
569 let vals: Vec<f64> = line
570 .split_whitespace()
571 .filter_map(|s| s.parse().ok())
572 .collect();
573 if vals.len() < 3 {
574 return Err(Error::Parse("POSCAR: bad lattice vector".into()));
575 }
576 row[0] = vals[0] * scale;
577 row[1] = vals[1] * scale;
578 row[2] = vals[2] * scale;
579 }
580
581 let species_line = lines.next().unwrap_or("").trim().to_string();
583 let (elements, count_line) = if species_line
584 .chars()
585 .next()
586 .map(|c| c.is_alphabetic())
587 .unwrap_or(false)
588 {
589 let elems: Vec<String> = species_line
590 .split_whitespace()
591 .map(|s| s.to_string())
592 .collect();
593 let cl = lines.next().unwrap_or("").trim().to_string();
594 (elems, cl)
595 } else {
596 (vec!["X".to_string()], species_line.clone())
598 };
599
600 let counts: Vec<usize> = count_line
601 .split_whitespace()
602 .filter_map(|s| s.parse().ok())
603 .collect();
604
605 let coord_type_line = lines.next().unwrap_or("").trim().to_string();
607 let coord_type_line = if coord_type_line.to_lowercase().starts_with('s') {
608 lines.next().unwrap_or("").trim().to_string()
609 } else {
610 coord_type_line
611 };
612 let is_cartesian = coord_type_line.to_lowercase().starts_with('c')
613 || coord_type_line.to_lowercase().starts_with('k');
614
615 let mut atoms = Vec::new();
617 for (ei, &count) in counts.iter().enumerate() {
618 let element = elements.get(ei).cloned().unwrap_or_else(|| "X".to_string());
619 for _ in 0..count {
620 let line = lines.next().unwrap_or("0 0 0");
621 let vals: Vec<f64> = line
622 .split_whitespace()
623 .take(3)
624 .filter_map(|s| s.parse().ok())
625 .collect();
626 let mut pos = [
627 vals.first().copied().unwrap_or(0.0),
628 vals.get(1).copied().unwrap_or(0.0),
629 vals.get(2).copied().unwrap_or(0.0),
630 ];
631 if is_cartesian {
632 pos[0] /= lattice[0][0].abs().max(1e-12);
634 pos[1] /= lattice[1][1].abs().max(1e-12);
635 pos[2] /= lattice[2][2].abs().max(1e-12);
636 }
637 atoms.push(CrystalAtom {
638 element: element.clone(),
639 fractional_pos: pos,
640 occupancy: 1.0,
641 b_factor: 0.0,
642 });
643 }
644 }
645
646 Ok(CrystalStructure {
647 lattice,
648 atoms,
649 space_group: 1,
650 })
651 }
652}
653
654impl XrdPattern {
659 pub fn new(two_theta: Vec<f64>, intensity: Vec<f64>) -> Self {
661 Self {
662 two_theta,
663 intensity,
664 }
665 }
666
667 pub fn find_peaks(&self) -> Vec<usize> {
671 let n = self.intensity.len();
672 if n < 3 {
673 return vec![];
674 }
675 let mut peaks = Vec::new();
676 for i in 1..n - 1 {
677 if self.intensity[i] > self.intensity[i - 1]
678 && self.intensity[i] > self.intensity[i + 1]
679 {
680 peaks.push(i);
681 }
682 }
683 peaks
684 }
685
686 pub fn d_spacings(&self) -> Vec<f64> {
690 const LAMBDA: f64 = 1.5406; self.find_peaks()
692 .iter()
693 .map(|&i| {
694 let theta = self.two_theta[i].to_radians() / 2.0;
695 let sin_t = theta.sin();
696 if sin_t > 1e-12 {
697 LAMBDA / (2.0 * sin_t)
698 } else {
699 f64::INFINITY
700 }
701 })
702 .collect()
703 }
704}
705
706pub fn simulate_xrd(
714 crystal: &CrystalStructure,
715 wavelength: f64,
716 theta_range: (f64, f64),
717 n_pts: usize,
718) -> XrdPattern {
719 let (two_theta_min, two_theta_max) = theta_range;
720 let step = if n_pts > 1 {
721 (two_theta_max - two_theta_min) / (n_pts - 1) as f64
722 } else {
723 0.0
724 };
725
726 let two_theta: Vec<f64> = (0..n_pts)
727 .map(|i| two_theta_min + i as f64 * step)
728 .collect();
729 let mut intensity = vec![0.0_f64; n_pts];
730
731 let max_hkl = 5_i32;
733 let vol = crystal.volume();
734 if vol < 1e-12 {
735 return XrdPattern::new(two_theta, intensity);
736 }
737
738 for h in -max_hkl..=max_hkl {
739 for k in -max_hkl..=max_hkl {
740 for l in -max_hkl..=max_hkl {
741 if h == 0 && k == 0 && l == 0 {
742 continue;
743 }
744 let d = crystal.d_spacing(h, k, l);
745 if d.is_infinite() || d < 0.5 {
746 continue;
747 }
748
749 let sin_theta = wavelength / (2.0 * d);
751 if sin_theta > 1.0 {
752 continue;
753 }
754 let two_theta_hkl = 2.0 * sin_theta.asin().to_degrees();
755
756 if two_theta_hkl < two_theta_min || two_theta_hkl > two_theta_max {
758 continue;
759 }
760 let idx = ((two_theta_hkl - two_theta_min) / step.max(1e-12)) as usize;
761 if idx >= n_pts {
762 continue;
763 }
764
765 let (fre, fim) = crystal.structure_factor(h, k, l);
767 let f2 = fre * fre + fim * fim;
768
769 let theta = two_theta_hkl.to_radians() / 2.0;
771 let lp = (1.0 + (2.0 * theta).cos().powi(2))
772 / (theta.sin().powi(2) * (2.0 * theta).cos()).abs().max(1e-12);
773
774 let sigma_pts = 2.0;
776 for di in -(3 * sigma_pts as i64)..=(3 * sigma_pts as i64) {
777 let j = idx as i64 + di;
778 if j < 0 || j >= n_pts as i64 {
779 continue;
780 }
781 let dt = di as f64 / sigma_pts;
782 intensity[j as usize] += f2 * lp * (-0.5 * dt * dt).exp();
783 }
784 }
785 }
786 }
787
788 XrdPattern::new(two_theta, intensity)
789}
790
791#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
802 fn test_cif_parser_minimal_cubic() {
803 let cif = r#"
804data_test
805_cell_length_a 5.0
806_cell_length_b 5.0
807_cell_length_c 5.0
808_cell_angle_alpha 90.0
809_cell_angle_beta 90.0
810_cell_angle_gamma 90.0
811_symmetry_int_tables_number 225
812"#;
813 let parser = CifParser::new();
814 let crystal = parser.parse(cif).unwrap();
815 assert!((crystal.volume() - 125.0).abs() < 0.01);
816 assert_eq!(crystal.space_group, 225);
817 }
818
819 #[test]
820 fn test_cif_parser_space_group_fallback() {
821 let cif = "data_x\n_cell_length_a 3.0\n_cell_length_b 3.0\n_cell_length_c 3.0\n";
822 let parser = CifParser::new();
823 let crystal = parser.parse(cif).unwrap();
824 assert_eq!(crystal.space_group, 1);
825 }
826
827 #[test]
828 fn test_cif_parser_with_standard_uncertainty() {
829 let cif =
830 "data_x\n_cell_length_a 4.123(5)\n_cell_length_b 4.123(5)\n_cell_length_c 4.123(5)\n";
831 let parser = CifParser::new();
832 let crystal = parser.parse(cif).unwrap();
833 assert!((crystal.lattice[0][0] - 4.123).abs() < 0.001);
834 }
835
836 #[test]
837 fn test_volume_cubic() {
838 let crystal = CrystalStructure::new_cubic(3.0);
839 assert!((crystal.volume() - 27.0).abs() < 1e-10);
840 }
841
842 #[test]
843 fn test_volume_orthorhombic() {
844 let crystal = CrystalStructure {
845 lattice: [[2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0]],
846 atoms: vec![],
847 space_group: 1,
848 };
849 assert!((crystal.volume() - 24.0).abs() < 1e-10);
850 }
851
852 #[test]
853 fn test_to_cartesian_fractional_origin() {
854 let mut crystal = CrystalStructure::new_cubic(4.0);
855 crystal.atoms.push(CrystalAtom {
856 element: "Fe".into(),
857 fractional_pos: [0.0, 0.0, 0.0],
858 occupancy: 1.0,
859 b_factor: 0.0,
860 });
861 let cart = crystal.to_cartesian();
862 assert!((cart[0][0]).abs() < 1e-12);
863 assert!((cart[0][1]).abs() < 1e-12);
864 assert!((cart[0][2]).abs() < 1e-12);
865 }
866
867 #[test]
868 fn test_to_cartesian_fractional_half() {
869 let mut crystal = CrystalStructure::new_cubic(4.0);
870 crystal.atoms.push(CrystalAtom {
871 element: "O".into(),
872 fractional_pos: [0.5, 0.5, 0.5],
873 occupancy: 1.0,
874 b_factor: 0.0,
875 });
876 let cart = crystal.to_cartesian();
877 assert!((cart[0][0] - 2.0).abs() < 1e-10);
878 assert!((cart[0][1] - 2.0).abs() < 1e-10);
879 assert!((cart[0][2] - 2.0).abs() < 1e-10);
880 }
881
882 #[test]
883 fn test_d_spacing_cubic_100() {
884 let crystal = CrystalStructure::new_cubic(3.0);
886 let d = crystal.d_spacing(1, 0, 0);
887 assert!((d - 3.0).abs() < 0.001, "d_100 = {d}");
888 }
889
890 #[test]
891 fn test_d_spacing_cubic_110() {
892 let a = 4.0;
894 let crystal = CrystalStructure::new_cubic(a);
895 let d = crystal.d_spacing(1, 1, 0);
896 let expected = a / 2.0_f64.sqrt();
897 assert!(
898 (d - expected).abs() < 0.001,
899 "d_110 = {d}, expected {expected}"
900 );
901 }
902
903 #[test]
904 fn test_d_spacing_cubic_111() {
905 let a = 3.0;
907 let crystal = CrystalStructure::new_cubic(a);
908 let d = crystal.d_spacing(1, 1, 1);
909 let expected = a / 3.0_f64.sqrt();
910 assert!(
911 (d - expected).abs() < 0.001,
912 "d_111 = {d}, expected {expected}"
913 );
914 }
915
916 #[test]
917 fn test_reciprocal_lattice_cubic() {
918 let a = 2.0 * std::f64::consts::PI;
919 let crystal = CrystalStructure::new_cubic(a);
920 let rec = crystal.reciprocal_lattice();
921 assert!((rec[0][0] - 1.0).abs() < 1e-10);
923 assert!((rec[1][1] - 1.0).abs() < 1e-10);
924 assert!((rec[2][2] - 1.0).abs() < 1e-10);
925 }
926
927 #[test]
928 fn test_structure_factor_single_atom_at_origin() {
929 let mut crystal = CrystalStructure::new_cubic(4.0);
930 crystal.atoms.push(CrystalAtom {
931 element: "Fe".into(),
932 fractional_pos: [0.0, 0.0, 0.0],
933 occupancy: 1.0,
934 b_factor: 0.0,
935 });
936 let (re, im) = crystal.structure_factor(1, 0, 0);
938 assert!(re > 0.0, "real part should be positive");
939 assert!(im.abs() < 1e-10, "imaginary part should be 0");
940 }
941
942 #[test]
943 fn test_density_positive() {
944 let mut crystal = CrystalStructure::new_cubic(3.615); for _ in 0..4 {
946 crystal.atoms.push(CrystalAtom {
947 element: "Cu".into(),
948 fractional_pos: [0.0, 0.0, 0.0],
949 occupancy: 1.0,
950 b_factor: 0.0,
951 });
952 }
953 let rho = crystal.density(63.546); assert!(rho > 0.0, "density must be positive");
955 assert!(
957 rho > 5.0 && rho < 15.0,
958 "density {rho} out of expected range"
959 );
960 }
961
962 #[test]
963 fn test_write_cif_roundtrip_cell() {
964 let crystal = CrystalStructure::new_cubic(5.0);
965 let cif = write_cif(&crystal);
966 assert!(cif.contains("_cell_length_a"));
967 assert!(cif.contains("5.000000"));
968 }
969
970 #[test]
971 fn test_write_cif_contains_atom_loop() {
972 let mut crystal = CrystalStructure::new_cubic(4.0);
973 crystal.atoms.push(CrystalAtom {
974 element: "Na".into(),
975 fractional_pos: [0.0, 0.0, 0.0],
976 occupancy: 1.0,
977 b_factor: 0.5,
978 });
979 let cif = write_cif(&crystal);
980 assert!(cif.contains("loop_"));
981 assert!(cif.contains("Na"));
982 }
983
984 #[test]
985 fn test_vasp_reader_simple_poscar() {
986 let poscar = "Fe BCC\n1.0\n2.87 0.0 0.0\n0.0 2.87 0.0\n0.0 0.0 2.87\nFe\n2\nDirect\n0.0 0.0 0.0\n0.5 0.5 0.5\n";
987 let reader = VaspReader::new();
988 let crystal = reader.parse_poscar(poscar).unwrap();
989 assert_eq!(crystal.atoms.len(), 2);
990 assert_eq!(crystal.atoms[0].element, "Fe");
991 }
992
993 #[test]
994 fn test_vasp_reader_lattice_scale() {
995 let poscar =
996 "Test\n2.0\n1.0 0.0 0.0\n0.0 1.0 0.0\n0.0 0.0 1.0\nH\n1\nDirect\n0.0 0.0 0.0\n";
997 let reader = VaspReader::new();
998 let crystal = reader.parse_poscar(poscar).unwrap();
999 assert!((crystal.lattice[0][0] - 2.0).abs() < 1e-10);
1001 }
1002
1003 #[test]
1004 fn test_xrd_pattern_find_peaks() {
1005 let two_theta: Vec<f64> = (0..20).map(|i| i as f64).collect();
1007 let mut intensity = vec![0.0; 20];
1008 intensity[5] = 100.0;
1009 intensity[10] = 200.0;
1010 let pattern = XrdPattern::new(two_theta, intensity);
1011 let peaks = pattern.find_peaks();
1012 assert!(peaks.contains(&5), "peak at index 5");
1013 assert!(peaks.contains(&10), "peak at index 10");
1014 }
1015
1016 #[test]
1017 fn test_xrd_d_spacings_bragg() {
1018 let two_theta = vec![40.0, 44.5, 50.0];
1020 let intensity = vec![5.0, 100.0, 5.0];
1021 let pattern = XrdPattern::new(two_theta, intensity);
1022 let ds = pattern.d_spacings();
1023 assert!(!ds.is_empty());
1024 assert!(ds[0] > 1.5 && ds[0] < 3.0, "d = {}", ds[0]);
1026 }
1027
1028 #[test]
1029 fn test_simulate_xrd_returns_correct_size() {
1030 let crystal = CrystalStructure::new_cubic(3.0);
1031 let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 100);
1032 assert_eq!(pattern.two_theta.len(), 100);
1033 assert_eq!(pattern.intensity.len(), 100);
1034 }
1035
1036 #[test]
1037 fn test_simulate_xrd_non_negative_intensity() {
1038 let mut crystal = CrystalStructure::new_cubic(3.615);
1039 crystal.atoms.push(CrystalAtom {
1040 element: "Cu".into(),
1041 fractional_pos: [0.0, 0.0, 0.0],
1042 occupancy: 1.0,
1043 b_factor: 0.0,
1044 });
1045 let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 80.0), 200);
1046 for &i in &pattern.intensity {
1047 assert!(i >= 0.0, "negative intensity: {i}");
1048 }
1049 }
1050
1051 #[test]
1052 fn test_simulate_xrd_has_peaks() {
1053 let mut crystal = CrystalStructure::new_cubic(3.615);
1054 crystal.atoms.push(CrystalAtom {
1055 element: "Cu".into(),
1056 fractional_pos: [0.0, 0.0, 0.0],
1057 occupancy: 1.0,
1058 b_factor: 0.0,
1059 });
1060 let pattern = simulate_xrd(&crystal, 1.5406, (20.0, 100.0), 500);
1061 let peaks = pattern.find_peaks();
1062 assert!(!peaks.is_empty(), "simulated XRD should have peaks");
1063 }
1064
1065 #[test]
1066 fn test_approx_scattering_factor_known() {
1067 assert!((CrystalStructure::approx_scattering_factor("Fe") - 26.0).abs() < 0.5);
1068 assert!((CrystalStructure::approx_scattering_factor("O") - 8.0).abs() < 0.5);
1069 assert!((CrystalStructure::approx_scattering_factor("Cu") - 29.0).abs() < 0.5);
1070 }
1071
1072 #[test]
1073 fn test_build_lattice_cubic_is_diagonal() {
1074 let a = 4.0;
1075 let pi2 = std::f64::consts::PI / 2.0;
1076 let lat = CifParser::build_lattice(a, a, a, pi2, pi2, pi2);
1077 assert!((lat[0][0] - a).abs() < 1e-10);
1078 assert!(lat[0][1].abs() < 1e-10);
1079 assert!(lat[0][2].abs() < 1e-10);
1080 assert!((lat[1][1] - a).abs() < 1e-8, "b[1] = {}", lat[1][1]);
1081 }
1082
1083 #[test]
1084 fn test_cif_tokenize_quoted() {
1085 let tokens = CifParser::tokenize_line("'hello world' 3.14");
1086 assert_eq!(tokens[0], "hello world");
1087 assert_eq!(tokens[1], "3.14");
1088 }
1089
1090 #[test]
1091 fn test_strip_su() {
1092 assert_eq!(CifParser::strip_su("1.234(5)"), "1.234");
1093 assert_eq!(CifParser::strip_su("5.000"), "5.000");
1094 }
1095
1096 #[test]
1097 fn test_d_spacing_000_is_infinity() {
1098 let crystal = CrystalStructure::new_cubic(3.0);
1099 let d = crystal.d_spacing(0, 0, 0);
1100 assert!(d.is_infinite());
1101 }
1102
1103 #[test]
1104 fn test_crystal_structure_no_atoms_cartesian() {
1105 let crystal = CrystalStructure::new_cubic(5.0);
1106 assert!(crystal.to_cartesian().is_empty());
1107 }
1108
1109 #[test]
1110 fn test_cif_parser_empty_returns_default() {
1111 let parser = CifParser::new();
1112 let crystal = parser.parse("").unwrap();
1113 assert!((crystal.volume() - 1.0).abs() < 1e-10); }
1115}