Skip to main content

qsym2/bindings/python/
symmetry_group_detection.rs

1//! Python bindings for QSym² symmetry-group detection.
2//!
3//! See [`crate::drivers::symmetry_group_detection`] for more information.
4
5use std::collections::HashMap;
6use std::fmt;
7use std::path::PathBuf;
8
9use anyhow::{self, format_err};
10use nalgebra::{Point3, Vector3};
11use numpy::{PyArray1, ToPyArray};
12use pyo3::exceptions::PyRuntimeError;
13use pyo3::prelude::*;
14
15use crate::auxiliary::atom::{Atom, ElementMap};
16use crate::auxiliary::molecule::Molecule;
17use crate::drivers::QSym2Driver;
18use crate::drivers::symmetry_group_detection::{
19    SymmetryGroupDetectionDriver, SymmetryGroupDetectionParams,
20};
21#[allow(unused_imports)]
22use crate::io::QSym2FileType;
23use crate::symmetry::symmetry_core::Symmetry;
24use crate::symmetry::symmetry_element::{AntiunitaryKind, SymmetryElementKind};
25use crate::symmetry::symmetry_element_order::ElementOrder;
26
27// ===========================
28// Struct and enum definitions
29// ===========================
30
31// ----------
32// PyMolecule
33// ----------
34
35/// Python-exposed structure to marshall molecular structure information between Rust and Python.
36#[pyclass]
37#[derive(Clone)]
38pub struct PyMolecule {
39    /// The ordinary atoms in the molecule.
40    ///
41    /// Python type: `list[tuple[str, tuple[float, float, float]]]`
42    #[pyo3(get)]
43    pub atoms: Vec<(String, [f64; 3])>,
44
45    /// An optional uniform external magnetic field.
46    ///
47    /// Python type: `Optional[tuple[float, float, float]]`
48    #[pyo3(get)]
49    pub magnetic_field: Option<[f64; 3]>,
50
51    /// An optional uniform external electric field.
52    ///
53    /// Python type: `Optional[tuple[float, float, float]]`
54    #[pyo3(get)]
55    pub electric_field: Option<[f64; 3]>,
56
57    /// Threshold for comparing molecules.
58    ///
59    /// Python type: `float`
60    #[pyo3(get)]
61    pub threshold: f64,
62}
63
64#[pymethods]
65impl PyMolecule {
66    /// Creates a new `PyMolecule` structure.
67    ///
68    /// # Arguments
69    ///
70    /// * `atoms` - The ordinary atoms in the molecule.
71    /// * `threshold` - Threshold for comparing molecules.
72    /// * `magnetic_field` - An optional uniform external magnetic field.
73    /// * `electric_field` - An optional uniform external electric field.
74    #[new]
75    #[pyo3(signature = (atoms, threshold, magnetic_field=None, electric_field=None))]
76    pub fn new(
77        atoms: Vec<(String, [f64; 3])>,
78        threshold: f64,
79        magnetic_field: Option<[f64; 3]>,
80        electric_field: Option<[f64; 3]>,
81    ) -> Self {
82        Self {
83            atoms,
84            threshold,
85            magnetic_field,
86            electric_field,
87        }
88    }
89}
90
91impl From<PyMolecule> for Molecule {
92    fn from(pymol: PyMolecule) -> Self {
93        let emap = ElementMap::new();
94        let mut mol = Self::from_atoms(
95            &pymol
96                .atoms
97                .iter()
98                .map(|(ele, r)| {
99                    Atom::new_ordinary(ele, Point3::new(r[0], r[1], r[2]), &emap, pymol.threshold)
100                })
101                .collect::<Vec<_>>(),
102            pymol.threshold,
103        );
104        mol.set_magnetic_field(pymol.magnetic_field.map(Vector3::from_iterator));
105        mol.set_electric_field(pymol.electric_field.map(Vector3::from_iterator));
106        mol
107    }
108}
109
110// ---------------------
111// PySymmetryElementKind
112// ---------------------
113
114/// Python-exposed enumerated type to marshall symmetry element kind information one-way from Rust to
115/// Python.
116#[pyclass(eq, eq_int)]
117#[derive(Clone, Hash, PartialEq, Eq)]
118pub enum PySymmetryElementKind {
119    /// Variant denoting proper symmetry elements.
120    Proper,
121
122    /// Variant denoting time-reversed proper symmetry elements.
123    ProperTR,
124
125    /// Variant denoting improper symmetry elements (mirror-plane convention).
126    ImproperMirrorPlane,
127
128    /// Variant denoting time-reversed improper symmetry elements (mirror-plane convention).
129    ImproperMirrorPlaneTR,
130}
131
132impl fmt::Display for PySymmetryElementKind {
133    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
134        match self {
135            PySymmetryElementKind::Proper => write!(f, "Proper"),
136            PySymmetryElementKind::ProperTR => write!(f, "Time-reversed proper"),
137            PySymmetryElementKind::ImproperMirrorPlane => {
138                write!(f, "Improper (mirror-plane convention)")
139            }
140            PySymmetryElementKind::ImproperMirrorPlaneTR => {
141                write!(f, "Time-reversed improper (mirror-plane convention)")
142            }
143        }
144    }
145}
146
147impl TryFrom<&SymmetryElementKind> for PySymmetryElementKind {
148    type Error = anyhow::Error;
149
150    fn try_from(symkind: &SymmetryElementKind) -> Result<Self, Self::Error> {
151        match symkind {
152            SymmetryElementKind::Proper(None) => Ok(Self::Proper),
153            SymmetryElementKind::Proper(Some(AntiunitaryKind::TimeReversal)) => Ok(Self::ProperTR),
154            SymmetryElementKind::ImproperMirrorPlane(None) => Ok(Self::ImproperMirrorPlane),
155            SymmetryElementKind::ImproperMirrorPlane(Some(AntiunitaryKind::TimeReversal)) => {
156                Ok(Self::ImproperMirrorPlaneTR)
157            }
158            _ => Err(format_err!(
159                "Symmetry element kind `{symkind}` is not yet supported in Python."
160            )),
161        }
162    }
163}
164
165// ----------
166// PySymmetry
167// ----------
168
169/// Python-exposed structure to marshall symmetry information one-way from Rust to Python.
170#[pyclass]
171#[derive(Clone)]
172pub struct PySymmetry {
173    /// The name of the symmetry group.
174    #[pyo3(get)]
175    group_name: String,
176
177    /// The symmetry elements.
178    ///
179    /// Python type: `dict[PySymmetryElementKind, dict[int, list[numpy.1darray[float]]]]`
180    #[allow(clippy::type_complexity)]
181    elements: HashMap<PySymmetryElementKind, HashMap<i32, Vec<Py<PyArray1<f64>>>>>,
182
183    /// The symmetry generators.
184    ///
185    /// Python type: `dict[PySymmetryElementKind, dict[int, list[numpy.1darray[float]]]]`
186    #[allow(clippy::type_complexity)]
187    generators: HashMap<PySymmetryElementKind, HashMap<i32, Vec<Py<PyArray1<f64>>>>>,
188}
189
190#[pymethods]
191impl PySymmetry {
192    /// Returns a boolean indicating if the group is infinite.
193    pub fn is_infinite(&self) -> bool {
194        self.elements
195            .values()
196            .any(|kind_elements| kind_elements.contains_key(&-1))
197            || self
198                .generators
199                .values()
200                .any(|kind_generators| kind_generators.contains_key(&-1))
201    }
202
203    /// Returns symmetry elements of all *finite* and *infinite* orders of a given kind.
204    ///
205    /// # Arguments
206    ///
207    /// * `kind` - The symmetry element kind.
208    ///
209    /// # Returns
210    ///
211    /// A hashmap where the keys are integers indicating the orders of the elements and the values
212    /// are vectors of one-dimensional arrays, each of which gives the axis of a symmetry element.
213    /// If the order value is `-1`, then the associated elements have infinite order.
214    pub fn get_elements_of_kind(
215        &self,
216        py: Python<'_>,
217        kind: &PySymmetryElementKind,
218    ) -> PyResult<HashMap<i32, Vec<Py<PyArray1<f64>>>>> {
219        self.elements
220            .get(kind)
221            .map(|elements| {
222                elements
223                    .iter()
224                    .map(|(order, axes)| {
225                        (*order, axes.iter().map(|axis| axis.clone_ref(py)).collect())
226                    })
227                    .collect()
228            })
229            .ok_or_else(|| PyRuntimeError::new_err(format!("Elements of kind `{kind}` not found.")))
230    }
231
232    /// Returns symmetry generators of *finite* and *infinite* orders of a given kind.
233    ///
234    /// # Arguments
235    ///
236    /// * `kind` - The symmetry generator kind.
237    ///
238    /// # Returns
239    ///
240    /// A hashmap where the keys are integers indicating the orders of the generators and the values
241    /// are vectors of one-dimensional arrays, each of which gives the axis of a symmetry generator.
242    /// If the order value is `-1`, then the associated generators have infinite order.
243    pub fn get_generators_of_kind(
244        &self,
245        py: Python<'_>,
246        kind: &PySymmetryElementKind,
247    ) -> PyResult<HashMap<i32, Vec<Py<PyArray1<f64>>>>> {
248        self.generators
249            .get(kind)
250            .map(|generators| {
251                generators
252                    .iter()
253                    .map(|(order, axes)| {
254                        (*order, axes.iter().map(|axis| axis.clone_ref(py)).collect())
255                    })
256                    .collect()
257            })
258            .ok_or_else(|| PyRuntimeError::new_err(format!("Elements of kind `{kind}` not found.")))
259    }
260}
261
262impl TryFrom<&Symmetry> for PySymmetry {
263    type Error = anyhow::Error;
264
265    fn try_from(sym: &Symmetry) -> Result<Self, Self::Error> {
266        let group_name = sym
267            .group_name
268            .clone()
269            .ok_or(format_err!("Symmetry group name not found."))?;
270        let elements = Python::attach(|py| {
271            sym.elements
272                .iter()
273                .map(|(symkind, kind_elements)| {
274                    let pysymkind = PySymmetryElementKind::try_from(symkind)?;
275                    let pykind_elements = kind_elements
276                        .iter()
277                        .map(|(order, order_elements)| {
278                            let order_i32 = match order {
279                                ElementOrder::Int(ord) => i32::try_from(*ord)?,
280                                ElementOrder::Inf => -1,
281                            };
282                            let pyorder_elements = order_elements
283                                .iter()
284                                .map(|ele| {
285                                    ele.raw_axis()
286                                        .iter()
287                                        .cloned()
288                                        .collect::<Vec<_>>()
289                                        .to_pyarray(py)
290                                        .unbind()
291                                })
292                                .collect::<Vec<_>>();
293                            Ok::<_, Self::Error>((order_i32, pyorder_elements))
294                        })
295                        .collect::<Result<HashMap<i32, Vec<_>>, _>>()?;
296                    Ok::<_, Self::Error>((pysymkind, pykind_elements))
297                })
298                .collect::<Result<HashMap<_, _>, _>>()
299        })?;
300
301        let generators = Python::attach(|py| {
302            sym.generators
303                .iter()
304                .map(|(symkind, kind_generators)| {
305                    let pysymkind = PySymmetryElementKind::try_from(symkind)?;
306                    let pykind_generators = kind_generators
307                        .iter()
308                        .map(|(order, order_generators)| {
309                            let order_i32 = match order {
310                                ElementOrder::Int(ord) => i32::try_from(*ord)?,
311                                ElementOrder::Inf => -1,
312                            };
313                            let pyorder_generators = order_generators
314                                .iter()
315                                .map(|ele| {
316                                    ele.raw_axis()
317                                        .iter()
318                                        .cloned()
319                                        .collect::<Vec<_>>()
320                                        .to_pyarray(py)
321                                        .unbind()
322                                })
323                                .collect::<Vec<_>>();
324                            Ok::<_, Self::Error>((order_i32, pyorder_generators))
325                        })
326                        .collect::<Result<HashMap<i32, Vec<_>>, _>>()?;
327                    Ok::<_, Self::Error>((pysymkind, pykind_generators))
328                })
329                .collect::<Result<HashMap<_, _>, _>>()
330        })?;
331
332        Ok(PySymmetry {
333            group_name,
334            elements,
335            generators,
336        })
337    }
338}
339
340// =========
341// Functions
342// =========
343
344/// Python-exposed function to perform symmetry-group detection and log the result via the
345/// `qsym2-output` logger at the `INFO` level.
346///
347/// See [`crate::drivers::symmetry_group_detection`] for more information.
348///
349/// # Arguments
350///
351/// * `inp_xyz` - An optional string providing the path to an XYZ file containing the molecule to
352/// be analysed. Only one of `inp_xyz` or `inp_mol` can be specified.
353/// * `inp_mol` - An optional `PyMolecule` structure containing the molecule to be analysed. Only
354/// one of `inp_xyz` or `inp_mol` can be specified.
355/// * `out_sym` - An optional name for the [`QSym2FileType::Sym`] file to be saved that contains
356/// the serialised results of the symmetry-group detection.
357/// * `moi_thresholds` - Thresholds for comparing moments of inertia.
358/// * `distance_thresholds` - Thresholds for comparing distances.
359/// * `time_reversal` - A boolean indicating whether elements involving time reversal should also
360/// be considered.
361/// * `write_symmetry_elements` - A boolean indicating if detected symmetry elements should be
362/// printed in the output.
363/// * `fictitious_magnetic_field` - An optional fictitious uniform external magnetic field.
364/// * `fictitious_electric_field` - An optional fictitious uniform external electric field.
365///
366/// # Returns
367///
368/// Returns a tuple of a [`PySymmetry`] for the unitary group and another optional [`PySymmetry`]
369/// for the magnetic group if requested.
370///
371/// # Errors
372///
373/// Returns an error if any intermediate step in the symmetry-group detection procedure fails.
374#[allow(clippy::too_many_arguments)]
375#[pyfunction]
376#[pyo3(signature = (
377    inp_xyz,
378    inp_mol,
379    out_sym,
380    moi_thresholds,
381    distance_thresholds,
382    time_reversal,
383    write_symmetry_elements=true,
384    fictitious_magnetic_field=None,
385    fictitious_electric_field=None,
386))]
387pub fn detect_symmetry_group(
388    py: Python<'_>,
389    inp_xyz: Option<PathBuf>,
390    inp_mol: Option<PyMolecule>,
391    out_sym: Option<PathBuf>,
392    moi_thresholds: Vec<f64>,
393    distance_thresholds: Vec<f64>,
394    time_reversal: bool,
395    write_symmetry_elements: bool,
396    fictitious_magnetic_field: Option<[f64; 3]>,
397    fictitious_electric_field: Option<[f64; 3]>,
398) -> PyResult<(PySymmetry, Option<PySymmetry>)> {
399    let (unitary_symmetry, magnetic_symmetry_opt) = py.detach(|| {
400        let params = SymmetryGroupDetectionParams::builder()
401            .distance_thresholds(&distance_thresholds)
402            .moi_thresholds(&moi_thresholds)
403            .time_reversal(time_reversal)
404            .fictitious_magnetic_fields(
405                fictitious_magnetic_field
406                    .map(|bs| vec![(Point3::<f64>::origin(), Vector3::new(bs[0], bs[1], bs[2]))]),
407            )
408            .fictitious_electric_fields(
409                fictitious_electric_field
410                    .map(|es| vec![(Point3::<f64>::origin(), Vector3::new(es[0], es[1], es[2]))]),
411            )
412            .field_origin_com(true)
413            .write_symmetry_elements(write_symmetry_elements)
414            .result_save_name(out_sym)
415            .build()
416            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
417        let inp_mol = inp_mol.map(Molecule::from);
418        let mut pd_driver = SymmetryGroupDetectionDriver::builder()
419            .parameters(&params)
420            .xyz(inp_xyz)
421            .molecule(inp_mol.as_ref())
422            .build()
423            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
424        pd_driver
425            .run()
426            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
427        let result = pd_driver
428            .result()
429            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
430        Ok::<_, PyErr>((
431            result.unitary_symmetry.clone(),
432            result.magnetic_symmetry.clone(),
433        ))
434    })?;
435    let pyunitary_symmetry: PySymmetry = (&unitary_symmetry)
436        .try_into()
437        .map_err(|err: anyhow::Error| PyRuntimeError::new_err(err.to_string()))?;
438    let pymagnetic_symmetry: Option<PySymmetry> = magnetic_symmetry_opt
439        .map(|ref magsym| {
440            magsym
441                .try_into()
442                .map_err(|err: anyhow::Error| PyRuntimeError::new_err(err.to_string()))
443        })
444        .transpose()?;
445    Ok((pyunitary_symmetry, pymagnetic_symmetry))
446}