#![deny(missing_docs)]
#![allow(clippy::redundant_pub_crate, clippy::suboptimal_flops)]
mod eigen;
mod hessian;
mod network;
mod rtb;
#[cfg(feature = "sparse")]
mod sparse;
use nalgebra::{DMatrix, Rotation3, Unit, Vector3};
use network::Contact;
use rtb::BlockGeometry;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Atom {
pub position: [f64; 3],
pub mass: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct Params {
pub cutoff: f64,
pub gamma: f64,
pub mass_weighted: bool,
pub k_modes: Option<usize>,
}
impl Default for Params {
fn default() -> Self {
Self {
cutoff: 15.0,
gamma: 1.0,
mass_weighted: false,
k_modes: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
TooFewAtoms,
NotFinite,
BlockCountMismatch,
DegenerateBlock,
SparseSolverFailed,
NotRigidBlocks,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooFewAtoms => write!(f, "at least two atoms are required"),
Self::NotFinite => write!(f, "non-finite coordinate or mass"),
Self::BlockCountMismatch => write!(f, "blocks must have one entry per atom"),
Self::DegenerateBlock => write!(f, "a multi-atom block is collinear or coincident"),
Self::SparseSolverFailed => write!(f, "the sparse solver failed"),
Self::NotRigidBlocks => write!(f, "nonlinear modes require with_blocks"),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug)]
pub struct NormalModes {
eigenvalues: Vec<f64>,
modes: Vec<[f64; 3]>,
n_atoms: usize,
disconnected: Vec<usize>,
rtb: Option<Rtb>,
}
#[derive(Debug)]
struct Rtb {
reduced: DMatrix<f64>,
blocks: Vec<BlockGeometry>,
}
const ZERO_EIGENVALUE: f64 = 1e-6;
const BOLTZMANN_KCAL_PER_MOL_K: f64 = 1.987_204_259e-3;
const ROTATION_EPS: f64 = 1e-12;
fn validated_inputs(atoms: &[Atom], params: &Params) -> Result<(Vec<[f64; 3]>, Vec<f64>), Error> {
if atoms.len() < 2 {
return Err(Error::TooFewAtoms);
}
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
if positions.iter().flatten().any(|x| !x.is_finite()) {
return Err(Error::NotFinite);
}
if params.mass_weighted && atoms.iter().any(|a| !(a.mass.is_finite() && a.mass > 0.0)) {
return Err(Error::NotFinite);
}
let weights = if params.mass_weighted {
atoms.iter().map(|a| a.mass).collect()
} else {
vec![1.0; atoms.len()]
};
Ok((positions, weights))
}
fn build_hessian(
n_atoms: usize,
weights: &[f64],
contacts: &[Contact],
params: &Params,
) -> DMatrix<f64> {
let mut h = hessian::build(n_atoms, params.gamma, contacts);
if params.mass_weighted {
hessian::mass_weight(&mut h, weights);
}
h
}
fn lowest_nonzero_columns(eigenvalues: &[f64], k: usize) -> Vec<usize> {
eigenvalues
.iter()
.enumerate()
.filter(|(_, &lambda)| lambda > ZERO_EIGENVALUE)
.map(|(column, _)| column)
.take(k)
.collect()
}
fn pick_columns(m: &DMatrix<f64>, columns: &[usize]) -> DMatrix<f64> {
DMatrix::from_fn(m.nrows(), columns.len(), |r, idx| m[(r, columns[idx])])
}
struct Network {
positions: Vec<[f64; 3]>,
weights: Vec<f64>,
contacts: Vec<Contact>,
keep: Vec<usize>,
disconnected: Vec<usize>,
}
fn prepare(atoms: &[Atom], params: &Params) -> Result<Network, Error> {
let (positions, weights) = validated_inputs(atoms, params)?;
let contacts = network::contacts(&positions, params.cutoff);
let net = drop_disconnected(positions, weights, contacts);
if net.keep.len() < 2 {
return Err(Error::TooFewAtoms);
}
Ok(net)
}
fn drop_disconnected(
positions: Vec<[f64; 3]>,
weights: Vec<f64>,
contacts: Vec<Contact>,
) -> Network {
let n = positions.len();
let disconnected = network::disconnected_atoms(n, &contacts);
if disconnected.is_empty() {
return Network {
positions,
weights,
contacts,
keep: (0..n).collect(),
disconnected,
};
}
let mut dropped = vec![false; n];
for &d in &disconnected {
dropped[d] = true;
}
let mut new_index = vec![usize::MAX; n];
let mut keep = Vec::with_capacity(n - disconnected.len());
for old in 0..n {
if !dropped[old] {
new_index[old] = keep.len();
keep.push(old);
}
}
let contacts = contacts
.into_iter()
.map(|c| Contact {
i: new_index[c.i],
j: new_index[c.j],
delta: c.delta,
dist2: c.dist2,
})
.collect();
Network {
positions: keep.iter().map(|&old| positions[old]).collect(),
weights: keep.iter().map(|&old| weights[old]).collect(),
contacts,
keep,
disconnected,
}
}
impl NormalModes {
pub fn new(atoms: &[Atom], params: &Params) -> Result<Self, Error> {
let net = prepare(atoms, params)?;
#[cfg(feature = "sparse")]
if let Some(k) = params.k_modes {
return Self::solve_partial(&net, params, k, atoms.len());
}
let h = build_hessian(net.keep.len(), &net.weights, &net.contacts, params);
let spectrum = eigen::solve(h);
match params.k_modes {
None => Ok(Self::from_modes(
spectrum.eigenvalues,
&spectrum.eigenvectors,
&net,
atoms.len(),
)),
Some(k) => {
let columns = lowest_nonzero_columns(&spectrum.eigenvalues, k);
let eigenvalues = columns.iter().map(|&c| spectrum.eigenvalues[c]).collect();
let vectors = pick_columns(&spectrum.eigenvectors, &columns);
Ok(Self::from_modes(eigenvalues, &vectors, &net, atoms.len()))
}
}
}
#[cfg(feature = "sparse")]
fn solve_partial(
net: &Network,
params: &Params,
k: usize,
n_original: usize,
) -> Result<Self, Error> {
let (eigenvalues, vectors) = sparse::lowest_nonzero_modes(
net.keep.len(),
params.gamma,
&net.weights,
&net.contacts,
k,
)?;
Ok(Self::from_modes(eigenvalues, &vectors, net, n_original))
}
pub fn with_blocks(atoms: &[Atom], blocks: &[usize], params: &Params) -> Result<Self, Error> {
if blocks.len() != atoms.len() {
return Err(Error::BlockCountMismatch);
}
let net = prepare(atoms, params)?;
let blocks: Vec<usize> = net.keep.iter().map(|&old| blocks[old]).collect();
#[cfg(feature = "sparse")]
if let Some(k) = params.k_modes {
return Self::solve_rtb_partial(&net, &blocks, params, k, atoms.len());
}
let h = build_hessian(net.keep.len(), &net.weights, &net.contacts, params);
let p = rtb::projection(&net.positions, &net.weights, &blocks)?;
let reduced_hessian = p.tr_mul(&(&h * &p));
let spectrum = eigen::solve(reduced_hessian);
let (eigenvalues, reduced) = match params.k_modes {
None => (spectrum.eigenvalues, spectrum.eigenvectors),
Some(k) => {
let columns = lowest_nonzero_columns(&spectrum.eigenvalues, k);
let eigenvalues = columns.iter().map(|&c| spectrum.eigenvalues[c]).collect();
(eigenvalues, pick_columns(&spectrum.eigenvectors, &columns))
}
};
let all_atom = &p * &reduced;
let rtb = Self::build_rtb(&net, &blocks, reduced)?;
let mut modes = Self::from_modes(eigenvalues, &all_atom, &net, atoms.len());
modes.rtb = Some(rtb);
Ok(modes)
}
#[cfg(feature = "sparse")]
fn solve_rtb_partial(
net: &Network,
blocks: &[usize],
params: &Params,
k: usize,
n_original: usize,
) -> Result<Self, Error> {
let (eigenvalues, vectors, reduced) = sparse::lowest_rtb_modes(
&net.positions,
&net.weights,
blocks,
params.gamma,
&net.contacts,
k,
)?;
let rtb = Self::build_rtb(net, blocks, reduced)?;
let mut modes = Self::from_modes(eigenvalues, &vectors, net, n_original);
modes.rtb = Some(rtb);
Ok(modes)
}
fn from_modes(
eigenvalues: Vec<f64>,
vectors: &DMatrix<f64>,
net: &Network,
n_original: usize,
) -> Self {
let mut modes = vec![[0.0; 3]; eigenvalues.len() * n_original];
for (m, col) in vectors.column_iter().enumerate() {
let base = m * n_original;
for (p, &orig) in net.keep.iter().enumerate() {
modes[base + orig] = [col[3 * p], col[3 * p + 1], col[3 * p + 2]];
}
}
Self {
eigenvalues,
modes,
n_atoms: n_original,
disconnected: net.disconnected.clone(),
rtb: None,
}
}
fn build_rtb(net: &Network, blocks: &[usize], reduced: DMatrix<f64>) -> Result<Rtb, Error> {
let mut geometry = rtb::block_geometry(&net.positions, &net.weights, blocks)?;
for block in &mut geometry {
for atom in &mut block.atoms {
*atom = net.keep[*atom];
}
}
Ok(Rtb {
reduced,
blocks: geometry,
})
}
pub const fn len(&self) -> usize {
self.eigenvalues.len()
}
pub const fn is_empty(&self) -> bool {
self.eigenvalues.is_empty()
}
pub fn eigenvalues(&self) -> &[f64] {
&self.eigenvalues
}
pub fn eigenvector(&self, i: usize) -> &[[f64; 3]] {
&self.modes[i * self.n_atoms..(i + 1) * self.n_atoms]
}
pub fn displace(&self, positions: &[[f64; 3]], i: usize, amplitude: f64) -> Vec<[f64; 3]> {
let mode = self.eigenvector(i);
assert_eq!(
positions.len(),
mode.len(),
"positions must have one entry per atom"
);
positions
.iter()
.zip(mode)
.map(|(x, v)| {
[
x[0] + amplitude * v[0],
x[1] + amplitude * v[1],
x[2] + amplitude * v[2],
]
})
.collect()
}
pub fn displace_nonlinear(
&self,
positions: &[[f64; 3]],
i: usize,
amplitude: f64,
) -> Result<Vec<[f64; 3]>, Error> {
let rtb = self.rtb.as_ref().ok_or(Error::NotRigidBlocks)?;
assert!(i < self.len(), "mode index out of range");
assert_eq!(
positions.len(),
self.n_atoms,
"positions must have one entry per atom"
);
let mut out = positions.to_vec();
for block in &rtb.blocks {
let col = block.col;
let velocity = Vector3::new(
rtb.reduced[(col, i)],
rtb.reduced[(col + 1, i)],
rtb.reduced[(col + 2, i)],
);
let translation = amplitude * (velocity / block.total_mass.sqrt());
let rotation = block.isqrt.and_then(|isqrt| {
let omega = isqrt
* Vector3::new(
rtb.reduced[(col + 3, i)],
rtb.reduced[(col + 4, i)],
rtb.reduced[(col + 5, i)],
);
let speed = omega.norm();
(speed > ROTATION_EPS).then(|| {
Rotation3::from_axis_angle(&Unit::new_normalize(omega), amplitude * speed)
})
});
for &atom in &block.atoms {
let position = Vector3::from(positions[atom]);
let moved = rotation.as_ref().map_or_else(
|| position + translation,
|rotation| rotation * (position - block.com) + block.com + translation,
);
out[atom] = [moved.x, moved.y, moved.z];
}
}
Ok(out)
}
pub fn disconnected(&self) -> &[usize] {
&self.disconnected
}
pub fn thermal_amplitudes(&self, temperature_k: f64) -> Vec<f64> {
let two_kt = 2.0 * BOLTZMANN_KCAL_PER_MOL_K * temperature_k;
self.eigenvalues
.iter()
.map(|&lambda| {
if lambda > ZERO_EIGENVALUE {
(two_kt / lambda).sqrt()
} else {
0.0
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn carbon(x: f64, y: f64, z: f64) -> Atom {
Atom {
position: [x, y, z],
mass: 12.0,
}
}
#[test]
fn too_few_atoms_is_rejected() {
let r = NormalModes::new(&[carbon(0.0, 0.0, 0.0)], &Params::default());
assert!(matches!(r, Err(Error::TooFewAtoms)));
}
#[test]
fn non_finite_coordinate_is_rejected() {
let atoms = [carbon(0.0, 0.0, 0.0), carbon(f64::NAN, 0.0, 0.0)];
let r = NormalModes::new(&atoms, &Params::default());
assert!(matches!(r, Err(Error::NotFinite)));
}
#[test]
fn six_zero_modes_for_a_3d_cluster() {
let atoms = [
carbon(0.0, 0.0, 0.0),
carbon(1.5, 0.0, 0.0),
carbon(0.0, 1.5, 0.0),
carbon(0.0, 0.0, 1.5),
carbon(1.0, 1.0, 1.0),
];
let modes = NormalModes::new(&atoms, &Params::default()).unwrap();
let zeros = modes
.eigenvalues()
.iter()
.filter(|&&v| v.abs() < ZERO_EIGENVALUE)
.count();
assert_eq!(zeros, 6);
assert!(modes.eigenvalues()[6] > ZERO_EIGENVALUE);
}
#[test]
fn thermal_amplitudes_align_and_zero_out_rigid_modes() {
let atoms = [
carbon(0.0, 0.0, 0.0),
carbon(1.5, 0.0, 0.0),
carbon(0.0, 1.5, 0.0),
carbon(1.0, 1.0, 1.0),
];
let modes = NormalModes::new(&atoms, &Params::default()).unwrap();
let amps = modes.thermal_amplitudes(300.0);
assert_eq!(amps.len(), modes.len());
for &a in &s[..6] {
assert_relative_eq!(a, 0.0);
}
assert!(amps[6] >= amps[7]);
}
#[test]
fn mass_weighting_reproduces_diatomic_reduced_mass() {
let atoms = [
Atom {
position: [0.0, 0.0, 0.0],
mass: 12.0,
},
Atom {
position: [2.0, 0.0, 0.0],
mass: 16.0,
},
];
let params = Params {
cutoff: 5.0,
gamma: 1.0,
mass_weighted: true,
k_modes: None,
};
let modes = NormalModes::new(&atoms, ¶ms).unwrap();
let nonzero = modes
.eigenvalues()
.iter()
.filter(|&&v| v.abs() > ZERO_EIGENVALUE)
.count();
assert_eq!(nonzero, 1);
assert_relative_eq!(
*modes.eigenvalues().last().unwrap(),
1.0 / 12.0 + 1.0 / 16.0,
epsilon = 1e-10
);
}
#[test]
fn equal_mass_weighting_scales_spectrum() {
let m = 4.0;
let atoms = [
Atom {
position: [0.0, 0.0, 0.0],
mass: m,
},
Atom {
position: [1.5, 0.0, 0.0],
mass: m,
},
Atom {
position: [0.0, 1.5, 0.0],
mass: m,
},
Atom {
position: [1.0, 1.0, 1.0],
mass: m,
},
];
let base = Params {
cutoff: 5.0,
gamma: 1.0,
mass_weighted: false,
k_modes: None,
};
let weighted = Params {
mass_weighted: true,
..base
};
let unit = NormalModes::new(&atoms, &base).unwrap();
let scaled = NormalModes::new(&atoms, &weighted).unwrap();
for k in 0..unit.len() {
assert_relative_eq!(
scaled.eigenvalues()[k],
unit.eigenvalues()[k] / m,
epsilon = 1e-9
);
}
}
fn cluster6() -> Vec<Atom> {
[
(0.0, 0.0, 0.0),
(1.2, 0.0, 0.0),
(0.0, 1.2, 0.0),
(0.0, 0.0, 1.2),
(1.2, 1.2, 0.0),
(1.0, 0.5, 1.0),
]
.iter()
.map(|&(x, y, z)| carbon(x, y, z))
.collect()
}
fn rtb_params() -> Params {
Params {
cutoff: 5.0,
..Params::default()
}
}
#[test]
fn all_singleton_blocks_match_plain_anm() {
let atoms = cluster6();
let blocks: Vec<usize> = (0..atoms.len()).collect();
let plain = NormalModes::new(&atoms, &rtb_params()).unwrap();
let rtb = NormalModes::with_blocks(&atoms, &blocks, &rtb_params()).unwrap();
assert_eq!(rtb.len(), plain.len());
for (a, b) in rtb.eigenvalues().iter().zip(plain.eigenvalues()) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
#[test]
fn block_id_values_are_remapped() {
let atoms = cluster6();
let a = NormalModes::with_blocks(&atoms, &[0, 0, 0, 1, 1, 1], &rtb_params()).unwrap();
let b = NormalModes::with_blocks(&atoms, &[42, 42, 42, 7, 7, 7], &rtb_params()).unwrap();
for (x, y) in a.eigenvalues().iter().zip(b.eigenvalues()) {
assert_relative_eq!(x, y, epsilon = 1e-12);
}
}
#[test]
fn interleaved_blocks_are_grouped_by_id() {
let atoms = cluster6();
let modes = NormalModes::with_blocks(&atoms, &[0, 1, 0, 1, 0, 1], &rtb_params()).unwrap();
assert_eq!(modes.len(), 12); let zeros = modes
.eigenvalues()
.iter()
.filter(|&&v| v.abs() < 1e-6)
.count();
assert_eq!(zeros, 6);
}
#[test]
fn whole_structure_is_one_rigid_block() {
let atoms = cluster6();
let modes = NormalModes::with_blocks(&atoms, &[0; 6], &rtb_params()).unwrap();
assert_eq!(modes.len(), 6);
for &v in modes.eigenvalues() {
assert!(v.abs() < 1e-6);
}
}
#[test]
fn dof_accounting_mixes_block_sizes() {
let atoms = &cluster6()[..4];
let modes = NormalModes::with_blocks(atoms, &[0, 0, 0, 1], &rtb_params()).unwrap();
assert_eq!(modes.len(), 9);
}
#[test]
fn lifted_modes_are_unit_norm() {
let atoms = cluster6();
let modes = NormalModes::with_blocks(&atoms, &[0, 0, 0, 1, 1, 1], &rtb_params()).unwrap();
for i in 0..modes.len() {
let norm2: f64 = modes.eigenvector(i).iter().flatten().map(|x| x * x).sum();
assert_relative_eq!(norm2, 1.0, epsilon = 1e-9);
}
}
#[test]
fn block_count_must_match_atoms() {
let atoms = cluster6();
let r = NormalModes::with_blocks(&atoms, &[0, 0], &rtb_params());
assert!(matches!(r, Err(Error::BlockCountMismatch)));
}
#[test]
fn collinear_block_is_degenerate() {
let atoms = cluster6();
let r = NormalModes::with_blocks(&atoms, &[0, 0, 1, 1, 1, 2], &rtb_params());
assert!(matches!(r, Err(Error::DegenerateBlock)));
}
#[test]
fn isolated_atom_is_dropped() {
let mut atoms = cluster6();
atoms.push(carbon(100.0, 100.0, 100.0)); let modes = NormalModes::new(&atoms, &rtb_params()).unwrap();
assert_eq!(modes.disconnected(), &[6]);
assert_eq!(modes.len(), 18); for i in 0..modes.len() {
assert_eq!(modes.eigenvector(i)[6], [0.0, 0.0, 0.0]);
}
let zeros = modes
.eigenvalues()
.iter()
.filter(|&&v| v.abs() < ZERO_EIGENVALUE)
.count();
assert_eq!(zeros, 6);
let reference = NormalModes::new(&cluster6(), &rtb_params()).unwrap();
for (a, b) in modes.eigenvalues().iter().zip(reference.eigenvalues()) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
#[test]
fn isolated_atom_is_dropped_with_blocks() {
let mut atoms = cluster6();
atoms.push(carbon(100.0, 100.0, 100.0));
let modes =
NormalModes::with_blocks(&atoms, &[0, 0, 0, 1, 1, 1, 2], &rtb_params()).unwrap();
assert_eq!(modes.disconnected(), &[6]);
assert_eq!(modes.len(), 12); for i in 0..modes.len() {
assert_eq!(modes.eigenvector(i)[6], [0.0, 0.0, 0.0]);
}
let reference =
NormalModes::with_blocks(&cluster6(), &[0, 0, 0, 1, 1, 1], &rtb_params()).unwrap();
for (a, b) in modes.eigenvalues().iter().zip(reference.eigenvalues()) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
#[test]
fn all_atoms_disconnected_is_too_few() {
let atoms = [carbon(0.0, 0.0, 0.0), carbon(100.0, 0.0, 0.0)];
assert!(matches!(
NormalModes::new(&atoms, &rtb_params()),
Err(Error::TooFewAtoms)
));
}
#[test]
fn displace_shifts_atoms_along_the_mode() {
let atoms = cluster6();
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let modes = NormalModes::new(&atoms, &rtb_params()).unwrap();
assert_eq!(modes.displace(&positions, 6, 0.0), positions);
let (i, a) = (6, 0.5); let moved = modes.displace(&positions, i, a);
let mode = modes.eigenvector(i);
for (got, (orig, v)) in moved.iter().zip(positions.iter().zip(mode)) {
for c in 0..3 {
assert_relative_eq!(got[c], orig[c] + a * v[c], epsilon = 1e-12);
}
}
assert!(moved.iter().zip(&positions).any(|(g, o)| g != o));
}
#[test]
fn displace_leaves_disconnected_atom_fixed() {
let mut atoms = cluster6();
atoms.push(carbon(100.0, 100.0, 100.0));
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let modes = NormalModes::new(&atoms, &rtb_params()).unwrap();
let moved = modes.displace(&positions, 6, 5.0);
assert_eq!(moved[6], positions[6]);
}
fn distance(p: &[[f64; 3]], a: usize, b: usize) -> f64 {
((p[a][0] - p[b][0]).powi(2) + (p[a][1] - p[b][1]).powi(2) + (p[a][2] - p[b][2]).powi(2))
.sqrt()
}
#[test]
fn nonlinear_preserves_intra_block_distances() {
let atoms = cluster6();
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let modes = NormalModes::with_blocks(&atoms, &[0, 0, 0, 1, 1, 1], &rtb_params()).unwrap();
let mut saw_rotation = false;
for i in 6..modes.len() {
let moved = modes.displace_nonlinear(&positions, i, 3.0).unwrap();
for group in [[0, 1, 2], [3, 4, 5]] {
for &a in &group {
for &b in &group {
assert_relative_eq!(
distance(&moved, a, b),
distance(&positions, a, b),
epsilon = 1e-9
);
}
}
}
let linear = modes.displace(&positions, i, 3.0);
if moved
.iter()
.zip(&linear)
.any(|(m, l)| (m[0] - l[0]).abs() > 1e-6)
{
saw_rotation = true;
}
}
assert!(saw_rotation, "expected at least one mode to rotate a block");
}
#[test]
fn nonlinear_singleton_blocks_equal_linear() {
let atoms = cluster6();
let blocks: Vec<usize> = (0..atoms.len()).collect();
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let modes = NormalModes::with_blocks(&atoms, &blocks, &rtb_params()).unwrap();
for i in 6..modes.len() {
let nonlinear = modes.displace_nonlinear(&positions, i, 1.5).unwrap();
let linear = modes.displace(&positions, i, 1.5);
for (a, b) in nonlinear.iter().zip(&linear) {
for c in 0..3 {
assert_relative_eq!(a[c], b[c], epsilon = 1e-9);
}
}
}
}
#[test]
fn nonlinear_small_amplitude_is_physical_mode() {
let atoms = vec![
Atom {
position: [0.0, 0.0, 0.0],
mass: 12.0,
},
Atom {
position: [1.2, 0.0, 0.0],
mass: 14.0,
},
Atom {
position: [0.0, 1.2, 0.0],
mass: 16.0,
},
Atom {
position: [3.0, 0.0, 1.0],
mass: 12.0,
},
Atom {
position: [4.2, 0.2, 0.5],
mass: 32.0,
},
Atom {
position: [3.0, 1.2, 1.5],
mass: 14.0,
},
];
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let params = Params {
cutoff: 5.0,
mass_weighted: true,
..Params::default()
};
let modes = NormalModes::with_blocks(&atoms, &[0, 0, 0, 1, 1, 1], ¶ms).unwrap();
let a = 1e-6;
for i in 6..modes.len() {
let nonlinear = modes.displace_nonlinear(&positions, i, a).unwrap();
let lifted = modes.displace(&positions, i, a);
for (k, atom) in atoms.iter().enumerate() {
let sqrt_m = atom.mass.sqrt();
for c in 0..3 {
let physical = (nonlinear[k][c] - positions[k][c]) * sqrt_m;
let lifted_c = lifted[k][c] - positions[k][c];
assert_relative_eq!(physical, lifted_c, epsilon = 1e-12, max_relative = 1e-5);
}
}
}
}
#[test]
fn nonlinear_requires_blocks() {
let atoms = cluster6();
let positions: Vec<[f64; 3]> = atoms.iter().map(|a| a.position).collect();
let modes = NormalModes::new(&atoms, &rtb_params()).unwrap();
assert!(matches!(
modes.displace_nonlinear(&positions, 6, 1.0),
Err(Error::NotRigidBlocks)
));
}
#[cfg(not(feature = "sparse"))]
#[test]
fn k_modes_falls_back_to_dense() {
let atoms = cluster6();
let mut params = rtb_params();
params.k_modes = Some(2);
let lowest_two = |full: &NormalModes| -> Vec<f64> {
full.eigenvalues()
.iter()
.filter(|&&v| v > ZERO_EIGENVALUE)
.take(2)
.copied()
.collect()
};
let plain = NormalModes::new(&atoms, ¶ms).unwrap();
assert_eq!(plain.len(), 2);
let plain_full = NormalModes::new(&atoms, &rtb_params()).unwrap();
for (got, want) in plain.eigenvalues().iter().zip(lowest_two(&plain_full)) {
assert_relative_eq!(got, &want, epsilon = 1e-9);
}
let blocks = [0, 0, 0, 1, 1, 1];
let rtb = NormalModes::with_blocks(&atoms, &blocks, ¶ms).unwrap();
assert_eq!(rtb.len(), 2);
let rtb_full = NormalModes::with_blocks(&atoms, &blocks, &rtb_params()).unwrap();
for (got, want) in rtb.eigenvalues().iter().zip(lowest_two(&rtb_full)) {
assert_relative_eq!(got, &want, epsilon = 1e-9);
}
}
}