qsym2/drivers/molecule_symmetrisation_bootstrap/
mod.rs

1//! Driver for molecule symmetrisation by bootstrapping in QSym².
2//!
3//! This algorithm symmetrises a molecule iteratively by defining two threshold levels: a `loose`
4//! level and a `target` level.
5//!
6//! In every iteration, the following steps are performed:
7//!
8//! 1. The molecule is symmetry-analysed at the `target` level; any symmetry elements found are stashed and the symmetry group name, if any, is registered.
9//! 2. The molecule is symmetry-analysed at the `loose` level; any symmetry elements found are added to the stash and the symmetry group name, if any, is registered.
10//! 3. The convergence criteria (see below) are checked.
11//!     - If convergence has been reached, the symmetrisation procedure is terminated.
12//!     - If convergence has not been reached, the following steps are carried out.
13//! 4. All symmetry elements found in the stash are used to generate all possible symmetry operations which are then used to symmetrise the molecule: each symmetry operation is applied on the original molecule to produce a symmetry-equivalent copy, then all symmetry-equivalent copies are averaged to give the symmetrised molecule.
14//! 5. Repeat steps 1 to 4 above until convergence is reached.
15//!
16//! There are two convergence criteria for the symmetrisation procedure:
17//! - **either** when the loose-threshold symmetry agrees with the target-threshold symmetry,
18//! - **or** when the target-threshold symmetry contains more elements than the loose-threshold symmetry and has been consistently identified for a pre-specified number of consecutive iterations.
19//!
20//! At least one criterion must be satisfied in order for convergence to be reached.
21
22use std::fmt;
23use std::path::PathBuf;
24
25use anyhow::{ensure, format_err};
26use derive_builder::Builder;
27use itertools::Itertools;
28use nalgebra::Point3;
29use ndarray::{Array2, Axis};
30use num_traits::ToPrimitive;
31use rayon::prelude::*;
32use serde::{Deserialize, Serialize};
33
34use crate::auxiliary::geometry::Transform;
35use crate::auxiliary::molecule::Molecule;
36use crate::drivers::QSym2Driver;
37use crate::drivers::symmetry_group_detection::{
38    SymmetryGroupDetectionDriver, SymmetryGroupDetectionParams,
39};
40use crate::io::QSym2FileType;
41use crate::io::format::{QSym2Output, log_subtitle, log_title, nice_bool, qsym2_output};
42use crate::permutation::{IntoPermutation, Permutation};
43use crate::symmetry::symmetry_core::{PreSymmetry, Symmetry};
44
45#[cfg(test)]
46#[path = "molecule_symmetrisation_bootstrap_tests.rs"]
47mod molecule_symmetrisation_bootstrap_tests;
48
49// ==================
50// Struct definitions
51// ==================
52
53// ----------
54// Parameters
55// ----------
56
57fn default_true() -> bool {
58    true
59}
60fn default_max_iterations() -> usize {
61    50
62}
63fn default_consistent_iterations() -> usize {
64    10
65}
66fn default_loose_threshold() -> f64 {
67    1e-2
68}
69fn default_tight_threshold() -> f64 {
70    1e-7
71}
72
73/// Structure containing control parameters for molecule symmetrisation by bootstrapping.
74#[derive(Clone, Builder, Debug, Serialize, Deserialize)]
75pub struct MoleculeSymmetrisationBootstrapParams {
76    /// Boolean indicating if the molecule is also reoriented to align its principal axes with the
77    /// space-fixed Cartesian axes at every iteration.
78    ///
79    /// See [`Molecule::reorientate`] for more information.
80    #[builder(default = "true")]
81    #[serde(default = "default_true")]
82    pub reorientate_molecule: bool,
83
84    /// The `loose` moment-of-inertia threshold for the symmetrisation. The symmetry elements found
85    /// at this threshold level will be used to bootstrap the symmetry of the molecule.
86    #[builder(default = "1e-2")]
87    #[serde(default = "default_loose_threshold")]
88    pub loose_moi_threshold: f64,
89
90    /// The `loose` distance threshold for the symmetrisation. The symmetry elements found at this
91    /// threshold level will be used to bootstrap the symmetry of the molecule.
92    #[builder(default = "1e-2")]
93    #[serde(default = "default_loose_threshold")]
94    pub loose_distance_threshold: f64,
95
96    /// The `target` moment-of-inertia threshold for the symmetrisation.
97    #[builder(default = "1e-7")]
98    #[serde(default = "default_tight_threshold")]
99    pub target_moi_threshold: f64,
100
101    /// The `target` distance threshold for the symmetrisation.
102    #[builder(default = "1e-7")]
103    #[serde(default = "default_tight_threshold")]
104    pub target_distance_threshold: f64,
105
106    /// The maximum number of symmetrisation iterations.
107    #[builder(default = "50")]
108    #[serde(default = "default_max_iterations")]
109    pub max_iterations: usize,
110
111    /// The number of consecutive iterations during which the symmetry group at the `target` level
112    /// of threshold must be consistently found for convergence to be reached, *if this group
113    /// cannot become identical to the symmetry group at the `loose` level of threshold*.
114    #[builder(default = "10")]
115    #[serde(default = "default_consistent_iterations")]
116    pub consistent_target_symmetry_iterations: usize,
117
118    /// The finite order to which any infinite-order symmetry element is reduced, so that a finite
119    /// number of symmetry operations can be used for the symmetrisation.
120    #[builder(default = "None")]
121    #[serde(default)]
122    pub infinite_order_to_finite: Option<u32>,
123
124    /// Boolean indicating if any available magnetic group should be used for symmetrisation instead
125    /// of the unitary group.
126    #[builder(default = "false")]
127    #[serde(default)]
128    pub use_magnetic_group: bool,
129
130    /// The output verbosity level.
131    #[builder(default = "0")]
132    #[serde(default)]
133    pub verbose: u8,
134
135    /// Optional name (without the `.xyz` extension) for writing the symmetrised molecule to an XYZ
136    /// file. If `None`, no XYZ files will be written.
137    #[builder(default = "None")]
138    #[serde(default)]
139    pub symmetrised_result_xyz: Option<PathBuf>,
140
141    /// Optional name for saving the symmetry-group detection verification result of the symmetrised
142    /// system as a binary file of type [`QSym2FileType::Sym`]. If `None`, the result will not be
143    /// saved.
144    #[builder(default = "None")]
145    #[serde(default)]
146    pub symmetrised_result_save_name: Option<PathBuf>,
147}
148
149impl MoleculeSymmetrisationBootstrapParams {
150    /// Returns a builder to construct a [`MoleculeSymmetrisationBootstrapParams`] structure.
151    pub fn builder() -> MoleculeSymmetrisationBootstrapParamsBuilder {
152        MoleculeSymmetrisationBootstrapParamsBuilder::default()
153    }
154}
155
156impl Default for MoleculeSymmetrisationBootstrapParams {
157    fn default() -> Self {
158        Self::builder()
159            .build()
160            .expect("Unable to construct a default `MoleculeSymmetrisationBootstrapParams`.")
161    }
162}
163
164impl fmt::Display for MoleculeSymmetrisationBootstrapParams {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        writeln!(f, "Loose MoI threshold: {:.3e}", self.loose_moi_threshold)?;
167        writeln!(
168            f,
169            "Loose geo threshold: {:.3e}",
170            self.loose_distance_threshold
171        )?;
172        writeln!(f, "Target MoI threshold: {:.3e}", self.target_moi_threshold)?;
173        writeln!(
174            f,
175            "Target geo threshold: {:.3e}",
176            self.target_distance_threshold
177        )?;
178        writeln!(f)?;
179        writeln!(
180            f,
181            "Group used for symmetrisation: {}",
182            if self.use_magnetic_group {
183                "magnetic group"
184            } else {
185                "unitary group"
186            }
187        )?;
188        if let Some(finite_order) = self.infinite_order_to_finite {
189            writeln!(f, "Infinite order to finite: {finite_order}")?;
190        }
191        writeln!(
192            f,
193            "Maximum symmetrisation iterations: {}",
194            self.max_iterations
195        )?;
196        writeln!(
197            f,
198            "Target symmetry consistent iterations: {}",
199            self.consistent_target_symmetry_iterations
200        )?;
201        writeln!(f, "Output level: {}", self.verbose)?;
202        writeln!(
203            f,
204            "Save symmetrised molecule to XYZ file: {}",
205            if let Some(name) = self.symmetrised_result_xyz.as_ref() {
206                let mut path = name.clone();
207                path.set_extension("xyz");
208                path.display().to_string()
209            } else {
210                nice_bool(false)
211            }
212        )?;
213        writeln!(
214            f,
215            "Save symmetry-group detection results of symmetrised system to file: {}",
216            if let Some(name) = self.symmetrised_result_save_name.as_ref() {
217                let mut path = name.clone();
218                path.set_extension(QSym2FileType::Sym.ext());
219                path.display().to_string()
220            } else {
221                nice_bool(false)
222            }
223        )?;
224        writeln!(f)?;
225
226        Ok(())
227    }
228}
229
230// ------
231// Result
232// ------
233
234/// Structure to contain molecule symmetrisation by bootstrapping results.
235#[derive(Clone, Builder, Debug)]
236pub struct MoleculeSymmetrisationBootstrapResult<'a> {
237    /// The control parameters used to obtain this set of molecule symmetrisation results.
238    parameters: &'a MoleculeSymmetrisationBootstrapParams,
239
240    /// The symmetrised molecule.
241    pub symmetrised_molecule: Molecule,
242}
243
244impl<'a> MoleculeSymmetrisationBootstrapResult<'a> {
245    fn builder() -> MoleculeSymmetrisationBootstrapResultBuilder<'a> {
246        MoleculeSymmetrisationBootstrapResultBuilder::default()
247    }
248}
249
250// ------
251// Driver
252// ------
253
254/// Driver for molecule symmetrisation by bootstrapping in QSym².
255#[derive(Clone, Builder)]
256#[builder(build_fn(validate = "Self::validate"))]
257pub struct MoleculeSymmetrisationBootstrapDriver<'a> {
258    /// The control parameters for molecule symmetrisation by bootstrapping.
259    parameters: &'a MoleculeSymmetrisationBootstrapParams,
260
261    /// The molecule to be symmetrised.
262    molecule: &'a Molecule,
263
264    /// The result of the symmetrisation.
265    #[builder(setter(skip), default = "None")]
266    result: Option<MoleculeSymmetrisationBootstrapResult<'a>>,
267}
268
269impl<'a> MoleculeSymmetrisationBootstrapDriverBuilder<'a> {
270    fn validate(&self) -> Result<(), String> {
271        let params = self
272            .parameters
273            .ok_or("No molecule symmetrisation parameters found.".to_string())?;
274        if params.consistent_target_symmetry_iterations > params.max_iterations {
275            return Err(format!(
276                "The number of consistent target-symmetry iterations, `{}`, cannot exceed the \
277                    maximum number of iterations, `{}`.",
278                params.consistent_target_symmetry_iterations, params.max_iterations,
279            ));
280        }
281        if params.target_moi_threshold < 0.0
282            || params.loose_moi_threshold < 0.0
283            || params.target_distance_threshold < 0.0
284            || params.loose_distance_threshold < 0.0
285        {
286            return Err("The thresholds cannot be negative.".to_string());
287        }
288        if params.target_moi_threshold > params.loose_moi_threshold {
289            return Err(format!(
290                "The target MoI threshold, `{:.3e}`, cannot be larger than the \
291                    loose MoI threshold, `{:.3e}`.",
292                params.target_moi_threshold, params.loose_moi_threshold
293            ));
294        }
295        if params.target_distance_threshold > params.loose_distance_threshold {
296            return Err(format!(
297                "The target distance threshold, `{:.3e}`, cannot be larger than the \
298                    loose distance threshold, `{:.3e}`.",
299                params.target_distance_threshold, params.loose_distance_threshold
300            ));
301        }
302        Ok(())
303    }
304}
305
306impl<'a> MoleculeSymmetrisationBootstrapDriver<'a> {
307    /// Returns a builder to construct a [`MoleculeSymmetrisationBootstrapDriver`] structure.
308    pub fn builder() -> MoleculeSymmetrisationBootstrapDriverBuilder<'a> {
309        MoleculeSymmetrisationBootstrapDriverBuilder::default()
310    }
311
312    /// Executes molecule symmetrisation by bootstrapping.
313    fn symmetrise_molecule(&mut self) -> Result<(), anyhow::Error> {
314        log_title("Molecule Symmetrisation by Bootstrapping");
315        qsym2_output!("");
316        let params = self.parameters;
317        params.log_output_display();
318
319        let mut trial_mol = self.molecule.recentre();
320
321        if params.verbose >= 1 {
322            let orig_mol = self
323                .molecule
324                .adjust_threshold(params.target_distance_threshold);
325            qsym2_output!("Unsymmetrised original molecule:");
326            orig_mol.log_output_display();
327            qsym2_output!("");
328
329            qsym2_output!("Unsymmetrised recentred molecule:");
330            trial_mol.log_output_display();
331            qsym2_output!("");
332        }
333
334        if params.reorientate_molecule {
335            // If reorientation is requested, the trial molecule is reoriented prior to
336            // symmetrisation, so that the symmetrisation procedure acts on the reoriented molecule
337            // itself. The molecule might become disoriented during the symmetrisation process, but
338            // any such disorientation is likely to be fairly small, and post-symmetrisation
339            // corrections on small disorientation are better than on large disorientation.
340            trial_mol.reorientate_mut(params.target_moi_threshold);
341            qsym2_output!("Unsymmetrised recentred and reoriented molecule:");
342            trial_mol.log_output_display();
343            qsym2_output!("");
344        };
345
346        log_subtitle("Iterative molecule symmetry bootstrapping");
347        qsym2_output!("");
348        qsym2_output!("Thresholds:");
349        qsym2_output!(
350            "  Loose : {:.3e} (MoI) - {:.3e} (distance)",
351            params.loose_distance_threshold,
352            params.loose_moi_threshold,
353        );
354        qsym2_output!(
355            "  Target: {:.3e} (MoI) - {:.3e} (distance)",
356            params.target_moi_threshold,
357            params.target_distance_threshold
358        );
359        qsym2_output!("");
360        qsym2_output!("Convergence criteria:");
361        qsym2_output!(
362            "  either: (1) when the loose-threshold symmetry agrees with the target-threshold symmetry,",
363        );
364        qsym2_output!(
365            "  or    : (2) when the target-threshold symmetry contains more elements than the loose-threshold symmetry and has been consistently identified for {} consecutive iteration{}.",
366            params.consistent_target_symmetry_iterations,
367            if params.consistent_target_symmetry_iterations == 1 {
368                ""
369            } else {
370                "s"
371            }
372        );
373        qsym2_output!("");
374
375        let count_length = usize::try_from(params.max_iterations.ilog10() + 1).map_err(|_| {
376            format_err!(
377                "Unable to convert `{}` to `usize`.",
378                params.max_iterations.ilog10() + 1
379            )
380        })?;
381        qsym2_output!("{}", "┈".repeat(count_length + 101));
382        qsym2_output!(
383            " {:>count_length$} {:>22} {:>19}  {:>22} {:>19}  {:>10}",
384            "#",
385            "Rot. sym. (loose)",
386            "Group (loose)",
387            "Rot. sym. (target)",
388            "Group (target)",
389            "Converged?",
390        );
391        qsym2_output!("{}", "┈".repeat(count_length + 101));
392
393        let mut symmetrisation_count = 0;
394        let mut consistent_target_sym_count = 0;
395        let mut loose_ops = vec![];
396        let mut prev_target_sym_group_name: Option<String> = None;
397        let mut converged = false;
398        while symmetrisation_count == 0
399            || (!converged && symmetrisation_count < params.max_iterations)
400        {
401            symmetrisation_count += 1;
402
403            // -------------------------------
404            // Loose threshold symmetry search
405            // -------------------------------
406            let mut loose_mol =
407                trial_mol.adjust_threshold(self.parameters.loose_distance_threshold);
408            let loose_presym = PreSymmetry::builder()
409                .moi_threshold(self.parameters.loose_moi_threshold)
410                .molecule(&loose_mol)
411                .build()
412                .map_err(|_| {
413                    format_err!("Cannot construct a loose-threshold pre-symmetry structure.")
414                })?;
415
416            let mut loose_sym = Symmetry::new();
417
418            // This might fail, but that's fine. We are bootstrapping.
419            let _loose_res = loose_sym.analyse(&loose_presym, self.parameters.use_magnetic_group);
420
421            // Only the operations are needed for the symmetrisation. We avoid constructing the
422            // full abstract group here, as group closure might not be fulfilled due to the low
423            // thresholds.
424
425            loose_ops.extend_from_slice(
426                &loose_sym.generate_all_operations(self.parameters.infinite_order_to_finite),
427            );
428            let n_ops_f64 = loose_ops.len().to_f64().ok_or_else(|| {
429                format_err!("Unable to convert the number of operations to `f64`.")
430            })?;
431
432            // Generate transformation matrix and atom permutations for each operation
433            let ts = loose_ops
434                .into_par_iter()
435                .flat_map(|op| {
436                    let tmat = op
437                        .get_3d_spatial_matrix()
438                        .select(Axis(0), &[2, 0, 1])
439                        .select(Axis(1), &[2, 0, 1])
440                        .reversed_axes();
441
442                    let ord_perm = op
443                        .act_permute(&loose_mol.molecule_ordinary_atoms())
444                        .ok_or_else(|| {
445                            format_err!(
446                                "Unable to determine the ordinary-atom permutation corresponding to `{op}`."
447                            )
448                        })?;
449                    let mag_perm_opt = loose_mol
450                        .molecule_magnetic_atoms()
451                        .as_ref()
452                        .and_then(|loose_mag_mol| op.act_permute(loose_mag_mol));
453                    let elec_perm_opt = loose_mol
454                        .molecule_electric_atoms()
455                        .as_ref()
456                        .and_then(|loose_elec_mol| op.act_permute(loose_elec_mol));
457                    Ok::<_, anyhow::Error>((tmat, ord_perm, mag_perm_opt, elec_perm_opt))
458                })
459                .collect::<Vec<_>>();
460
461            // Apply symmetry operations to the ordinary atoms
462            let loose_ord_coords = Array2::from_shape_vec(
463                (loose_mol.atoms.len(), 3),
464                loose_mol
465                    .atoms
466                    .iter()
467                    .flat_map(|atom| atom.coordinates.coords.iter().cloned())
468                    .collect::<Vec<_>>(),
469            )?;
470            let ave_ord_coords = ts.iter().fold(
471                // Parallelisation here does not improve performance, and even causes more
472                // numerical instability.
473                Array2::<f64>::zeros(loose_ord_coords.raw_dim()),
474                |acc, (tmat, ord_perm, _, _)| {
475                    // coords.dot(tmat) gives the atom positions transformed in R^3 by tmat.
476                    // .select(Axis(0), perm.image()) then permutes the rows so that the atom positions
477                    // go back to approximately where they were originally.
478                    acc + loose_ord_coords.dot(tmat).select(Axis(0), ord_perm.image())
479                },
480            ) / n_ops_f64;
481            loose_mol
482                .atoms
483                .par_iter_mut()
484                .enumerate()
485                .for_each(|(i, atom)| {
486                    atom.coordinates = Point3::<f64>::from_slice(
487                        ave_ord_coords
488                            .row(i)
489                            .as_slice()
490                            .expect("Unable to convert a row of averaged coordinates to a slice."),
491                    )
492                });
493
494            // Apply symmetry operations to the magnetic atoms, if any
495            if let Some(mag_atoms) = loose_mol.magnetic_atoms.as_mut() {
496                let loose_mag_coords = Array2::from_shape_vec(
497                    (mag_atoms.len(), 3),
498                    mag_atoms
499                        .iter()
500                        .flat_map(|atom| atom.coordinates.coords.iter().cloned())
501                        .collect::<Vec<_>>(),
502                )?;
503                let ave_mag_coords =
504                    ts.iter().try_fold(
505                        Array2::<f64>::zeros(loose_mag_coords.raw_dim()),
506                        |acc: Array2<f64>,
507                         (tmat, _, mag_perm_opt, _): &(
508                            Array2<_>,
509                            _,
510                            Option<Permutation<usize>>,
511                            _,
512                        )| {
513                            // coords.dot(tmat) gives the atom positions transformed in R^3 by tmat.
514                            // .select(Axis(0), perm.image()) then permutes the rows so that the atom positions
515                            // go back to approximately where they were originally.
516                            Ok::<_, anyhow::Error>(
517                                acc + loose_mag_coords.dot(tmat).select(
518                                    Axis(0),
519                                    mag_perm_opt
520                                        .as_ref()
521                                        .ok_or_else(|| {
522                                            format_err!(
523                                                "Expected magnetic atom permutation not found."
524                                            )
525                                        })?
526                                        .image(),
527                                ),
528                            )
529                        },
530                    )? / n_ops_f64;
531                mag_atoms.iter_mut().enumerate().for_each(|(i, atom)| {
532                    atom.coordinates = Point3::<f64>::from_slice(
533                        ave_mag_coords
534                            .row(i)
535                            .as_slice()
536                            .expect("Unable to convert a row of averaged coordinates to a slice."),
537                    )
538                });
539            }
540
541            // Apply symmetry operations to the electric atoms, if any
542            if let Some(elec_atoms) = loose_mol.electric_atoms.as_mut() {
543                let loose_elec_coords = Array2::from_shape_vec(
544                    (elec_atoms.len(), 3),
545                    elec_atoms
546                        .iter()
547                        .flat_map(|atom| atom.coordinates.coords.iter().cloned())
548                        .collect::<Vec<_>>(),
549                )?;
550                let ave_elec_coords = ts.iter().try_fold(
551                    Array2::<f64>::zeros(loose_elec_coords.raw_dim()),
552                    |acc: Array2<f64>,
553                     (tmat, _, _, elec_perm_opt): &(
554                        Array2<_>,
555                        _,
556                        Option<Permutation<usize>>,
557                        _,
558                    )| {
559                        // coords.dot(tmat) gives the atom positions transformed in R^3 by tmat.
560                        // .select(Axis(0), perm.image()) then permutes the rows so that the atom positions
561                        // go back to approximately where they were originally.
562                        Ok::<_, anyhow::Error>(
563                            acc + loose_elec_coords.dot(tmat).select(
564                                Axis(0),
565                                elec_perm_opt
566                                    .as_ref()
567                                    .ok_or_else(|| {
568                                        format_err!("Expected electric atom permutation not found.")
569                                    })?
570                                    .image(),
571                            ),
572                        )
573                    },
574                )? / n_ops_f64;
575                elec_atoms.iter_mut().enumerate().for_each(|(i, atom)| {
576                    atom.coordinates = Point3::<f64>::from_slice(
577                        ave_elec_coords
578                            .row(i)
579                            .as_slice()
580                            .expect("Unable to convert a row of averaged coordinates to a slice."),
581                    )
582                });
583            }
584
585            trial_mol = loose_mol;
586
587            // Recentre and reorientate after symmetrisation
588            trial_mol.recentre_mut();
589            if params.reorientate_molecule {
590                // If reorientation is requested, the trial molecule is reoriented prior to
591                // symmetrisation, so that the symmetrisation procedure acts on the reoriented molecule
592                // itself. The molecule might become disoriented during the symmetrisation process, but
593                // any such disorientation is likely to be fairly small, and post-symmetrisation
594                // corrections on small disorientation are better than on large disorientation.
595                trial_mol.reorientate_mut(params.target_moi_threshold);
596            };
597
598            // -------------------------------
599            // Target threshold symmetry check
600            // -------------------------------
601            let target_mol = trial_mol.adjust_threshold(self.parameters.target_distance_threshold);
602            let target_presym = PreSymmetry::builder()
603                .moi_threshold(self.parameters.target_moi_threshold)
604                .molecule(&target_mol)
605                .build()
606                .map_err(|_| {
607                    format_err!("Cannot construct a target-threshold pre-symmetry structure.")
608                })?;
609
610            let mut target_sym = Symmetry::new();
611
612            let _ = target_sym.analyse(&target_presym, params.use_magnetic_group);
613
614            let target_loose_consistent = target_sym.n_elements() == loose_sym.n_elements()
615                && target_sym.group_name.is_some()
616                && target_sym.group_name == loose_sym.group_name;
617
618            if target_sym.group_name == prev_target_sym_group_name
619                && target_sym.n_elements() >= loose_sym.n_elements()
620            {
621                consistent_target_sym_count += 1;
622            } else {
623                consistent_target_sym_count = 0;
624            }
625            prev_target_sym_group_name = target_sym.group_name.clone();
626            let target_consistent =
627                consistent_target_sym_count >= params.consistent_target_symmetry_iterations;
628
629            converged = target_loose_consistent || target_consistent;
630            let converged_reason = [target_loose_consistent, target_consistent]
631                .iter()
632                .enumerate()
633                .filter_map(|(i, c)| {
634                    if *c {
635                        Some(format!("({})", i + 1))
636                    } else {
637                        None
638                    }
639                })
640                .join("");
641
642            qsym2_output!(
643                " {:>count_length$} {:>22} {:>19}  {:>22} {:>19}  {:>10}",
644                symmetrisation_count,
645                loose_presym.rotational_symmetry.to_string(),
646                format!(
647                    "{} ({})",
648                    loose_sym.group_name.as_ref().unwrap_or(&"--".to_string()),
649                    loose_sym.n_elements()
650                ),
651                target_presym.rotational_symmetry.to_string(),
652                format!(
653                    "{} ({})",
654                    target_sym.group_name.as_ref().unwrap_or(&"--".to_string()),
655                    target_sym.n_elements()
656                ),
657                if converged {
658                    "yes ".to_string() + &converged_reason
659                } else {
660                    "no".to_string()
661                },
662            );
663
664            loose_ops =
665                target_sym.generate_all_operations(self.parameters.infinite_order_to_finite);
666        }
667        qsym2_output!("{}", "┈".repeat(count_length + 101));
668        qsym2_output!("");
669
670        // --------------------------------
671        // Verify the symmetrisation result
672        // --------------------------------
673        qsym2_output!("Verifying symmetrisation results...");
674        qsym2_output!("");
675        let verifying_pd_params = SymmetryGroupDetectionParams::builder()
676            .moi_thresholds(&[params.target_moi_threshold])
677            .distance_thresholds(&[params.target_distance_threshold])
678            .time_reversal(params.use_magnetic_group)
679            .write_symmetry_elements(true)
680            .result_save_name(params.symmetrised_result_save_name.clone())
681            .build()?;
682        let mut verifying_pd_driver = SymmetryGroupDetectionDriver::builder()
683            .parameters(&verifying_pd_params)
684            .molecule(Some(&trial_mol))
685            .build()?;
686        verifying_pd_driver.run()?;
687        let verifying_pd_res = verifying_pd_driver.result()?;
688        let verifying_group_name = if params.use_magnetic_group {
689            verifying_pd_res
690                .magnetic_symmetry
691                .as_ref()
692                .and_then(|magsym| magsym.group_name.as_ref())
693        } else {
694            verifying_pd_res.unitary_symmetry.group_name.as_ref()
695        };
696        ensure!(
697            prev_target_sym_group_name.as_ref() == verifying_group_name,
698            "Mismatched symmetry: iterative symmetry bootstrapping found {}, but verification found {}.",
699            prev_target_sym_group_name
700                .as_ref()
701                .unwrap_or(&"--".to_string()),
702            verifying_group_name.unwrap_or(&"--".to_string()),
703        );
704        qsym2_output!("Verifying symmetrisation results... Done.");
705        qsym2_output!("");
706
707        // --------------
708        // Saving results
709        // --------------
710        self.result = Some(
711            MoleculeSymmetrisationBootstrapResult::builder()
712                .parameters(self.parameters)
713                .symmetrised_molecule(trial_mol.clone())
714                .build()?,
715        );
716
717        if let Some(xyz_name) = params.symmetrised_result_xyz.as_ref() {
718            let mut path = xyz_name.clone();
719            path.set_extension("xyz");
720            verifying_pd_res
721                .pre_symmetry
722                .recentred_molecule
723                .to_xyz(&path)?;
724            qsym2_output!("Symmetrised molecule written to: {}", path.display());
725            qsym2_output!("");
726        }
727
728        Ok(())
729    }
730}
731
732impl<'a> QSym2Driver for MoleculeSymmetrisationBootstrapDriver<'a> {
733    type Params = MoleculeSymmetrisationBootstrapParams;
734
735    type Outcome = MoleculeSymmetrisationBootstrapResult<'a>;
736
737    fn result(&self) -> Result<&Self::Outcome, anyhow::Error> {
738        self.result
739            .as_ref()
740            .ok_or_else(|| format_err!("No molecule sprucing results found."))
741    }
742
743    fn run(&mut self) -> Result<(), anyhow::Error> {
744        self.symmetrise_molecule()
745    }
746}