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    #[allow(clippy::type_complexity)]
183    elements: HashMap<PySymmetryElementKind, HashMap<i32, Vec<Arc<Py<PyArray1<f64>>>>>>,
184
185    /// The symmetry generators.
186    ///
187    /// Python type: `dict[PySymmetryElementKind, dict[int, list[numpy.1darray[float]]]]`
188    #[allow(clippy::type_complexity)]
189    generators: HashMap<PySymmetryElementKind, HashMap<i32, Vec<Arc<Py<PyArray1<f64>>>>>>,
190}
191
192impl PySymmetry {
193    fn builder() -> PySymmetryBuilder {
194        PySymmetryBuilder::default()
195    }
196}
197
198#[pymethods]
199impl PySymmetry {
200    /// Returns a boolean indicating if the group is infinite.
201    pub fn is_infinite(&self) -> bool {
202        self.elements
203            .values()
204            .any(|kind_elements| kind_elements.contains_key(&-1))
205            || self
206                .generators
207                .values()
208                .any(|kind_generators| kind_generators.contains_key(&-1))
209    }
210
211    /// Returns symmetry elements of all *finite* orders of a given kind.
212    ///
213    /// # Arguments
214    ///
215    /// * `kind` - The symmetry element kind.
216    ///
217    /// # Returns
218    ///
219    /// A hashmap where the keys are integers indicating the orders of the elements and the values
220    /// are vectors of one-dimensional arrays, each of which gives the axis of a symmetry element.
221    /// If the order value is `-1`, then the associated elements have infinite order.
222    pub fn get_elements_of_kind(
223        &self,
224        kind: &PySymmetryElementKind,
225    ) -> PyResult<HashMap<i32, Vec<Py<PyArray1<f64>>>>> {
226        self.elements
227            .get(kind)
228            .cloned()
229            .and_then(|elements| {
230                elements
231                    .iter()
232                    .map(|(order, axes_arc)| {
233                        let axes_opt = axes_arc
234                            .iter()
235                            .map(|axis_arc| Arc::into_inner(axis_arc.clone()))
236                            .collect::<Option<Vec<_>>>();
237                        axes_opt.map(|axes| (*order, axes))
238                    })
239                    .collect::<Option<HashMap<_, _>>>()
240            })
241            .ok_or(PyRuntimeError::new_err(format!(
242                "Elements of kind `{kind}` not found."
243            )))
244    }
245
246    /// Returns symmetry generators of *finite*  and *infinite* orders of a given kind.
247    ///
248    /// # Arguments
249    ///
250    /// * `kind` - The symmetry generator kind.
251    ///
252    /// # Returns
253    ///
254    /// A hashmap where the keys are integers indicating the orders of the generators and the values
255    /// are vectors of one-dimensional arrays, each of which gives the axis of a symmetry generator.
256    /// If the order value is `-1`, then the associated generators have infinite order.
257    pub fn get_generators_of_kind(
258        &self,
259        kind: &PySymmetryElementKind,
260    ) -> PyResult<HashMap<i32, Vec<Py<PyArray1<f64>>>>> {
261        self.generators
262            .get(kind)
263            .cloned()
264            .and_then(|generators| {
265                generators
266                    .iter()
267                    .map(|(order, axes_arc)| {
268                        let axes_opt = axes_arc
269                            .iter()
270                            .map(|axis_arc| Arc::into_inner(axis_arc.clone()))
271                            .collect::<Option<Vec<_>>>();
272                        axes_opt.map(|axes| (*order, axes))
273                    })
274                    .collect::<Option<HashMap<_, _>>>()
275            })
276            .ok_or(PyRuntimeError::new_err(format!(
277                "Elements of kind `{kind}` not found."
278            )))
279    }
280}
281
282impl TryFrom<&Symmetry> for PySymmetry {
283    type Error = anyhow::Error;
284
285    fn try_from(sym: &Symmetry) -> Result<Self, Self::Error> {
286        let group_name = sym
287            .group_name
288            .clone()
289            .ok_or(format_err!("Symmetry group name not found."))?;
290        let elements = sym
291            .elements
292            .iter()
293            .map(|(symkind, kind_elements)| {
294                let pysymkind = PySymmetryElementKind::try_from(symkind)?;
295                let pykind_elements = kind_elements
296                    .iter()
297                    .map(|(order, order_elements)| {
298                        let order_i32 = match order {
299                            ElementOrder::Int(ord) => i32::try_from(*ord)?,
300                            ElementOrder::Inf => -1,
301                        };
302                        let pyorder_elements = order_elements
303                            .iter()
304                            .map(|ele| {
305                                Arc::new(Python::attach(|py| {
306                                    ele.raw_axis()
307                                        .iter()
308                                        .cloned()
309                                        .collect::<Vec<_>>()
310                                        .to_pyarray(py)
311                                        .unbind()
312                                }))
313                            })
314                            .collect::<Vec<_>>();
315                        Ok::<_, Self::Error>((order_i32, pyorder_elements))
316                    })
317                    .collect::<Result<HashMap<i32, Vec<_>>, _>>()?;
318                Ok::<_, Self::Error>((pysymkind, pykind_elements))
319            })
320            .collect::<Result<HashMap<_, _>, _>>()?;
321
322        let generators = sym
323            .generators
324            .iter()
325            .map(|(symkind, kind_generators)| {
326                let pysymkind = PySymmetryElementKind::try_from(symkind)?;
327                let pykind_generators = kind_generators
328                    .iter()
329                    .map(|(order, order_generators)| {
330                        let order_i32 = match order {
331                            ElementOrder::Int(ord) => i32::try_from(*ord)?,
332                            ElementOrder::Inf => -1,
333                        };
334                        let pyorder_generators = order_generators
335                            .iter()
336                            .map(|ele| {
337                                Arc::new(Python::attach(|py| {
338                                    ele.raw_axis()
339                                        .iter()
340                                        .cloned()
341                                        .collect::<Vec<_>>()
342                                        .to_pyarray(py)
343                                        .unbind()
344                                }))
345                            })
346                            .collect::<Vec<_>>();
347                        Ok::<_, Self::Error>((order_i32, pyorder_generators))
348                    })
349                    .collect::<Result<HashMap<i32, Vec<_>>, _>>()?;
350                Ok::<_, Self::Error>((pysymkind, pykind_generators))
351            })
352            .collect::<Result<HashMap<_, _>, _>>()?;
353
354        PySymmetry::builder()
355            .group_name(group_name)
356            .elements(elements)
357            .generators(generators)
358            .build()
359            .map_err(|err| format_err!(err))
360    }
361}
362
363// =========
364// Functions
365// =========
366
367/// Python-exposed function to perform symmetry-group detection and log the result via the
368/// `qsym2-output` logger at the `INFO` level.
369///
370/// See [`crate::drivers::symmetry_group_detection`] for more information.
371///
372/// # Arguments
373///
374/// * `inp_xyz` - An optional string providing the path to an XYZ file containing the molecule to
375/// be analysed. Only one of `inp_xyz` or `inp_mol` can be specified.
376/// * `inp_mol` - An optional `PyMolecule` structure containing the molecule to be analysed. Only
377/// one of `inp_xyz` or `inp_mol` can be specified.
378/// * `out_sym` - An optional name for the [`QSym2FileType::Sym`] file to be saved that contains
379/// the serialised results of the symmetry-group detection.
380/// * `moi_thresholds` - Thresholds for comparing moments of inertia.
381/// * `distance_thresholds` - Thresholds for comparing distances.
382/// * `time_reversal` - A boolean indicating whether elements involving time reversal should also
383/// be considered.
384/// * `write_symmetry_elements` - A boolean indicating if detected symmetry elements should be
385/// printed in the output.
386/// * `fictitious_magnetic_field` - An optional fictitious uniform external magnetic field.
387/// * `fictitious_electric_field` - An optional fictitious uniform external electric field.
388///
389/// # Returns
390///
391/// Returns a tuple of a [`PySymmetry`] for the unitary group and another optional [`PySymmetry`]
392/// for the magnetic group if requested.
393///
394/// # Errors
395///
396/// Returns an error if any intermediate step in the symmetry-group detection procedure fails.
397#[allow(clippy::too_many_arguments)]
398#[pyfunction]
399#[pyo3(signature = (
400    inp_xyz,
401    inp_mol,
402    out_sym,
403    moi_thresholds,
404    distance_thresholds,
405    time_reversal,
406    write_symmetry_elements=true,
407    fictitious_magnetic_field=None,
408    fictitious_electric_field=None,
409))]
410pub fn detect_symmetry_group(
411    py: Python<'_>,
412    inp_xyz: Option<PathBuf>,
413    inp_mol: Option<PyMolecule>,
414    out_sym: Option<PathBuf>,
415    moi_thresholds: Vec<f64>,
416    distance_thresholds: Vec<f64>,
417    time_reversal: bool,
418    write_symmetry_elements: bool,
419    fictitious_magnetic_field: Option<[f64; 3]>,
420    fictitious_electric_field: Option<[f64; 3]>,
421) -> PyResult<(PySymmetry, Option<PySymmetry>)> {
422    py.detach(|| {
423        let params = SymmetryGroupDetectionParams::builder()
424            .distance_thresholds(&distance_thresholds)
425            .moi_thresholds(&moi_thresholds)
426            .time_reversal(time_reversal)
427            .fictitious_magnetic_fields(
428                fictitious_magnetic_field
429                    .map(|bs| vec![(Point3::<f64>::origin(), Vector3::new(bs[0], bs[1], bs[2]))]),
430            )
431            .fictitious_electric_fields(
432                fictitious_electric_field
433                    .map(|es| vec![(Point3::<f64>::origin(), Vector3::new(es[0], es[1], es[2]))]),
434            )
435            .field_origin_com(true)
436            .write_symmetry_elements(write_symmetry_elements)
437            .result_save_name(out_sym)
438            .build()
439            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
440        let inp_mol = inp_mol.map(Molecule::from);
441        let mut pd_driver = SymmetryGroupDetectionDriver::builder()
442            .parameters(&params)
443            .xyz(inp_xyz)
444            .molecule(inp_mol.as_ref())
445            .build()
446            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
447        pd_driver
448            .run()
449            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
450        let pyunitary_symmetry: PySymmetry = (&pd_driver
451            .result()
452            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?
453            .unitary_symmetry)
454            .try_into()
455            .map_err(|err: anyhow::Error| PyRuntimeError::new_err(err.to_string()))?;
456        let pymagnetic_symmetry: Option<PySymmetry> = pd_driver
457            .result()
458            .map_err(|err| PyRuntimeError::new_err(err.to_string()))?
459            .magnetic_symmetry
460            .as_ref()
461            .map(|magsym| {
462                magsym
463                    .try_into()
464                    .map_err(|err: anyhow::Error| PyRuntimeError::new_err(err.to_string()))
465            })
466            .transpose()?;
467        Ok((pyunitary_symmetry, pymagnetic_symmetry))
468    })
469}