Skip to main content

molframe_cif/small_cif/
model.rs

1//! Core-CIF small-molecule crystal structures.
2
3use crate::{CifValue, DataBlock, Document};
4use molframe_core::structure::UnitCell;
5use std::collections::BTreeMap;
6
7/// One crystallographic atom site from core CIF.
8#[derive(Clone, Debug, PartialEq)]
9pub struct SmallCifAtom {
10    /// Unique atom-site label.
11    pub label: Box<str>,
12    /// Chemical type symbol as deposited.
13    pub type_symbol: Box<str>,
14    /// Fractional coordinates, when recorded.
15    pub fractional: Option<[f64; 3]>,
16    /// Cartesian coordinates in ångströms, when recorded.
17    pub cartesian: Option<[f64; 3]>,
18    /// Site occupancy.
19    pub occupancy: Option<f64>,
20    /// Isotropic displacement parameter U.
21    pub u_iso: Option<f64>,
22    /// Formal oxidation/charge text when present.
23    pub charge: Option<Box<str>>,
24}
25
26/// One geometric bond record with resolved atom indices.
27#[derive(Clone, Debug, PartialEq)]
28pub struct SmallCifBond {
29    /// First atom index.
30    pub first: usize,
31    /// Second atom index.
32    pub second: usize,
33    /// Reported bond distance in ångströms.
34    pub distance: Option<f64>,
35    /// Site-symmetry code applied to the second site.
36    pub site_symmetry: Option<Box<str>>,
37    /// Published bond/type marker.
38    pub bond_type: Option<Box<str>>,
39}
40
41/// One core-CIF crystal model.
42#[derive(Clone, Debug, PartialEq)]
43pub struct SmallCifStructure {
44    /// Data-block identifier.
45    pub name: Box<str>,
46    /// Unit cell.
47    pub cell: Option<UnitCell>,
48    /// Hermann-Mauguin space-group name.
49    pub space_group_name: Option<Box<str>>,
50    /// International Tables space-group number.
51    pub space_group_number: Option<i32>,
52    /// Explicit symmetry operations in source order.
53    pub symmetry_operations: Vec<Box<str>>,
54    /// Atom sites in source order.
55    pub atoms: Vec<SmallCifAtom>,
56    /// Resolved geometric bonds.
57    pub bonds: Vec<SmallCifBond>,
58}
59
60/// Invalid core-CIF small-molecule model.
61#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum SmallCifError {
64    /// No data block or atom-site list is present.
65    #[error("small-molecule CIF has no atom-site table")]
66    MissingAtoms,
67    /// An atom label is absent or duplicated.
68    #[error("small-molecule CIF atom labels are absent or duplicated")]
69    AtomLabels,
70    /// Coordinate columns are incomplete or malformed.
71    #[error("small-molecule CIF coordinates are incomplete or malformed")]
72    Coordinates,
73    /// Cell metadata is incomplete or invalid.
74    #[error("small-molecule CIF unit cell is incomplete or invalid")]
75    Cell,
76    /// A bond references an absent atom label.
77    #[error("small-molecule CIF bond references an absent atom")]
78    BondAtom,
79    /// A required deposited chemical type symbol is absent.
80    #[error("small-molecule CIF atom type symbol is absent")]
81    AtomTypeSymbol,
82}
83
84/// Dictionary dialect used to resolve core-CIF item names.
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86pub enum SmallCifDialect {
87    /// Current DDL2 category/item names.
88    #[default]
89    Ddl2,
90    /// Legacy DDL1 underscore tags, enabled only by explicit request.
91    Ddl1,
92}
93
94/// Explicit decisions for lowering a small-molecule CIF.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub struct SmallCifOptions {
97    dialect: SmallCifDialect,
98}
99
100impl SmallCifOptions {
101    /// Uses current DDL2 category/item names.
102    #[must_use]
103    pub const fn ddl2() -> Self {
104        Self {
105            dialect: SmallCifDialect::Ddl2,
106        }
107    }
108
109    /// Uses legacy DDL1 underscore tags explicitly.
110    #[must_use]
111    pub const fn ddl1() -> Self {
112        Self {
113            dialect: SmallCifDialect::Ddl1,
114        }
115    }
116}
117
118/// Lowers the first core-CIF data block to a small-molecule crystal model.
119/// Current DDL2 category/item names are used. Legacy DDL1 tags require
120/// [`lower_small_cif_with_options`] and [`SmallCifOptions::ddl1`].
121///
122/// # Errors
123///
124/// Returns an error for missing/duplicate atom labels, incomplete coordinates
125/// or cell data, or unresolved geometric bonds.
126pub fn lower_small_cif(document: &Document) -> Result<SmallCifStructure, SmallCifError> {
127    lower_small_cif_with_options(document, SmallCifOptions::ddl2())
128}
129
130/// Lowers the first core-CIF block under an explicit dictionary dialect.
131///
132/// # Errors
133///
134/// Returns an error for missing chemistry, identifiers, coordinates, cell data,
135/// or bond endpoints under the selected dictionary dialect.
136pub fn lower_small_cif_with_options(
137    document: &Document,
138    options: SmallCifOptions,
139) -> Result<SmallCifStructure, SmallCifError> {
140    let block = document.first_block().ok_or(SmallCifError::MissingAtoms)?;
141    let source = Source {
142        block,
143        dialect: options.dialect,
144    };
145    let labels = source
146        .column("atom_site", "label", "atom_site_label")
147        .ok_or(SmallCifError::MissingAtoms)?;
148    if labels.is_empty() {
149        return Err(SmallCifError::MissingAtoms);
150    }
151    let mut atoms = Vec::with_capacity(labels.len());
152    let mut label_indices = BTreeMap::new();
153    for row in 0..labels.len() {
154        let label = labels
155            .get(row)
156            .and_then(CifValue::as_identifier)
157            .ok_or(SmallCifError::AtomLabels)?
158            .into_owned();
159        if label_indices.insert(label.clone(), row).is_some() {
160            return Err(SmallCifError::AtomLabels);
161        }
162        let type_symbol = source
163            .identifier("atom_site", "type_symbol", "atom_site_type_symbol", row)
164            .ok_or(SmallCifError::AtomTypeSymbol)?
165            .into();
166        let fractional = coordinate(&source, "fract", row)?;
167        let cartesian = coordinate(&source, "Cartn", row)?;
168        if fractional.is_none() && cartesian.is_none() {
169            return Err(SmallCifError::Coordinates);
170        }
171        atoms.push(SmallCifAtom {
172            label: label.into(),
173            type_symbol,
174            fractional,
175            cartesian,
176            occupancy: source.number("atom_site", "occupancy", "atom_site_occupancy", row),
177            u_iso: source.number(
178                "atom_site",
179                "U_iso_or_equiv",
180                "atom_site_U_iso_or_equiv",
181                row,
182            ),
183            charge: source
184                .identifier("atom_site", "charge", "atom_site_charge", row)
185                .map(Into::into),
186        });
187    }
188    let cell = lower_cell(&source)?;
189    let bonds = lower_bonds(&source, &label_indices)?;
190    Ok(SmallCifStructure {
191        name: block.name().into(),
192        cell,
193        space_group_name: match options.dialect {
194            SmallCifDialect::Ddl2 => {
195                source.identifier("space_group", "name_H-M_alt", "space_group_name_H-M_alt", 0)
196            }
197            SmallCifDialect::Ddl1 => source.identifier(
198                "symmetry",
199                "space_group_name_H-M",
200                "symmetry_space_group_name_H-M",
201                0,
202            ),
203        }
204        .map(Into::into),
205        space_group_number: match options.dialect {
206            SmallCifDialect::Ddl2 => {
207                source.integer("space_group", "IT_number", "space_group_IT_number", 0)
208            }
209            SmallCifDialect::Ddl1 => source.integer(
210                "symmetry",
211                "Int_Tables_number",
212                "symmetry_Int_Tables_number",
213                0,
214            ),
215        },
216        symmetry_operations: symmetry_operations(&source),
217        atoms,
218        bonds,
219    })
220}
221
222fn coordinate(
223    source: &Source<'_>,
224    namespace: &str,
225    row: usize,
226) -> Result<Option<[f64; 3]>, SmallCifError> {
227    let values = ["x", "y", "z"].map(|axis| {
228        let item = format!("{namespace}_{axis}");
229        let tag = format!("atom_site_{namespace}_{axis}");
230        source.number("atom_site", &item, &tag, row)
231    });
232    if values.iter().all(Option::is_none) {
233        return Ok(None);
234    }
235    let [Some(x), Some(y), Some(z)] = values else {
236        return Err(SmallCifError::Coordinates);
237    };
238    if [x, y, z].iter().any(|value| !value.is_finite()) {
239        return Err(SmallCifError::Coordinates);
240    }
241    Ok(Some([x, y, z]))
242}
243
244fn lower_cell(source: &Source<'_>) -> Result<Option<UnitCell>, SmallCifError> {
245    let values = [
246        ("length_a", "cell_length_a"),
247        ("length_b", "cell_length_b"),
248        ("length_c", "cell_length_c"),
249        ("angle_alpha", "cell_angle_alpha"),
250        ("angle_beta", "cell_angle_beta"),
251        ("angle_gamma", "cell_angle_gamma"),
252    ]
253    .map(|(item, tag)| source.number("cell", item, tag, 0));
254    if values.iter().all(Option::is_none) {
255        return Ok(None);
256    }
257    let [
258        Some(a),
259        Some(b),
260        Some(c),
261        Some(alpha),
262        Some(beta),
263        Some(gamma),
264    ] = values
265    else {
266        return Err(SmallCifError::Cell);
267    };
268    if [a, b, c]
269        .iter()
270        .any(|value| !value.is_finite() || *value <= 0.0)
271        || [alpha, beta, gamma]
272            .iter()
273            .any(|value| !value.is_finite() || !(0.0..180.0).contains(value))
274    {
275        return Err(SmallCifError::Cell);
276    }
277    Ok(Some(UnitCell {
278        lengths: [a, b, c],
279        angles: [alpha, beta, gamma],
280    }))
281}
282
283fn lower_bonds(
284    source: &Source<'_>,
285    indices: &BTreeMap<String, usize>,
286) -> Result<Vec<SmallCifBond>, SmallCifError> {
287    let Some(first_column) = source.column(
288        "geom_bond",
289        "atom_site_label_1",
290        "geom_bond_atom_site_label_1",
291    ) else {
292        return Ok(Vec::new());
293    };
294    let mut bonds = Vec::with_capacity(first_column.len());
295    for row in 0..first_column.len() {
296        let first_label = first_column
297            .get(row)
298            .and_then(CifValue::as_identifier)
299            .ok_or(SmallCifError::BondAtom)?;
300        let second_label = source
301            .identifier(
302                "geom_bond",
303                "atom_site_label_2",
304                "geom_bond_atom_site_label_2",
305                row,
306            )
307            .ok_or(SmallCifError::BondAtom)?;
308        bonds.push(SmallCifBond {
309            first: *indices
310                .get(first_label.as_ref())
311                .ok_or(SmallCifError::BondAtom)?,
312            second: *indices
313                .get(second_label.as_ref())
314                .ok_or(SmallCifError::BondAtom)?,
315            distance: source.number("geom_bond", "distance", "geom_bond_distance", row),
316            site_symmetry: source
317                .identifier(
318                    "geom_bond",
319                    "site_symmetry_2",
320                    "geom_bond_site_symmetry_2",
321                    row,
322                )
323                .map(Into::into),
324            bond_type: source
325                .identifier("geom_bond", "type", "geom_bond_type", row)
326                .map(Into::into),
327        });
328    }
329    Ok(bonds)
330}
331
332fn symmetry_operations(source: &Source<'_>) -> Vec<Box<str>> {
333    let column = match source.dialect {
334        SmallCifDialect::Ddl2 => source.column(
335            "space_group_symop",
336            "operation_xyz",
337            "space_group_symop_operation_xyz",
338        ),
339        SmallCifDialect::Ddl1 => {
340            source.column("symmetry_equiv", "pos_as_xyz", "symmetry_equiv_pos_as_xyz")
341        }
342    };
343    column
344        .into_iter()
345        .flat_map(crate::Column::iter)
346        .filter_map(CifValue::as_str)
347        .map(Into::into)
348        .collect()
349}
350struct Source<'a> {
351    block: &'a DataBlock,
352    dialect: SmallCifDialect,
353}
354
355impl<'a> Source<'a> {
356    fn column(&self, category: &str, item: &str, ddl1_tag: &str) -> Option<&'a crate::Column> {
357        match self.dialect {
358            SmallCifDialect::Ddl2 => self.block.category(category)?.column(item),
359            SmallCifDialect::Ddl1 => self.block.category(ddl1_tag)?.column(ddl1_tag),
360        }
361    }
362
363    fn number(&self, category: &str, item: &str, ddl1_tag: &str, row: usize) -> Option<f64> {
364        self.column(category, item, ddl1_tag)?.get(row)?.as_float()
365    }
366
367    fn integer(&self, category: &str, item: &str, ddl1_tag: &str, row: usize) -> Option<i32> {
368        i32::try_from(
369            self.column(category, item, ddl1_tag)?
370                .get(row)?
371                .as_integer()?,
372        )
373        .ok()
374    }
375
376    fn identifier(
377        &self,
378        category: &str,
379        item: &str,
380        ddl1_tag: &str,
381        row: usize,
382    ) -> Option<std::borrow::Cow<'a, str>> {
383        self.column(category, item, ddl1_tag)?
384            .get(row)?
385            .as_identifier()
386    }
387}
388
389#[cfg(test)]
390#[path = "model_tests.rs"]
391mod tests;