Skip to main content

xtalforge_core/
structure.rs

1//! Crystal structures: a lattice, a space-group identity and asymmetric sites.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::Error;
9use crate::Lattice;
10use crate::bonding::{ExplicitBond, TopologyBond};
11use crate::elements::element_by_symbol;
12use crate::site::Site;
13use crate::symop::SymOp;
14
15/// A crystal structure.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Structure {
18    name: String,
19    lattice: Lattice,
20    hall: String,
21    sites: Vec<Site>,
22    #[serde(default)]
23    operations: Vec<SymOp>,
24    #[serde(default)]
25    explicit_bonds: Vec<ExplicitBond>,
26    #[serde(default)]
27    topology_bonds: Vec<TopologyBond>,
28    metadata: BTreeMap<String, Value>,
29}
30
31mod properties;
32
33impl Structure {
34    /// Structure name.
35    #[must_use]
36    pub fn name(&self) -> &str {
37        &self.name
38    }
39
40    /// Unit cell.
41    #[must_use]
42    pub fn lattice(&self) -> &Lattice {
43        &self.lattice
44    }
45
46    /// Hall symbol of the space group; `P 1` for a P1 structure.
47    #[must_use]
48    pub fn hall(&self) -> &str {
49        &self.hall
50    }
51
52    /// Asymmetric-unit sites.
53    #[must_use]
54    pub fn sites(&self) -> &[Site] {
55        &self.sites
56    }
57
58    /// Explicit symmetry operations; empty means identity only.
59    #[must_use]
60    pub fn operations(&self) -> &[SymOp] {
61        &self.operations
62    }
63
64    /// User bonds and removals applied after perception.
65    #[must_use]
66    pub fn explicit_bonds(&self) -> &[ExplicitBond] {
67        &self.explicit_bonds
68    }
69
70    /// Topology edges, kept separate from chemical bonds.
71    #[must_use]
72    pub fn topology_bonds(&self) -> &[TopologyBond] {
73        &self.topology_bonds
74    }
75
76    /// Open structure metadata.
77    #[must_use]
78    pub fn metadata(&self) -> &BTreeMap<String, Value> {
79        &self.metadata
80    }
81
82    /// Whether the structure is P1: the Hall symbol is `P 1` (or empty) and
83    /// every operation is the identity. A CIF may carry an explicit operation
84    /// loop without a space-group symbol; such a file is not P1 even though
85    /// its Hall symbol defaults to `P 1`.
86    #[must_use]
87    pub fn is_p1(&self) -> bool {
88        let hall = self.hall.trim();
89        (hall.is_empty() || hall == "P 1" || hall == "P1")
90            && self.operations.iter().all(SymOp::is_identity)
91    }
92
93    /// Cartesian coordinates of the asymmetric sites.
94    #[must_use]
95    pub fn cart_sites(&self) -> Vec<[f64; 3]> {
96        self.sites
97            .iter()
98            .map(|site| self.lattice.to_cart(site.frac()))
99            .collect()
100    }
101
102    /// Structured warnings collected from the structure metadata.
103    #[must_use]
104    pub fn warnings(&self) -> Vec<StructureWarning> {
105        let mut warnings = Vec::new();
106        for (key, value) in &self.metadata {
107            let Some(message) = value.as_str() else {
108                continue;
109            };
110            let code = key
111                .strip_suffix("_warning")
112                .unwrap_or(key.as_str())
113                .to_owned();
114            let hint = if code.contains("symmetry") {
115                Some("use --spacegroup SYMBOL to override the setting".to_owned())
116            } else if code.contains("coincident") {
117                Some("remove one of the overlapping atoms or lower --symprec".to_owned())
118            } else {
119                None
120            };
121            warnings.push(StructureWarning {
122                code,
123                message: message.to_owned(),
124                atoms: Vec::new(),
125                fields: vec![key.clone()],
126                hint,
127            });
128        }
129        warnings
130    }
131
132    /// Summary used by the CLI, Python and WASM `info` surfaces.
133    ///
134    /// # Errors
135    ///
136    /// Returns the errors of [`Structure::density`].
137    pub fn summary(&self) -> Result<StructureSummary, Error> {
138        Ok(StructureSummary {
139            name: self.name.clone(),
140            formula: self.formula(),
141            hall: self.hall.clone(),
142            n_sites: self.sites.len(),
143            volume: self.lattice.volume(),
144            density: self.density()?,
145            total_charge: self.total_charge(),
146            is_p1: self.is_p1(),
147            warnings: self.warnings(),
148        })
149    }
150
151    /// Copy with a new name.
152    #[must_use]
153    pub fn with_name(mut self, name: impl Into<String>) -> Self {
154        self.name = name.into();
155        self
156    }
157
158    /// Copy with a new lattice.
159    #[must_use]
160    pub fn with_lattice(mut self, lattice: Lattice) -> Self {
161        self.lattice = lattice;
162        self
163    }
164
165    /// Copy with one more site.
166    #[must_use]
167    pub fn with_site(mut self, site: Site) -> Self {
168        self.sites.push(site);
169        self
170    }
171
172    /// A non-periodic molecule in a padded P1 box.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`Error::InvalidInput`] for no points, a non-positive padding,
177    /// or a point whose element is unknown.
178    pub fn molecule(
179        name: impl Into<String>,
180        points: &[(String, [f64; 3])],
181        padding: f64,
182    ) -> Result<Self, Error> {
183        if points.is_empty() {
184            return Err(Error::InvalidInput(
185                "a molecule needs at least one atom".to_owned(),
186            ));
187        }
188        if !padding.is_finite() || padding <= 0.0 {
189            return Err(Error::InvalidInput(
190                "molecule padding must be positive and finite".to_owned(),
191            ));
192        }
193        let [mut min_x, mut min_y, mut min_z] = [f64::INFINITY; 3];
194        let [mut max_x, mut max_y, mut max_z] = [f64::NEG_INFINITY; 3];
195        for (_, point) in points {
196            let [x, y, z] = *point;
197            min_x = min_x.min(x);
198            min_y = min_y.min(y);
199            min_z = min_z.min(z);
200            max_x = max_x.max(x);
201            max_y = max_y.max(y);
202            max_z = max_z.max(z);
203        }
204        let [length_x, length_y, length_z] = [
205            max_x - min_x + 2.0 * padding,
206            max_y - min_y + 2.0 * padding,
207            max_z - min_z + 2.0 * padding,
208        ];
209        let lattice = Lattice::orthorhombic(length_x, length_y, length_z)?;
210        let [origin_x, origin_y, origin_z] = [min_x - padding, min_y - padding, min_z - padding];
211        let mut builder = StructureBuilder::new(name, lattice);
212        for (index, (element, point)) in points.iter().enumerate() {
213            let [x, y, z] = *point;
214            let frac = [
215                (x - origin_x) / length_x,
216                (y - origin_y) / length_y,
217                (z - origin_z) / length_z,
218            ];
219            builder = builder.site(Site::new(
220                format!("{}{}", element, index + 1),
221                element,
222                frac,
223            )?);
224        }
225        builder.build()
226    }
227}
228
229/// Version of the serialized structure schema.
230pub const SCHEMA_VERSION: u32 = 1;
231
232/// Versioned JSON envelope for a structure.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct StructureDocument {
236    /// Schema version, independent of the crate version.
237    pub schema_version: u32,
238    /// The structure payload.
239    pub structure: Structure,
240}
241
242impl StructureDocument {
243    /// Wraps a structure in the current schema version.
244    #[must_use]
245    pub fn new(structure: Structure) -> Self {
246        Self {
247            schema_version: SCHEMA_VERSION,
248            structure,
249        }
250    }
251
252    /// Serializes the document to JSON.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`Error::InvalidInput`] if serialization fails.
257    pub fn to_json(&self) -> Result<String, Error> {
258        serde_json::to_string(self)
259            .map_err(|error| Error::InvalidInput(format!("could not serialize structure: {error}")))
260    }
261
262    /// Parses a document and checks its schema version.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`Error::InvalidInput`] for invalid JSON, unknown fields or an
267    /// unsupported schema version.
268    pub fn from_json(text: &str) -> Result<Self, Error> {
269        let document: Self = serde_json::from_str(text)
270            .map_err(|error| Error::InvalidInput(format!("invalid structure JSON: {error}")))?;
271        if document.schema_version != SCHEMA_VERSION {
272            return Err(Error::InvalidInput(format!(
273                "unsupported structure schema_version {}, expected {SCHEMA_VERSION}",
274                document.schema_version
275            )));
276        }
277        Ok(document)
278    }
279}
280
281impl Structure {
282    /// Wraps this structure in the versioned document envelope.
283    #[must_use]
284    pub fn to_document(&self) -> StructureDocument {
285        StructureDocument::new(self.clone())
286    }
287}
288
289/// Parses a structure JSON document and returns its summary.
290///
291/// Accepts a bare structure object or the versioned `StructureDocument`
292/// envelope that the CLI writes with `--json`. CIF text is read with the `io`
293/// crate, not here.
294///
295/// # Errors
296///
297/// Returns [`Error::InvalidInput`] for invalid JSON or a structure that cannot
298/// produce a summary.
299pub fn structure_info(text: &str) -> Result<StructureSummary, Error> {
300    let structure: Structure = if text.contains("\"schema_version\"") {
301        StructureDocument::from_json(text)?.structure
302    } else {
303        serde_json::from_str(text)
304            .map_err(|error| Error::InvalidInput(format!("invalid structure JSON: {error}")))?
305    };
306    structure.summary()
307}
308
309/// One structured warning attached to a structure.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct StructureWarning {
312    /// Stable snake_case code.
313    pub code: String,
314    /// Human-readable message.
315    pub message: String,
316    /// P1 atom indices involved, when known.
317    pub atoms: Vec<usize>,
318    /// Fields involved, when known.
319    pub fields: Vec<String>,
320    /// Suggested fix, when one exists.
321    pub hint: Option<String>,
322}
323
324/// Summary of a structure for the surfaces.
325#[derive(Debug, Clone, PartialEq, Serialize)]
326pub struct StructureSummary {
327    /// Structure name.
328    pub name: String,
329    /// Formula string.
330    pub formula: String,
331    /// Hall symbol.
332    pub hall: String,
333    /// Number of asymmetric sites.
334    pub n_sites: usize,
335    /// Cell volume in cubic Angstrom.
336    pub volume: f64,
337    /// Density in g/cm3.
338    pub density: f64,
339    /// Total charge.
340    pub total_charge: f64,
341    /// Whether the structure is P1.
342    pub is_p1: bool,
343    /// Structured reader and geometry warnings.
344    pub warnings: Vec<StructureWarning>,
345}
346
347/// Builder for a [`Structure`].
348#[derive(Debug, Clone)]
349pub struct StructureBuilder {
350    name: String,
351    lattice: Lattice,
352    hall: String,
353    sites: Vec<Site>,
354    operations: Vec<SymOp>,
355    explicit_bonds: Vec<ExplicitBond>,
356    topology_bonds: Vec<TopologyBond>,
357    metadata: BTreeMap<String, Value>,
358}
359
360impl StructureBuilder {
361    /// Starts a P1 structure.
362    #[must_use]
363    pub fn new(name: impl Into<String>, lattice: Lattice) -> Self {
364        Self {
365            name: name.into(),
366            lattice,
367            hall: "P 1".to_owned(),
368            sites: Vec::new(),
369            operations: Vec::new(),
370            explicit_bonds: Vec::new(),
371            topology_bonds: Vec::new(),
372            metadata: BTreeMap::new(),
373        }
374    }
375
376    /// Sets the Hall symbol.
377    #[must_use]
378    pub fn hall(mut self, hall: impl Into<String>) -> Self {
379        self.hall = hall.into();
380        self
381    }
382
383    /// Adds a site.
384    #[must_use]
385    pub fn site(mut self, site: Site) -> Self {
386        self.sites.push(site);
387        self
388    }
389
390    /// Adds an explicit symmetry operation.
391    #[must_use]
392    pub fn operation(mut self, operation: SymOp) -> Self {
393        self.operations.push(operation);
394        self
395    }
396
397    /// Adds an explicit bond or removal.
398    #[must_use]
399    pub fn explicit_bond(mut self, bond: ExplicitBond) -> Self {
400        self.explicit_bonds.push(bond);
401        self
402    }
403
404    /// Adds a topology edge.
405    #[must_use]
406    pub fn topology_bond(mut self, bond: TopologyBond) -> Self {
407        self.topology_bonds.push(bond);
408        self
409    }
410
411    /// Adds one metadata entry.
412    #[must_use]
413    pub fn metadata(mut self, key: impl Into<String>, value: Value) -> Self {
414        self.metadata.insert(key.into(), value);
415        self
416    }
417
418    /// Builds the structure.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`Error::InvalidInput`] when the Hall symbol is empty after
423    /// trimming.
424    pub fn build(self) -> Result<Structure, Error> {
425        if self.hall.trim().is_empty() {
426            return Err(Error::InvalidInput(
427                "space-group Hall symbol must not be empty; use P 1".to_owned(),
428            ));
429        }
430        Ok(Structure {
431            name: self.name,
432            lattice: self.lattice,
433            hall: self.hall,
434            sites: self.sites,
435            operations: self.operations,
436            explicit_bonds: self.explicit_bonds,
437            topology_bonds: self.topology_bonds,
438            metadata: self.metadata,
439        })
440    }
441}