1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UnitCell {
15 pub lattice: [[f64; 3]; 3],
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct CellParameters {
22 pub a: f64,
24 pub b: f64,
25 pub c: f64,
26 pub alpha: f64,
28 pub beta: f64,
29 pub gamma: f64,
30}
31
32impl UnitCell {
33 pub fn new(lattice: [[f64; 3]; 3]) -> Self {
35 Self { lattice }
36 }
37
38 pub fn cubic(a: f64) -> Self {
40 Self {
41 lattice: [[a, 0.0, 0.0], [0.0, a, 0.0], [0.0, 0.0, a]],
42 }
43 }
44
45 pub fn from_parameters(params: &CellParameters) -> Self {
49 let alpha = params.alpha.to_radians();
50 let beta = params.beta.to_radians();
51 let gamma = params.gamma.to_radians();
52
53 let cos_alpha = alpha.cos();
54 let cos_beta = beta.cos();
55 let cos_gamma = gamma.cos();
56 let sin_gamma = gamma.sin();
57
58 let vol_factor = 1.0 - cos_alpha * cos_alpha - cos_beta * cos_beta - cos_gamma * cos_gamma
61 + 2.0 * cos_alpha * cos_beta * cos_gamma;
62 if vol_factor <= 0.0 {
63 eprintln!(
64 "Warning: cell angles α={:.1}° β={:.1}° γ={:.1}° produce non-positive volume",
65 params.alpha, params.beta, params.gamma
66 );
67 }
68
69 if sin_gamma.abs() < 1e-12 {
71 return Self {
72 lattice: [
73 [params.a, 0.0, 0.0],
74 [0.0, params.b, 0.0],
75 [0.0, 0.0, params.c],
76 ],
77 };
78 }
79
80 let ax = params.a;
82 let bx = params.b * cos_gamma;
83 let by = params.b * sin_gamma;
84 let cx = params.c * cos_beta;
85 let cy = params.c * (cos_alpha - cos_beta * cos_gamma) / sin_gamma;
86 let cz_sq = params.c * params.c - cx * cx - cy * cy;
87
88 let cz = if cz_sq > 0.0 { cz_sq.sqrt() } else { 0.0 };
90
91 Self {
92 lattice: [[ax, 0.0, 0.0], [bx, by, 0.0], [cx, cy, cz]],
93 }
94 }
95
96 pub fn parameters(&self) -> CellParameters {
98 let a_vec = self.lattice[0];
99 let b_vec = self.lattice[1];
100 let c_vec = self.lattice[2];
101
102 let a = norm3(a_vec);
103 let b = norm3(b_vec);
104 let c = norm3(c_vec);
105
106 let alpha = angle_between(b_vec, c_vec).to_degrees();
107 let beta = angle_between(a_vec, c_vec).to_degrees();
108 let gamma = angle_between(a_vec, b_vec).to_degrees();
109
110 CellParameters {
111 a,
112 b,
113 c,
114 alpha,
115 beta,
116 gamma,
117 }
118 }
119
120 pub fn volume(&self) -> f64 {
122 let a = self.lattice[0];
123 let b = self.lattice[1];
124 let c = self.lattice[2];
125 let bxc = cross3(b, c);
127 dot3(a, bxc).abs()
128 }
129
130 pub fn frac_to_cart(&self, frac: [f64; 3]) -> [f64; 3] {
133 let a = self.lattice[0];
134 let b = self.lattice[1];
135 let c = self.lattice[2];
136 [
137 frac[0] * a[0] + frac[1] * b[0] + frac[2] * c[0],
138 frac[0] * a[1] + frac[1] * b[1] + frac[2] * c[1],
139 frac[0] * a[2] + frac[1] * b[2] + frac[2] * c[2],
140 ]
141 }
142
143 pub fn cart_to_frac(&self, cart: [f64; 3]) -> [f64; 3] {
147 let inv = self.inverse_matrix();
148 [
150 inv[0][0] * cart[0] + inv[1][0] * cart[1] + inv[2][0] * cart[2],
151 inv[0][1] * cart[0] + inv[1][1] * cart[1] + inv[2][1] * cart[2],
152 inv[0][2] * cart[0] + inv[1][2] * cart[1] + inv[2][2] * cart[2],
153 ]
154 }
155
156 pub fn wrap_frac(frac: [f64; 3]) -> [f64; 3] {
158 [
159 frac[0] - frac[0].floor(),
160 frac[1] - frac[1].floor(),
161 frac[2] - frac[2].floor(),
162 ]
163 }
164
165 pub fn wrap_cart(&self, cart: [f64; 3]) -> [f64; 3] {
167 let frac = self.cart_to_frac(cart);
168 let wrapped = Self::wrap_frac(frac);
169 self.frac_to_cart(wrapped)
170 }
171
172 pub fn minimum_image_distance(&self, a: [f64; 3], b: [f64; 3]) -> f64 {
174 let fa = self.cart_to_frac(a);
175 let fb = self.cart_to_frac(b);
176 let mut df = [fa[0] - fb[0], fa[1] - fb[1], fa[2] - fb[2]];
177 for d in &mut df {
179 *d -= d.round();
180 }
181 let dc = self.frac_to_cart(df);
182 norm3(dc)
183 }
184
185 pub fn supercell(&self, na: usize, nb: usize, nc: usize) -> (UnitCell, Vec<[f64; 3]>) {
188 let new_lattice = [
189 [
190 self.lattice[0][0] * na as f64,
191 self.lattice[0][1] * na as f64,
192 self.lattice[0][2] * na as f64,
193 ],
194 [
195 self.lattice[1][0] * nb as f64,
196 self.lattice[1][1] * nb as f64,
197 self.lattice[1][2] * nb as f64,
198 ],
199 [
200 self.lattice[2][0] * nc as f64,
201 self.lattice[2][1] * nc as f64,
202 self.lattice[2][2] * nc as f64,
203 ],
204 ];
205
206 let mut offsets = Vec::with_capacity(na * nb * nc);
207 for ia in 0..na {
208 for ib in 0..nb {
209 for ic in 0..nc {
210 offsets.push([ia as f64, ib as f64, ic as f64]);
211 }
212 }
213 }
214
215 (UnitCell::new(new_lattice), offsets)
216 }
217
218 pub(crate) fn inverse_matrix(&self) -> [[f64; 3]; 3] {
220 let m = self.lattice;
221 let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
222 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
223 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
224
225 let inv_det = 1.0 / det;
226
227 [
228 [
229 (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
230 (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
231 (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
232 ],
233 [
234 (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
235 (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
236 (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
237 ],
238 [
239 (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
240 (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
241 (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
242 ],
243 ]
244 }
245}
246
247fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
248 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
249}
250
251fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
252 [
253 a[1] * b[2] - a[2] * b[1],
254 a[2] * b[0] - a[0] * b[2],
255 a[0] * b[1] - a[1] * b[0],
256 ]
257}
258
259fn norm3(v: [f64; 3]) -> f64 {
260 (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
261}
262
263fn angle_between(a: [f64; 3], b: [f64; 3]) -> f64 {
264 let cos_angle = dot3(a, b) / (norm3(a) * norm3(b));
265 cos_angle.clamp(-1.0, 1.0).acos()
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct PowderXrdResult {
271 pub two_theta: Vec<f64>,
273 pub intensities: Vec<f64>,
275 pub miller_indices: Vec<[i32; 3]>,
277 pub d_spacings: Vec<f64>,
279}
280
281pub fn simulate_powder_xrd(
290 cell: &UnitCell,
291 elements: &[u8],
292 frac_coords: &[[f64; 3]],
293 two_theta_max: f64,
294) -> PowderXrdResult {
295 let lambda = 1.5406; let params = cell.parameters();
299 let d_min = lambda / (2.0 * (two_theta_max.to_radians() / 2.0).sin());
300 let h_max = (params.a / d_min).ceil() as i32 + 1;
301 let k_max = (params.b / d_min).ceil() as i32 + 1;
302 let l_max = (params.c / d_min).ceil() as i32 + 1;
303
304 let mut reflections = Vec::new();
305
306 for h in -h_max..=h_max {
307 for k in -k_max..=k_max {
308 for l in -l_max..=l_max {
309 if h == 0 && k == 0 && l == 0 {
310 continue;
311 }
312
313 let g = [
315 h as f64 * cell.inverse_matrix()[0][0]
316 + k as f64 * cell.inverse_matrix()[1][0]
317 + l as f64 * cell.inverse_matrix()[2][0],
318 h as f64 * cell.inverse_matrix()[0][1]
319 + k as f64 * cell.inverse_matrix()[1][1]
320 + l as f64 * cell.inverse_matrix()[2][1],
321 h as f64 * cell.inverse_matrix()[0][2]
322 + k as f64 * cell.inverse_matrix()[1][2]
323 + l as f64 * cell.inverse_matrix()[2][2],
324 ];
325 let g_len = norm3(g);
326 let d = 1.0 / g_len;
327
328 let sin_theta = lambda / (2.0 * d);
330 if !(0.0..=1.0).contains(&sin_theta) {
331 continue;
332 }
333 let two_theta_val = 2.0 * sin_theta.asin().to_degrees();
334 if !(1.0..=two_theta_max).contains(&two_theta_val) {
335 continue;
336 }
337
338 let mut f_real = 0.0f64;
340 let mut f_imag = 0.0f64;
341 for (i, &elem) in elements.iter().enumerate() {
342 let f_atom = elem as f64; let phase = 2.0
344 * std::f64::consts::PI
345 * (h as f64 * frac_coords[i][0]
346 + k as f64 * frac_coords[i][1]
347 + l as f64 * frac_coords[i][2]);
348 f_real += f_atom * phase.cos();
349 f_imag += f_atom * phase.sin();
350 }
351 let intensity = f_real * f_real + f_imag * f_imag;
352
353 if intensity > 1e-6 {
354 reflections.push((two_theta_val, intensity, [h, k, l], d));
355 }
356 }
357 }
358 }
359
360 reflections.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
362
363 let mut merged_theta: Vec<f64> = Vec::new();
364 let mut merged_intensity = Vec::new();
365 let mut merged_hkl = Vec::new();
366 let mut merged_d = Vec::new();
367
368 for (tt, intens, hkl, d) in &reflections {
369 if let Some(last_tt) = merged_theta.last() {
370 if (*tt - *last_tt).abs() < 0.01 {
371 let idx = merged_intensity.len() - 1;
372 merged_intensity[idx] += intens;
373 continue;
374 }
375 }
376 merged_theta.push(*tt);
377 merged_intensity.push(*intens);
378 merged_hkl.push(*hkl);
379 merged_d.push(*d);
380 }
381
382 let max_i = merged_intensity
384 .iter()
385 .cloned()
386 .fold(0.0f64, f64::max)
387 .max(1e-10);
388 for i in merged_intensity.iter_mut() {
389 *i = *i / max_i * 100.0;
390 }
391
392 PowderXrdResult {
393 two_theta: merged_theta,
394 intensities: merged_intensity,
395 miller_indices: merged_hkl,
396 d_spacings: merged_d,
397 }
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct SymmetryOperation {
403 pub rotation: [[i32; 3]; 3],
405 pub translation: [f64; 3],
407 pub label: String,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct SpaceGroup {
414 pub number: u16,
416 pub symbol: String,
418 pub crystal_system: String,
420 pub operations: Vec<SymmetryOperation>,
422}
423
424pub fn get_space_group(number: u16) -> Option<SpaceGroup> {
426 match number {
427 1 => Some(SpaceGroup {
428 number: 1,
429 symbol: "P1".to_string(),
430 crystal_system: "triclinic".to_string(),
431 operations: vec![SymmetryOperation {
432 rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
433 translation: [0.0, 0.0, 0.0],
434 label: "x,y,z".to_string(),
435 }],
436 }),
437 2 => Some(SpaceGroup {
438 number: 2,
439 symbol: "P-1".to_string(),
440 crystal_system: "triclinic".to_string(),
441 operations: vec![
442 SymmetryOperation {
443 rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
444 translation: [0.0, 0.0, 0.0],
445 label: "x,y,z".to_string(),
446 },
447 SymmetryOperation {
448 rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, -1]],
449 translation: [0.0, 0.0, 0.0],
450 label: "-x,-y,-z".to_string(),
451 },
452 ],
453 }),
454 14 => Some(SpaceGroup {
455 number: 14,
456 symbol: "P2_1/c".to_string(),
457 crystal_system: "monoclinic".to_string(),
458 operations: vec![
459 SymmetryOperation {
460 rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
461 translation: [0.0, 0.0, 0.0],
462 label: "x,y,z".to_string(),
463 },
464 SymmetryOperation {
465 rotation: [[-1, 0, 0], [0, 1, 0], [0, 0, -1]],
466 translation: [0.0, 0.5, 0.5],
467 label: "-x,y+1/2,-z+1/2".to_string(),
468 },
469 SymmetryOperation {
470 rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, -1]],
471 translation: [0.0, 0.0, 0.0],
472 label: "-x,-y,-z".to_string(),
473 },
474 SymmetryOperation {
475 rotation: [[1, 0, 0], [0, -1, 0], [0, 0, 1]],
476 translation: [0.0, 0.5, 0.5],
477 label: "x,-y+1/2,z+1/2".to_string(),
478 },
479 ],
480 }),
481 225 => Some(SpaceGroup {
482 number: 225,
483 symbol: "Fm-3m".to_string(),
484 crystal_system: "cubic".to_string(),
485 operations: vec![
486 SymmetryOperation {
487 rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
488 translation: [0.0, 0.0, 0.0],
489 label: "x,y,z".to_string(),
490 },
491 SymmetryOperation {
492 rotation: [[-1, 0, 0], [0, -1, 0], [0, 0, 1]],
493 translation: [0.0, 0.0, 0.0],
494 label: "-x,-y,z".to_string(),
495 },
496 SymmetryOperation {
497 rotation: [[-1, 0, 0], [0, 1, 0], [0, 0, -1]],
498 translation: [0.0, 0.0, 0.0],
499 label: "-x,y,-z".to_string(),
500 },
501 SymmetryOperation {
502 rotation: [[1, 0, 0], [0, -1, 0], [0, 0, -1]],
503 translation: [0.0, 0.0, 0.0],
504 label: "x,-y,-z".to_string(),
505 },
506 ],
507 }),
508 _ => None,
509 }
510}
511
512pub fn apply_symmetry(op: &SymmetryOperation, frac: [f64; 3]) -> [f64; 3] {
514 let r = op.rotation;
515 let t = op.translation;
516 [
517 r[0][0] as f64 * frac[0] + r[0][1] as f64 * frac[1] + r[0][2] as f64 * frac[2] + t[0],
518 r[1][0] as f64 * frac[0] + r[1][1] as f64 * frac[1] + r[1][2] as f64 * frac[2] + t[1],
519 r[2][0] as f64 * frac[0] + r[2][1] as f64 * frac[1] + r[2][2] as f64 * frac[2] + t[2],
520 ]
521}
522
523pub fn expand_by_symmetry(
525 space_group: &SpaceGroup,
526 frac_coords: &[[f64; 3]],
527 elements: &[u8],
528) -> (Vec<[f64; 3]>, Vec<u8>) {
529 let mut all_coords = Vec::new();
530 let mut all_elements = Vec::new();
531
532 for (i, &fc) in frac_coords.iter().enumerate() {
533 for op in &space_group.operations {
534 let new_fc = apply_symmetry(op, fc);
535 let wrapped = UnitCell::wrap_frac(new_fc);
536
537 let is_dup = all_coords.iter().any(|existing: &[f64; 3]| {
539 let dx = (existing[0] - wrapped[0]).abs();
540 let dy = (existing[1] - wrapped[1]).abs();
541 let dz = (existing[2] - wrapped[2]).abs();
542 let dx = dx.min(1.0 - dx);
544 let dy = dy.min(1.0 - dy);
545 let dz = dz.min(1.0 - dz);
546 dx < 0.01 && dy < 0.01 && dz < 0.01
547 });
548
549 if !is_dup {
550 all_coords.push(wrapped);
551 all_elements.push(elements[i]);
552 }
553 }
554 }
555
556 (all_coords, all_elements)
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct PorosityResult {
562 pub pore_volume: f64,
564 pub porosity: f64,
566 pub largest_cavity_diameter: f64,
568 pub pore_limiting_diameter: f64,
570}
571
572pub fn compute_porosity(
577 cell: &UnitCell,
578 elements: &[u8],
579 frac_coords: &[[f64; 3]],
580 probe_radius: f64,
581 grid_spacing: f64,
582) -> PorosityResult {
583 let params = cell.parameters();
584 let nx = (params.a / grid_spacing).ceil() as usize;
585 let ny = (params.b / grid_spacing).ceil() as usize;
586 let nz = (params.c / grid_spacing).ceil() as usize;
587
588 let total_points = nx * ny * nz;
589 let mut void_count = 0usize;
590 let mut max_void_dist = 0.0f64;
591 let mut min_void_dist = f64::INFINITY;
592
593 let atom_positions: Vec<[f64; 3]> = frac_coords
595 .iter()
596 .map(|&fc| cell.frac_to_cart(fc))
597 .collect();
598 let vdw_radii: Vec<f64> = elements.iter().map(|&z| vdw_radius(z)).collect();
599
600 for ix in 0..nx {
601 for iy in 0..ny {
602 for iz in 0..nz {
603 let frac = [
604 (ix as f64 + 0.5) / nx as f64,
605 (iy as f64 + 0.5) / ny as f64,
606 (iz as f64 + 0.5) / nz as f64,
607 ];
608 let cart = cell.frac_to_cart(frac);
609
610 let mut min_surface_dist = f64::INFINITY;
612 for (j, &pos) in atom_positions.iter().enumerate() {
613 let d = cell.minimum_image_distance(cart, pos);
614 let surface_d = d - vdw_radii[j];
615 min_surface_dist = min_surface_dist.min(surface_d);
616 }
617
618 if min_surface_dist > probe_radius {
619 void_count += 1;
620 max_void_dist = max_void_dist.max(min_surface_dist);
621 min_void_dist = min_void_dist.min(min_surface_dist);
622 }
623 }
624 }
625 }
626
627 let vol = cell.volume();
628 let voxel_vol = vol / total_points as f64;
629 let pore_volume = void_count as f64 * voxel_vol;
630 let porosity = void_count as f64 / total_points as f64;
631
632 PorosityResult {
633 pore_volume,
634 porosity,
635 largest_cavity_diameter: if max_void_dist > 0.0 {
636 2.0 * max_void_dist
637 } else {
638 0.0
639 },
640 pore_limiting_diameter: if min_void_dist < f64::INFINITY && void_count > 0 {
641 2.0 * min_void_dist
642 } else {
643 0.0
644 },
645 }
646}
647
648fn vdw_radius(z: u8) -> f64 {
650 match z {
651 1 => 1.20,
652 6 => 1.70,
653 7 => 1.55,
654 8 => 1.52,
655 9 => 1.47,
656 15 => 1.80,
657 16 => 1.80,
658 17 => 1.75,
659 22 => 2.00, 26 => 2.00, 29 => 1.40, 30 => 1.39, 35 => 1.85,
664 53 => 1.98,
665 _ => 1.80,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn test_cubic_cell_volume() {
675 let cell = UnitCell::cubic(10.0);
676 assert!((cell.volume() - 1000.0).abs() < 1e-10);
677 }
678
679 #[test]
680 fn test_cubic_parameters() {
681 let cell = UnitCell::cubic(5.0);
682 let p = cell.parameters();
683 assert!((p.a - 5.0).abs() < 1e-10);
684 assert!((p.b - 5.0).abs() < 1e-10);
685 assert!((p.c - 5.0).abs() < 1e-10);
686 assert!((p.alpha - 90.0).abs() < 1e-10);
687 assert!((p.beta - 90.0).abs() < 1e-10);
688 assert!((p.gamma - 90.0).abs() < 1e-10);
689 }
690
691 #[test]
692 fn test_frac_cart_roundtrip() {
693 let cell = UnitCell::from_parameters(&CellParameters {
694 a: 10.0,
695 b: 12.0,
696 c: 8.0,
697 alpha: 90.0,
698 beta: 90.0,
699 gamma: 120.0,
700 });
701 let frac = [0.3, 0.4, 0.7];
702 let cart = cell.frac_to_cart(frac);
703 let back = cell.cart_to_frac(cart);
704 for i in 0..3 {
705 assert!(
706 (frac[i] - back[i]).abs() < 1e-10,
707 "Roundtrip failed at {i}: {:.6} vs {:.6}",
708 frac[i],
709 back[i]
710 );
711 }
712 }
713
714 #[test]
715 fn test_wrap_frac() {
716 let wrapped = UnitCell::wrap_frac([1.3, -0.2, 2.7]);
717 assert!((wrapped[0] - 0.3).abs() < 1e-10);
718 assert!((wrapped[1] - 0.8).abs() < 1e-10);
719 assert!((wrapped[2] - 0.7).abs() < 1e-10);
720 }
721
722 #[test]
723 fn test_minimum_image_distance_cubic() {
724 let cell = UnitCell::cubic(10.0);
725 let dist = cell.minimum_image_distance([1.0, 0.0, 0.0], [9.0, 0.0, 0.0]);
728 assert!(
729 (dist - 2.0).abs() < 1e-10,
730 "Minimum image distance should be 2.0, got {dist:.6}"
731 );
732 }
733
734 #[test]
735 fn test_supercell_offsets() {
736 let cell = UnitCell::cubic(5.0);
737 let (super_cell, offsets) = cell.supercell(2, 2, 2);
738 assert_eq!(offsets.len(), 8);
739 assert!((super_cell.lattice[0][0] - 10.0).abs() < 1e-10);
740 assert!((super_cell.volume() - 5.0f64.powi(3) * 8.0).abs() < 1e-6);
741 }
742
743 #[test]
744 fn test_from_parameters_orthorhombic() {
745 let p = CellParameters {
746 a: 5.0,
747 b: 7.0,
748 c: 9.0,
749 alpha: 90.0,
750 beta: 90.0,
751 gamma: 90.0,
752 };
753 let cell = UnitCell::from_parameters(&p);
754 assert!((cell.volume() - 315.0).abs() < 1e-6);
755 let back = cell.parameters();
756 assert!((back.a - 5.0).abs() < 1e-6);
757 assert!((back.b - 7.0).abs() < 1e-6);
758 assert!((back.c - 9.0).abs() < 1e-6);
759 }
760}