Skip to main content

xtalforge_core/
p1.rs

1//! P1 expansion with provenance back to the asymmetric site and operation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::Error;
6use crate::Lattice;
7use crate::Structure;
8use crate::spatial::{FractionalGrid, perpendicular_widths};
9use crate::symop::SymOp;
10
11/// One atom of an expanded P1 cell.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct P1Atom {
14    /// Element symbol.
15    pub element: String,
16    /// Site label inherited from the asymmetric unit.
17    pub label: String,
18    /// Fractional coordinates in the cell.
19    pub frac: [f64; 3],
20    /// Site occupancy.
21    pub occupancy: f64,
22    /// Index of the parent site.
23    pub site_idx: usize,
24    /// Index of the operation that generated this atom.
25    pub op_idx: usize,
26    /// Integer lattice shift applied after the operation.
27    pub tau: [i32; 3],
28    /// Disorder group inherited from the parent site, when present.
29    #[serde(default)]
30    pub disorder_group: Option<u32>,
31}
32
33/// The expanded P1 cell.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct P1Cell {
36    /// Cell lattice.
37    pub lattice: Lattice,
38    /// Expanded atoms.
39    pub atoms: Vec<P1Atom>,
40}
41
42impl P1Cell {
43    /// Expands a structure with its explicit operations, or the identity when
44    /// it has none.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`Error::InvalidInput`] for a negative or non-finite tolerance.
49    pub fn expand(structure: &Structure, tolerance: f64) -> Result<Self, Error> {
50        if !tolerance.is_finite() || tolerance < 0.0 {
51            return Err(Error::InvalidInput(
52                "P1 expansion tolerance must be non-negative and finite".to_owned(),
53            ));
54        }
55        let identity = [SymOp::identity()];
56        let operations = if structure.operations().is_empty() {
57            identity.as_slice()
58        } else {
59            structure.operations()
60        };
61        let widths = perpendicular_widths(structure.lattice())?;
62        let mut atoms: Vec<P1Atom> = Vec::new();
63        for (site_idx, site) in structure.sites().iter().enumerate() {
64            // Coincidences are per site: two disordered sites may occupy the
65            // same point and both must survive the expansion.
66            let mut grid = FractionalGrid::new(widths, tolerance);
67            for (op_idx, operation) in operations.iter().enumerate() {
68                let base = operation.apply(site.frac());
69                let [bx, by, bz] = base;
70                // `x - floor(x)` is exactly 1.0 for a tiny negative `x` such
71                // as -1e-32 (a Cartesian round trip of 0); such a value is
72                // 0 of the next cell so the coordinate stays in [0, 1).
73                let unit = |value: f64| {
74                    let floor = value.floor();
75                    let frac = value - floor;
76                    if frac >= 1.0 {
77                        (0.0, floor + 1.0)
78                    } else {
79                        (frac, floor)
80                    }
81                };
82                let (fx, floor_x) = unit(bx);
83                let (fy, floor_y) = unit(by);
84                let (fz, floor_z) = unit(bz);
85                let frac = [fx, fy, fz];
86                let tau = [-floor_x as i32, -floor_y as i32, -floor_z as i32];
87                let mut duplicate = false;
88                grid.for_each_candidate(site.element(), frac, |existing_index| {
89                    let Some(existing) = atoms.get(existing_index) else {
90                        return true;
91                    };
92                    if structure.lattice().distance(existing.frac, frac) <= tolerance {
93                        duplicate = true;
94                        return false;
95                    }
96                    true
97                });
98                if duplicate {
99                    continue;
100                }
101                let atom_index = atoms.len();
102                atoms.push(P1Atom {
103                    element: site.element().to_owned(),
104                    label: site.label().to_owned(),
105                    frac,
106                    occupancy: site.occupancy(),
107                    site_idx,
108                    op_idx,
109                    tau,
110                    disorder_group: site.disorder_group(),
111                });
112                grid.insert(site.element(), frac, atom_index);
113            }
114        }
115        Ok(Self {
116            lattice: *structure.lattice(),
117            atoms,
118        })
119    }
120
121    /// Number of atoms in the P1 cell.
122    #[must_use]
123    pub fn n_atoms(&self) -> usize {
124        self.atoms.len()
125    }
126
127    /// Element symbols in atom order.
128    #[must_use]
129    pub fn elements(&self) -> Vec<String> {
130        self.atoms.iter().map(|atom| atom.element.clone()).collect()
131    }
132
133    /// Fractional coordinates in atom order.
134    #[must_use]
135    pub fn frac(&self) -> Vec<[f64; 3]> {
136        self.atoms.iter().map(|atom| atom.frac).collect()
137    }
138
139    /// Occupancies in atom order.
140    #[must_use]
141    pub fn occupancy(&self) -> Vec<f64> {
142        self.atoms.iter().map(|atom| atom.occupancy).collect()
143    }
144
145    /// Atoms generated by one asymmetric site.
146    #[must_use]
147    pub fn images_of(&self, site_idx: usize) -> Vec<&P1Atom> {
148        self.atoms
149            .iter()
150            .filter(|atom| atom.site_idx == site_idx)
151            .collect()
152    }
153}
154
155impl Structure {
156    /// Returns this structure as an explicit P1 structure.
157    ///
158    /// A structure that is already P1 is cloned. Otherwise the cell is
159    /// expanded with its operations, the operations are dropped and the
160    /// original site metadata (occupancy, displacement parameters, charge and
161    /// props) is kept. `Structure::supercell` uses this so a supercell of a
162    /// symmetric structure contains the whole cell, not only the asymmetric
163    /// unit.
164    ///
165    /// # Errors
166    ///
167    /// Returns the errors of the P1 expansion or of the rebuilt sites.
168    pub fn as_p1(&self) -> Result<Self, Error> {
169        if self.is_p1() {
170            return Ok(self.clone());
171        }
172        let expanded = self.p1()?;
173        let mut builder = crate::StructureBuilder::new(self.name(), *self.lattice()).hall("P 1");
174        for atom in &expanded.atoms {
175            let site = self.sites().get(atom.site_idx).ok_or_else(|| {
176                Error::InvalidInput("the P1 expansion references a missing site".to_owned())
177            })?;
178            builder = builder.site(site.clone().with_frac(atom.frac)?);
179        }
180        for (key, value) in self.metadata() {
181            builder = builder.metadata(key.clone(), value.clone());
182        }
183        builder.build()
184    }
185}
186
187impl Structure {
188    /// Expands this structure to P1 with the default tolerance.
189    ///
190    /// # Errors
191    ///
192    /// Returns the errors of [`P1Cell::expand`].
193    pub fn p1(&self) -> Result<P1Cell, Error> {
194        P1Cell::expand(self, 0.05)
195    }
196}