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