Skip to main content
← OpenMECP Documentation

omecp/
lst.rs

1//! Linear Synchronous Transit (LST) interpolation module.
2//!
3//! This module provides functionality for interpolating between molecular geometries
4//! using various methods including Linear Synchronous Transit (LST) and Quadratic
5//! Synchronous Transit (QST). These methods are commonly used in quantum chemistry
6//! to generate initial reaction paths and locate transition states.
7//!
8//! # Overview
9//!
10//! The LST method creates a series of intermediate geometries between two endpoint
11//! structures by linear interpolation in Cartesian coordinates. The QST method
12//! extends this to quadratic interpolation using three geometries (reactant,
13//! product, and an intermediate structure).
14//!
15//! # Key Features
16//!
17//! - **Kabsch Algorithm**: Optimal alignment of geometries before interpolation
18//! - **Multiple Methods**: Linear, quadratic, and energy-weighted interpolation
19//! - **Validation**: Comprehensive geometry validation and error checking
20//! - **Path Analysis**: Path length calculation and geometry preview
21//!
22//! # Examples
23//!
24//! ```rust
25//! use omecp::lst::{interpolate, InterpolationMethod};
26//! use omecp::geometry::Geometry;
27//!
28//! // Create two geometries (example with water molecule)
29//! let elements = vec!["O".to_string(), "H".to_string(), "H".to_string()];
30//! let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
31//! let coords2 = vec![0.0, 0.0, 0.5, 1.2, 0.0, 0.5, 0.0, 1.2, 0.5];
32//!
33//! let geom1 = Geometry::new(elements.clone(), coords1);
34//! let geom2 = Geometry::new(elements, coords2);
35//!
36//! // Generate 10 intermediate geometries using linear interpolation
37//! let path = interpolate(&geom1, &geom2, 10, InterpolationMethod::Linear);
38//! ```
39//!
40//! # References
41//!
42//! - Halgren, T. A.; Lipscomb, W. N. *Chem. Phys. Lett.* **1977**, 49, 225-232.
43//! - Kabsch, W. *Acta Crystallogr. A* **1976**, 32, 922-923.
44
45use crate::geometry::Geometry;
46use nalgebra::DMatrix;
47
48/// Interpolation methods available for LST calculations.
49///
50/// This enum defines the different interpolation strategies that can be used
51/// to generate intermediate geometries between molecular structures.
52///
53/// # Variants
54///
55/// - [`Linear`](InterpolationMethod::Linear): Simple linear interpolation between two geometries
56/// - [`Quadratic`](InterpolationMethod::Quadratic): Quadratic interpolation using three geometries
57/// - [`EnergyWeighted`](InterpolationMethod::EnergyWeighted): Energy-informed interpolation (future feature)
58///
59/// # Examples
60///
61/// ```rust
62/// use omecp::lst::InterpolationMethod;
63///
64/// let method = InterpolationMethod::Linear;
65/// assert_eq!(method, InterpolationMethod::Linear);
66/// ```
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum InterpolationMethod {
69    /// Linear Synchronous Transit (LST) interpolation.
70    ///
71    /// Performs simple linear interpolation between two endpoint geometries.
72    /// This is the most commonly used method and provides a straight-line
73    /// path in Cartesian coordinate space after optimal alignment.
74    Linear,
75
76    /// Quadratic Synchronous Transit (QST) interpolation.
77    ///
78    /// Uses quadratic interpolation with three geometries: two endpoints and
79    /// one intermediate structure. If only two geometries are provided, a
80    /// midpoint geometry is automatically generated.
81    Quadratic,
82
83    /// Energy-weighted interpolation method.
84    ///
85    /// **Note**: This is a placeholder for future implementation. Currently
86    /// falls back to linear interpolation. Will incorporate energy information
87    /// to create more physically meaningful interpolation paths.
88    EnergyWeighted,
89}
90
91/// Performs LST interpolation between two geometries using the specified method.
92///
93/// This is the main entry point for LST interpolation. It dispatches to the
94/// appropriate interpolation function based on the selected method and returns
95/// a vector of intermediate geometries.
96///
97/// # Arguments
98///
99/// * `geom1` - The starting geometry (reactant structure)
100/// * `geom2` - The ending geometry (product structure)
101/// * `num_points` - Number of intermediate points to generate (excluding endpoints)
102/// * `method` - The interpolation method to use
103///
104/// # Returns
105///
106/// Returns a `Vec<Geometry>` containing the interpolated geometries. The vector
107/// will have `num_points + 2` elements (including both endpoints).
108///
109/// # Panics
110///
111/// Panics if the input geometries have different numbers of atoms or different
112/// element types.
113///
114/// # Examples
115///
116/// ```rust
117/// use omecp::lst::{interpolate, InterpolationMethod};
118/// use omecp::geometry::Geometry;
119///
120/// let elements = vec!["H".to_string(), "H".to_string()];
121/// let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
122/// let coords2 = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
123///
124/// let geom1 = Geometry::new(elements.clone(), coords1);
125/// let geom2 = Geometry::new(elements, coords2);
126///
127/// // Generate 5 intermediate points using linear interpolation
128/// let path = interpolate(&geom1, &geom2, 5, InterpolationMethod::Linear);
129/// assert_eq!(path.len(), 7); // 5 intermediate + 2 endpoints
130/// ```
131///
132/// # Implementation Notes
133///
134/// - For [`InterpolationMethod::Linear`]: Uses Kabsch algorithm for optimal alignment
135/// - For [`InterpolationMethod::Quadratic`]: Automatically generates midpoint if needed
136/// - For [`InterpolationMethod::EnergyWeighted`]: Currently falls back to linear interpolation
137pub fn interpolate(
138    geom1: &Geometry,
139    geom2: &Geometry,
140    num_points: usize,
141    method: InterpolationMethod,
142) -> Vec<Geometry> {
143    match method {
144        InterpolationMethod::Linear => interpolate_linear(geom1, geom2, num_points),
145        InterpolationMethod::Quadratic => {
146            // For QST, we need a third geometry (midpoint approximation)
147            let midpoint = create_midpoint_geometry(geom1, geom2);
148            interpolate_quadratic(geom1, &midpoint, geom2, num_points)
149        }
150        InterpolationMethod::EnergyWeighted => {
151            // For now, fall back to linear (would need energy values)
152            interpolate_linear(geom1, geom2, num_points)
153        }
154    }
155}
156
157/// Performs Linear Synchronous Transit (LST) interpolation between two geometries.
158///
159/// This function implements the LST method using the Kabsch algorithm for optimal
160/// alignment of the geometries before interpolation. The Kabsch algorithm finds
161/// the optimal rotation matrix that minimizes the root-mean-square deviation
162/// between corresponding atoms.
163///
164/// # Arguments
165///
166/// * `geom1` - The starting geometry (will be aligned to geom2)
167/// * `geom2` - The target geometry (reference for alignment)
168/// * `num_points` - Number of intermediate points to generate
169///
170/// # Returns
171///
172/// Returns a `Vec<Geometry>` containing `num_points + 2` geometries, including
173/// the aligned starting geometry and the target geometry.
174///
175/// # Panics
176///
177/// Panics if the input geometries have different numbers of atoms.
178///
179/// # Algorithm Details
180///
181/// 1. **Alignment**: Uses Kabsch algorithm to find optimal rotation matrix
182/// 2. **Rotation**: Applies rotation to align geom1 with geom2
183/// 3. **Interpolation**: Linear interpolation between aligned geometries
184///
185/// The Kabsch algorithm steps:
186/// - Compute cross-covariance matrix R = Y * X^T
187/// - Compute R^T * R and its eigendecomposition
188/// - Construct rotation matrix U from eigenvectors and eigenvalues
189///
190/// # Examples
191///
192/// ```rust
193/// use omecp::lst::interpolate_linear;
194/// use omecp::geometry::Geometry;
195///
196/// let elements = vec!["C".to_string(), "H".to_string()];
197/// let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
198/// let coords2 = vec![0.0, 0.0, 1.0, 1.0, 0.0, 1.0];
199///
200/// let geom1 = Geometry::new(elements.clone(), coords1);
201/// let geom2 = Geometry::new(elements, coords2);
202///
203/// let path = interpolate_linear(&geom1, &geom2, 3);
204/// assert_eq!(path.len(), 5); // 3 intermediate + 2 endpoints
205/// ```
206///
207/// # References
208///
209/// Kabsch, W. "A solution for the best rotation to relate two sets of vectors."
210/// *Acta Crystallographica Section A* **1976**, 32(5), 922-923.
211pub fn interpolate_linear(geom1: &Geometry, geom2: &Geometry, num_points: usize) -> Vec<Geometry> {
212    if geom1.num_atoms != geom2.num_atoms {
213        panic!("Geometries must have same number of atoms");
214    }
215
216    let n = geom1.num_atoms;
217
218    // Convert to 3xN matrices
219    let mut x = DMatrix::zeros(3, n);
220    let mut y = DMatrix::zeros(3, n);
221
222    for i in 0..n {
223        let coords1 = geom1.get_atom_coords(i);
224        let coords2 = geom2.get_atom_coords(i);
225        x[(0, i)] = coords1[0];
226        x[(1, i)] = coords1[1];
227        x[(2, i)] = coords1[2];
228        y[(0, i)] = coords2[0];
229        y[(1, i)] = coords2[1];
230        y[(2, i)] = coords2[2];
231    }
232
233    // Kabsch algorithm: find optimal rotation matrix
234    let r = &y * x.transpose();
235    let rt_r = r.transpose() * &r;
236    let eigen = rt_r.symmetric_eigen();
237
238    let mut mius = DMatrix::zeros(3, 3);
239    for i in 0..3 {
240        mius[(i, i)] = 1.0 / eigen.eigenvalues[i].sqrt();
241    }
242
243    let a = eigen.eigenvectors;
244    let b = &mius * (&r * &a).transpose();
245    let u = b.transpose() * &a;
246
247    // Apply rotation to first geometry
248    let x_aligned = &u * &x;
249
250    // Linear interpolation
251    let mut geometries = Vec::new();
252
253    for i in 0..=num_points {
254        let t = i as f64 / (num_points + 1) as f64;
255        let interp = &x_aligned * (1.0 - t) + &y * t;
256
257        let mut coords = Vec::new();
258        for j in 0..n {
259            coords.push(interp[(0, j)]);
260            coords.push(interp[(1, j)]);
261            coords.push(interp[(2, j)]);
262        }
263
264        geometries.push(Geometry::new(geom1.elements.clone(), coords));
265    }
266
267    geometries
268}
269
270/// Performs Quadratic Synchronous Transit (QST) interpolation using three geometries.
271///
272/// This function implements quadratic interpolation between three geometries:
273/// a starting point, an intermediate point, and an ending point. The resulting
274/// path follows a quadratic curve in coordinate space, which can provide a
275/// more realistic reaction path than linear interpolation.
276///
277/// # Arguments
278///
279/// * `geom1` - The starting geometry (t = 0)
280/// * `geom_mid` - The intermediate geometry (t = 0.5)
281/// * `geom2` - The ending geometry (t = 1)
282/// * `num_points` - Number of intermediate points to generate
283///
284/// # Returns
285///
286/// Returns a `Vec<Geometry>` containing `num_points + 2` geometries following
287/// a quadratic interpolation path.
288///
289/// # Panics
290///
291/// Panics if the input geometries have different numbers of atoms or different
292/// element types.
293///
294/// # Mathematical Formula
295///
296/// The quadratic interpolation formula used is:
297/// ```text
298/// p(t) = (1-t)² × p₁ + 2(1-t)t × p_mid + t² × p₂
299/// ```
300/// where t ∈ [0, 1] is the interpolation parameter.
301///
302/// # Examples
303///
304/// ```rust
305/// use omecp::lst::interpolate_quadratic;
306/// use omecp::geometry::Geometry;
307///
308/// let elements = vec!["O".to_string(), "H".to_string(), "H".to_string()];
309/// let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
310/// let coords_mid = vec![0.0, 0.0, 0.2, 1.1, 0.0, 0.2, 0.0, 1.1, 0.2];
311/// let coords2 = vec![0.0, 0.0, 0.5, 1.2, 0.0, 0.5, 0.0, 1.2, 0.5];
312///
313/// let geom1 = Geometry::new(elements.clone(), coords1);
314/// let geom_mid = Geometry::new(elements.clone(), coords_mid);
315/// let geom2 = Geometry::new(elements, coords2);
316///
317/// let path = interpolate_quadratic(&geom1, &geom_mid, &geom2, 4);
318/// assert_eq!(path.len(), 6); // 4 intermediate + 2 endpoints
319/// ```
320///
321/// # Notes
322///
323/// - The intermediate geometry should represent a reasonable guess for the
324///   transition state or a point along the reaction coordinate
325/// - For best results, the intermediate geometry should be optimized or
326///   at least chemically reasonable
327/// - If no intermediate geometry is available, use [`create_midpoint_geometry`]
328///   to generate a simple midpoint structure
329pub fn interpolate_quadratic(
330    geom1: &Geometry,
331    geom_mid: &Geometry,
332    geom2: &Geometry,
333    num_points: usize,
334) -> Vec<Geometry> {
335    if geom1.num_atoms != geom2.num_atoms || geom1.num_atoms != geom_mid.num_atoms {
336        panic!("All geometries must have same number of atoms");
337    }
338
339    let _n = geom1.num_atoms;
340    let mut geometries = Vec::new();
341
342    // Convert all geometries to coordinate vectors
343    let coords1 = geometry_to_coords(geom1);
344    let coords_mid = geometry_to_coords(geom_mid);
345    let coords2 = geometry_to_coords(geom2);
346
347    for i in 0..=num_points {
348        let t = i as f64 / (num_points + 1) as f64;
349
350        // Quadratic interpolation: p(t) = (1-t)^2 * p1 + 2*(1-t)*t * p_mid + t^2 * p2
351        let mut coords = Vec::new();
352        for j in 0..coords1.len() {
353            let val = (1.0 - t).powi(2) * coords1[j]
354                + 2.0 * (1.0 - t) * t * coords_mid[j]
355                + t.powi(2) * coords2[j];
356            coords.push(val);
357        }
358
359        geometries.push(Geometry::new(geom1.elements.clone(), coords));
360    }
361
362    geometries
363}
364
365/// Creates a simple midpoint geometry for QST when only two geometries are provided.
366///
367/// This function generates an intermediate geometry by taking the arithmetic
368/// mean of corresponding atomic coordinates. While this provides a reasonable
369/// starting point for QST interpolation, it may not represent a chemically
370/// meaningful structure.
371///
372/// # Arguments
373///
374/// * `geom1` - The first endpoint geometry
375/// * `geom2` - The second endpoint geometry
376///
377/// # Returns
378///
379/// Returns a new `Geometry` with coordinates that are the arithmetic mean
380/// of the input geometries.
381///
382/// # Examples
383///
384/// ```rust
385/// use omecp::lst::create_midpoint_geometry;
386/// use omecp::geometry::Geometry;
387///
388/// let elements = vec!["H".to_string(), "H".to_string()];
389/// let coords1 = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
390/// let coords2 = vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0];
391///
392/// let geom1 = Geometry::new(elements.clone(), coords1);
393/// let geom2 = Geometry::new(elements, coords2);
394///
395/// let midpoint = create_midpoint_geometry(&geom1, &geom2);
396/// let mid_coords = midpoint.get_atom_coords(1);
397/// assert_eq!(mid_coords[0], 3.0); // (2.0 + 4.0) / 2.0
398/// ```
399///
400/// # Notes
401///
402/// - The resulting geometry uses the same element types as the input geometries
403/// - This is a simple geometric midpoint and may not be chemically reasonable
404/// - For better results, consider using an optimized transition state guess
405/// - The midpoint geometry is primarily used as a fallback when no intermediate
406///   structure is available for QST interpolation
407fn create_midpoint_geometry(geom1: &Geometry, geom2: &Geometry) -> Geometry {
408    let coords1 = geometry_to_coords(geom1);
409    let coords2 = geometry_to_coords(geom2);
410
411    let mut mid_coords = Vec::new();
412    for i in 0..coords1.len() {
413        mid_coords.push((coords1[i] + coords2[i]) / 2.0);
414    }
415
416    Geometry::new(geom1.elements.clone(), mid_coords)
417}
418
419/// Converts a geometry to a flat coordinate vector.
420///
421/// This utility function extracts all atomic coordinates from a geometry
422/// and returns them as a single flat vector in the order [x₁, y₁, z₁, x₂, y₂, z₂, ...].
423/// This format is convenient for mathematical operations and interpolation.
424///
425/// # Arguments
426///
427/// * `geom` - The geometry to convert
428///
429/// # Returns
430///
431/// Returns a `Vec<f64>` containing all coordinates in flat format.
432/// The vector length will be `3 × num_atoms`.
433///
434/// # Examples
435///
436/// ```rust
437/// use omecp::geometry::Geometry;
438/// // Note: geometry_to_coords is private, this example shows the concept
439///
440/// let elements = vec!["H".to_string(), "O".to_string()];
441/// let coords = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
442/// let geom = Geometry::new(elements, coords.clone());
443///
444/// // The function would return: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
445/// ```
446fn geometry_to_coords(geom: &Geometry) -> Vec<f64> {
447    let mut coords = Vec::new();
448    for i in 0..geom.num_atoms {
449        let atom_coords = geom.get_atom_coords(i);
450        coords.push(atom_coords[0]);
451        coords.push(atom_coords[1]);
452        coords.push(atom_coords[2]);
453    }
454    coords
455}
456
457/// Validates that interpolated geometries are chemically and numerically reasonable.
458///
459/// This function performs comprehensive validation of a set of geometries,
460/// checking for common issues that can arise during interpolation such as
461/// inconsistent atom counts, non-finite coordinates, and unreasonably large
462/// coordinate values.
463///
464/// # Arguments
465///
466/// * `geometries` - A slice of geometries to validate
467///
468/// # Returns
469///
470/// Returns `Ok(())` if all geometries pass validation, or `Err(String)` with
471/// a descriptive error message if any issues are found.
472///
473/// # Validation Checks
474///
475/// 1. **Non-empty**: Ensures at least one geometry is provided
476/// 2. **Consistent atom count**: All geometries have the same number of atoms
477/// 3. **Consistent elements**: All geometries have the same element types
478/// 4. **Finite coordinates**: No NaN or infinite coordinate values
479/// 5. **Reasonable coordinates**: No coordinates with absolute value > 1000 Angstrom
480///
481/// # Examples
482///
483/// ```rust
484/// use omecp::lst::{interpolate_linear, validate_geometries};
485/// use omecp::geometry::Geometry;
486///
487/// let elements = vec!["H".to_string(), "H".to_string()];
488/// let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
489/// let coords2 = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
490///
491/// let geom1 = Geometry::new(elements.clone(), coords1);
492/// let geom2 = Geometry::new(elements, coords2);
493///
494/// let path = interpolate_linear(&geom1, &geom2, 3);
495/// assert!(validate_geometries(&path).is_ok());
496/// ```
497///
498/// # Error Conditions
499///
500/// The function returns an error in the following cases:
501/// - Empty geometry vector
502/// - Inconsistent number of atoms between geometries
503/// - Different element types between geometries
504/// - Non-finite coordinates (NaN or infinite values)
505/// - Coordinates with absolute values exceeding 1000 Angstrom
506///
507/// # Notes
508///
509/// - The 1000 Angstrom limit is a safety check for obviously incorrect structures
510/// - This validation should be called after any interpolation operation
511/// - Failed validation often indicates issues with input geometries or
512///   numerical problems during interpolation
513pub fn validate_geometries(geometries: &[Geometry]) -> Result<(), String> {
514    if geometries.is_empty() {
515        return Err("No geometries to validate".to_string());
516    }
517
518    let num_atoms = geometries[0].num_atoms;
519    let elements = &geometries[0].elements;
520
521    for (i, geom) in geometries.iter().enumerate() {
522        if geom.num_atoms != num_atoms {
523            return Err(format!(
524                "Geometry {} has {} atoms, expected {}",
525                i, geom.num_atoms, num_atoms
526            ));
527        }
528
529        if geom.elements != *elements {
530            return Err(format!(
531                "Geometry {} has different elements than reference",
532                i
533            ));
534        }
535
536        // Check for NaN or infinite coordinates
537        for j in 0..geom.num_atoms {
538            let coords = geom.get_atom_coords(j);
539            for &coord in &coords {
540                if !coord.is_finite() {
541                    return Err(format!(
542                        "Geometry {} atom {} has non-finite coordinate: {}",
543                        i, j, coord
544                    ));
545                }
546            }
547        }
548
549        // Check for unreasonably large coordinates (> 1000 Angstrom)
550        for j in 0..geom.num_atoms {
551            let coords = geom.get_atom_coords(j);
552            for &coord in &coords {
553                if coord.abs() > 1000.0 {
554                    return Err(format!(
555                        "Geometry {} atom {} has unreasonably large coordinate: {}",
556                        i, j, coord
557                    ));
558                }
559            }
560        }
561    }
562
563    Ok(())
564}
565
566/// Calculates the total path length along an interpolated reaction coordinate.
567///
568/// This function computes the cumulative Euclidean distance between consecutive
569/// geometries in the interpolation path. The path length provides a measure of
570/// the total geometric change along the reaction coordinate and can be useful
571/// for analyzing reaction paths and comparing different interpolation methods.
572///
573/// # Arguments
574///
575/// * `geometries` - A slice of geometries representing the interpolation path
576///
577/// # Returns
578///
579/// Returns the total path length as a `f64` value in the same units as the
580/// input coordinates (typically Angstroms).
581///
582/// # Algorithm
583///
584/// The path length is calculated as:
585/// ```text
586/// L = Σᵢ √(Σⱼ (xᵢ₊₁,ⱼ - xᵢ,ⱼ)²)
587/// ```
588/// where the outer sum is over geometry pairs and the inner sum is over
589/// all coordinate components.
590///
591/// # Examples
592///
593/// ```rust
594/// use omecp::lst::{interpolate_linear, calculate_path_length};
595/// use omecp::geometry::Geometry;
596///
597/// let elements = vec!["H".to_string(), "H".to_string()];
598/// let coords1 = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
599/// let coords2 = vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
600///
601/// let geom1 = Geometry::new(elements.clone(), coords1);
602/// let geom2 = Geometry::new(elements, coords2);
603///
604/// let path = interpolate_linear(&geom1, &geom2, 1);
605/// let length = calculate_path_length(&path);
606/// // Length should be approximately sqrt(2) ≈ 1.414
607/// ```
608///
609/// # Notes
610///
611/// - Returns 0.0 for empty or single-geometry paths
612/// - The path length depends on the coordinate system and units used
613/// - Longer paths may indicate more significant structural changes
614/// - Can be used to compare the "smoothness" of different interpolation methods
615pub fn calculate_path_length(geometries: &[Geometry]) -> f64 {
616    let mut total_length = 0.0;
617
618    for i in 1..geometries.len() {
619        let coords1 = geometry_to_coords(&geometries[i - 1]);
620        let coords2 = geometry_to_coords(&geometries[i]);
621
622        let mut segment_length = 0.0;
623        for j in 0..coords1.len() {
624            let diff = coords1[j] - coords2[j];
625            segment_length += diff * diff;
626        }
627        total_length += segment_length.sqrt();
628    }
629
630    total_length
631}
632
633/// Prints a formatted preview of interpolated geometries for interactive confirmation.
634///
635/// This function displays a summary of the interpolation results, including
636/// the total number of geometries, path length, and coordinate details for
637/// key geometries (first, middle, and last). This is useful for visual
638/// inspection before proceeding with expensive quantum chemistry calculations.
639///
640/// # Arguments
641///
642/// * `geometries` - A slice of geometries to preview
643///
644/// # Output Format
645///
646/// The function prints to stdout with the following information:
647/// - Total number of geometries in the path
648/// - Total path length in Angstroms
649/// - Coordinate details for first, middle, and last geometries
650/// - Element symbols and Cartesian coordinates for each atom
651/// - Truncation indicator if more than 5 atoms per geometry
652///
653/// # Examples
654///
655/// ```rust
656/// use omecp::lst::{interpolate_linear, print_geometry_preview};
657/// use omecp::geometry::Geometry;
658///
659/// let elements = vec!["H".to_string(), "H".to_string()];
660/// let coords1 = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
661/// let coords2 = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
662///
663/// let geom1 = Geometry::new(elements.clone(), coords1);
664/// let geom2 = Geometry::new(elements, coords2);
665///
666/// let path = interpolate_linear(&geom1, &geom2, 3);
667/// print_geometry_preview(&path);
668/// ```
669///
670/// # Sample Output
671///
672/// ```text
673/// ****Geometry Preview****
674/// Total geometries: 5
675/// Path length: 1.000 Angstrom
676///
677/// --- Geometry 1 ---
678///  H    0.000    0.000    0.000
679///  H    1.000    0.000    0.000
680///
681/// --- Geometry 3 ---
682///  H    0.000    0.000    0.000
683///  H    1.500    0.000    0.000
684///
685/// --- Geometry 5 ---
686///  H    0.000    0.000    0.000
687///  H    2.000    0.000    0.000
688/// ```
689///
690/// # Notes
691///
692/// - Only shows first 5 atoms per geometry to keep output manageable
693/// - Coordinates are displayed with 3 decimal places
694/// - Useful for interactive workflows where user confirmation is needed
695/// - Should be called before expensive QM calculations on the path
696pub fn print_geometry_preview(geometries: &[Geometry]) {
697    println!("\n****Geometry Preview****");
698    println!("Total geometries: {}", geometries.len());
699    println!(
700        "Path length: {:.3} Angstrom",
701        calculate_path_length(geometries)
702    );
703
704    // Show first, middle, and last geometries
705    let indices = vec![0, geometries.len() / 2, geometries.len() - 1];
706
707    for &idx in &indices {
708        if idx < geometries.len() {
709            println!("\n--- Geometry {} ---", idx + 1);
710            let geom = &geometries[idx];
711            for i in 0..geom.num_atoms.min(5) {
712                // Show first 5 atoms
713                let coords = geom.get_atom_coords(i);
714                println!(
715                    "{:>2} {:>8.3} {:>8.3} {:>8.3}",
716                    geom.elements[i], coords[0], coords[1], coords[2]
717                );
718            }
719            if geom.num_atoms > 5 {
720                println!("... ({} more atoms)", geom.num_atoms - 5);
721            }
722        }
723    }
724}