use crate::error::GeomError;
use crate::math::Vec3;
use crate::monte_carlo::Rng;
use crate::statistics::inference::{ks_test_one_sample, TestResult};
use std::sync::Arc;
#[derive(Clone)]
pub enum Potential {
LennardJones {
eps: f64,
sigma: f64,
},
Morse {
d: f64,
a: f64,
r0: f64,
},
Coulomb {
ke: f64,
},
LjCoulomb {
eps: f64,
sigma: f64,
ke: f64,
},
Harmonic {
k: f64,
r0: f64,
},
Custom(Arc<dyn Fn(f64) -> (f64, f64) + Send + Sync>),
}
impl std::fmt::Debug for Potential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LennardJones { eps, sigma } => {
write!(f, "LennardJones {{ eps: {eps}, sigma: {sigma} }}")
}
Self::Morse { d, a, r0 } => write!(f, "Morse {{ d: {d}, a: {a}, r0: {r0} }}"),
Self::Coulomb { ke } => write!(f, "Coulomb {{ ke: {ke} }}"),
Self::LjCoulomb { eps, sigma, ke } => {
write!(f, "LjCoulomb {{ eps: {eps}, sigma: {sigma}, ke: {ke} }}")
}
Self::Harmonic { k, r0 } => write!(f, "Harmonic {{ k: {k}, r0: {r0} }}"),
Self::Custom(_) => write!(f, "Custom(..)"),
}
}
}
impl Potential {
#[must_use]
pub fn evaluate(&self, r: f64, qi: f64, qj: f64) -> (f64, f64) {
match *self {
Self::LennardJones { eps, sigma } => lj_pair(r, eps, sigma),
Self::Morse { d, a, r0 } => {
let e = (-a * (r - r0)).exp();
let energy = d * (1.0 - e) * (1.0 - e) - d;
(energy, -2.0 * d * a * e * (1.0 - e))
}
Self::Coulomb { ke } => {
let energy = ke * qi * qj / r;
(energy, energy / r)
}
Self::LjCoulomb { eps, sigma, ke } => {
let (u, f) = lj_pair(r, eps, sigma);
let coulomb = ke * qi * qj / r;
(u + coulomb, f + coulomb / r)
}
Self::Harmonic { k, r0 } => (0.5 * k * (r - r0) * (r - r0), -k * (r - r0)),
Self::Custom(ref law) => law(r),
}
}
#[must_use]
pub fn is_charged(&self) -> bool {
matches!(self, Self::Coulomb { .. } | Self::LjCoulomb { .. })
}
}
fn lj_pair(r: f64, eps: f64, sigma: f64) -> (f64, f64) {
let sr = sigma / r;
let sr6 = sr.powi(6);
let sr12 = sr6 * sr6;
(4.0 * eps * (sr12 - sr6), 24.0 * eps * (2.0 * sr12 - sr6) / r)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MdSample {
pub time: f64,
pub kinetic: f64,
pub potential: f64,
pub total: f64,
pub temperature: f64,
pub pressure: f64,
}
#[derive(Clone, Debug)]
pub struct MdSystem {
pub pos: Vec<Vec3>,
pub unwrapped: Vec<Vec3>,
pub vel: Vec<Vec3>,
pub mass: Vec<f64>,
pub charge: Vec<f64>,
pub box_size: Vec3,
pub periodic: bool,
pub potential: Potential,
pub cutoff: f64,
pub time: f64,
pub nose_hoover_zeta: f64,
forces: Vec<Vec3>,
}
impl MdSystem {
pub fn new(
pos: Vec<Vec3>,
vel: Vec<Vec3>,
mass: Vec<f64>,
box_size: Vec3,
periodic: bool,
potential: Potential,
cutoff: f64,
) -> Result<Self, GeomError> {
if pos.is_empty() || pos.len() != vel.len() || pos.len() != mass.len() {
return Err(GeomError::InvalidArgument("MdSystem: mismatched state"));
}
if mass.iter().any(|m| !(*m > 0.0)) {
return Err(GeomError::InvalidArgument("every mass must be positive"));
}
if !(cutoff > 0.0) {
return Err(GeomError::InvalidArgument("the cutoff must be positive"));
}
let shortest = box_size.x.min(box_size.y).min(box_size.z);
if !(shortest > 0.0) {
return Err(GeomError::InvalidArgument("every box edge must be positive"));
}
if periodic && cutoff > 0.5 * shortest {
return Err(GeomError::InvalidArgument(
"the cutoff exceeds half the shortest box edge",
));
}
let charge = vec![0.0; pos.len()];
let mut system = Self {
unwrapped: pos.clone(),
pos,
vel,
mass,
charge,
box_size,
periodic,
potential,
cutoff,
time: 0.0,
nose_hoover_zeta: 0.0,
forces: Vec::new(),
};
if system.periodic {
for k in 0..system.pos.len() {
system.pos[k] = system.wrap(system.pos[k]);
}
}
system.forces = system.compute_forces().0;
Ok(system)
}
pub fn lattice_fcc(
cells: usize,
density: f64,
temperature: f64,
eps: f64,
sigma: f64,
rng: &mut Rng,
) -> Result<Self, GeomError> {
if cells == 0 || cells > 32 {
return Err(GeomError::InvalidArgument("lattice_fcc handles 1 to 32 cells"));
}
if !(density > 0.0) || temperature < 0.0 || !(eps > 0.0) || !(sigma > 0.0) {
return Err(GeomError::InvalidArgument("lattice_fcc: bad parameters"));
}
let count = 4 * cells * cells * cells;
let length = (count as f64 / density).cbrt();
let a = length / cells as f64;
const BASIS: [(f64, f64, f64); 4] =
[(0.0, 0.0, 0.0), (0.5, 0.5, 0.0), (0.5, 0.0, 0.5), (0.0, 0.5, 0.5)];
let mut pos = Vec::with_capacity(count);
for i in 0..cells {
for j in 0..cells {
for k in 0..cells {
for (dx, dy, dz) in BASIS {
pos.push(Vec3::new(
(i as f64 + dx) * a,
(j as f64 + dy) * a,
(k as f64 + dz) * a,
));
}
}
}
}
let vel: Vec<Vec3> = (0..count)
.map(|_| {
let s = temperature.sqrt();
Vec3::new(
rng.next_gaussian() * s,
rng.next_gaussian() * s,
rng.next_gaussian() * s,
)
})
.collect();
let box_size = Vec3::new(length, length, length);
let cutoff = (2.5 * sigma).min(0.5 * length - 1e-9);
let mut system = Self::new(
pos,
vel,
vec![1.0; count],
box_size,
true,
Potential::LennardJones { eps, sigma },
cutoff,
)?;
system.remove_drift();
if temperature > 0.0 {
system.rescale_to_temperature(temperature);
}
Ok(system)
}
#[must_use]
pub fn len(&self) -> usize {
self.pos.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.pos.is_empty()
}
#[must_use]
pub fn volume(&self) -> f64 {
self.box_size.x * self.box_size.y * self.box_size.z
}
#[must_use]
pub fn wrap(&self, p: Vec3) -> Vec3 {
Vec3::new(
p.x.rem_euclid(self.box_size.x),
p.y.rem_euclid(self.box_size.y),
p.z.rem_euclid(self.box_size.z),
)
}
#[must_use]
pub fn minimum_image(&self, d: Vec3) -> Vec3 {
if !self.periodic {
return d;
}
let fold = |x: f64, l: f64| x - l * (x / l).round();
Vec3::new(fold(d.x, self.box_size.x), fold(d.y, self.box_size.y), fold(d.z, self.box_size.z))
}
pub fn remove_drift(&mut self) {
let total_mass: f64 = self.mass.iter().sum();
let momentum = self.total_momentum();
let correction = momentum * (1.0 / total_mass);
for v in &mut self.vel {
*v = *v - correction;
}
}
pub fn rescale_to_temperature(&mut self, target: f64) {
let current = self.temperature();
if current <= 0.0 || target < 0.0 {
return;
}
let factor = (target / current).sqrt();
for v in &mut self.vel {
*v = *v * factor;
}
}
}
impl MdSystem {
fn cell_counts(&self) -> Option<(usize, usize, usize)> {
if !self.periodic {
return None;
}
let n = |l: f64| ((l / self.cutoff).floor() as usize).max(1);
let (mut nx, mut ny, mut nz) = (n(self.box_size.x), n(self.box_size.y), n(self.box_size.z));
let budget = 8u128 * self.pos.len().max(1) as u128;
let product = |a: usize, b: usize, c: usize| a as u128 * b as u128 * c as u128;
while product(nx, ny, nz) > budget {
let largest = nx.max(ny).max(nz);
if largest <= 1 {
break;
}
if nx == largest {
nx = nx.div_ceil(2);
} else if ny == largest {
ny = ny.div_ceil(2);
} else {
nz = nz.div_ceil(2);
}
}
if nx >= 3 && ny >= 3 && nz >= 3 {
Some((nx, ny, nz))
} else {
None
}
}
fn for_each_pair(&self, mut visit: impl FnMut(usize, usize, Vec3, f64)) {
let rc2 = self.cutoff * self.cutoff;
let Some((nx, ny, nz)) = self.cell_counts() else {
for i in 0..self.pos.len() {
for j in (i + 1)..self.pos.len() {
let d = self.minimum_image(self.pos[i] - self.pos[j]);
let r2 = d.magnitude_squared();
if r2 < rc2 && r2 > 0.0 {
visit(i, j, d, r2);
}
}
}
return;
};
let index = |x: usize, y: usize, z: usize| (x * ny + y) * nz + z;
let mut cells = vec![Vec::new(); nx * ny * nz];
let of = |p: Vec3| {
let c = |v: f64, l: f64, n: usize| {
((v / l * n as f64).floor() as isize).rem_euclid(n as isize) as usize
};
(
c(p.x, self.box_size.x, nx),
c(p.y, self.box_size.y, ny),
c(p.z, self.box_size.z, nz),
)
};
for (k, p) in self.pos.iter().enumerate() {
let (x, y, z) = of(*p);
cells[index(x, y, z)].push(k);
}
const FORWARD: [(isize, isize, isize); 13] = [
(1, 0, 0),
(0, 1, 0),
(1, 1, 0),
(-1, 1, 0),
(0, 0, 1),
(1, 0, 1),
(-1, 0, 1),
(0, 1, 1),
(0, -1, 1),
(1, 1, 1),
(1, -1, 1),
(-1, 1, 1),
(-1, -1, 1),
];
let consider = |i: usize, j: usize, visit: &mut dyn FnMut(usize, usize, Vec3, f64)| {
let d = self.minimum_image(self.pos[i] - self.pos[j]);
let r2 = d.magnitude_squared();
if r2 < rc2 && r2 > 0.0 {
visit(i, j, d, r2);
}
};
for x in 0..nx {
for y in 0..ny {
for z in 0..nz {
let here = &cells[index(x, y, z)];
for a in 0..here.len() {
for b in (a + 1)..here.len() {
consider(here[a], here[b], &mut visit);
}
}
for (dx, dy, dz) in FORWARD {
let ox = (x as isize + dx).rem_euclid(nx as isize) as usize;
let oy = (y as isize + dy).rem_euclid(ny as isize) as usize;
let oz = (z as isize + dz).rem_euclid(nz as isize) as usize;
for &i in here {
for &j in &cells[index(ox, oy, oz)] {
consider(i, j, &mut visit);
}
}
}
}
}
}
}
fn compute_forces(&self) -> (Vec<Vec3>, f64, f64) {
let shift = if self.potential.is_charged() {
0.0
} else {
self.potential.evaluate(self.cutoff, 0.0, 0.0).0
};
let mut forces = vec![Vec3::new(0.0, 0.0, 0.0); self.pos.len()];
let mut energy = 0.0;
let mut virial = 0.0;
self.for_each_pair(|i, j, d, r2| {
let r = r2.sqrt();
let (u, f) = self.potential.evaluate(r, self.charge[i], self.charge[j]);
energy += u - shift;
virial += f * r;
let force = d * (f / r);
forces[i] = forces[i] + force;
forces[j] = forces[j] - force;
});
(forces, energy, virial)
}
#[must_use]
pub fn forces(&self) -> Vec<Vec3> {
self.compute_forces().0
}
pub fn refresh_forces(&mut self) {
self.forces = self.compute_forces().0;
}
#[must_use]
pub fn potential_energy(&self) -> f64 {
self.compute_forces().1
}
#[must_use]
pub fn kinetic_energy(&self) -> f64 {
self.vel
.iter()
.zip(&self.mass)
.map(|(v, m)| 0.5 * m * v.magnitude_squared())
.sum()
}
#[must_use]
pub fn total_momentum(&self) -> Vec3 {
self.vel
.iter()
.zip(&self.mass)
.fold(Vec3::new(0.0, 0.0, 0.0), |acc, (v, m)| acc + *v * *m)
}
#[must_use]
pub fn degrees_of_freedom(&self) -> f64 {
if self.periodic && self.pos.len() > 1 {
3.0 * self.pos.len() as f64 - 3.0
} else {
3.0 * self.pos.len() as f64
}
}
#[must_use]
pub fn temperature(&self) -> f64 {
2.0 * self.kinetic_energy() / self.degrees_of_freedom()
}
#[must_use]
pub fn pressure_virial(&self) -> f64 {
let (_, _, virial) = self.compute_forces();
let kinetic = 2.0 * self.kinetic_energy() / 3.0;
(kinetic + virial / 3.0) / self.volume()
}
#[must_use]
pub fn sample(&self) -> MdSample {
let (_, energy, virial) = self.compute_forces();
let kinetic = self.kinetic_energy();
MdSample {
time: self.time,
kinetic,
potential: energy,
total: kinetic + energy,
temperature: 2.0 * kinetic / self.degrees_of_freedom(),
pressure: (2.0 * kinetic / 3.0 + virial / 3.0) / self.volume(),
}
}
}
impl MdSystem {
pub fn step_velocity_verlet(&mut self, dt: f64) {
let half = 0.5 * dt;
for k in 0..self.pos.len() {
let a = self.forces[k] * (1.0 / self.mass[k]);
self.vel[k] = self.vel[k] + a * half;
let step = self.vel[k] * dt;
self.unwrapped[k] = self.unwrapped[k] + step;
self.pos[k] = self.pos[k] + step;
if self.periodic {
self.pos[k] = self.wrap(self.pos[k]);
}
}
self.forces = self.compute_forces().0;
for k in 0..self.pos.len() {
let a = self.forces[k] * (1.0 / self.mass[k]);
self.vel[k] = self.vel[k] + a * half;
}
self.time += dt;
}
pub fn thermostat_berendsen(&mut self, t_target: f64, tau: f64, dt: f64) {
let current = self.temperature();
if current <= 0.0 || tau <= 0.0 {
return;
}
let factor = (1.0 + dt / tau * (t_target / current - 1.0)).max(0.0).sqrt();
for v in &mut self.vel {
*v = *v * factor;
}
}
pub fn thermostat_nose_hoover(&mut self, t_target: f64, q: f64, dt: f64) {
if !(q > 0.0) {
return;
}
let dof = self.degrees_of_freedom();
let kinetic = self.kinetic_energy();
let acceleration = (2.0 * kinetic - dof * t_target) / q;
self.nose_hoover_zeta += acceleration * dt;
let factor = (-self.nose_hoover_zeta * dt).exp();
for v in &mut self.vel {
*v = *v * factor;
}
}
pub fn thermostat_langevin(&mut self, t_target: f64, gamma: f64, dt: f64, rng: &mut Rng) {
if gamma < 0.0 || t_target < 0.0 {
return;
}
let decay = (-gamma * dt).exp();
for k in 0..self.vel.len() {
let amplitude = (t_target / self.mass[k] * (1.0 - decay * decay)).max(0.0).sqrt();
self.vel[k] = self.vel[k] * decay
+ Vec3::new(
rng.next_gaussian() * amplitude,
rng.next_gaussian() * amplitude,
rng.next_gaussian() * amplitude,
);
}
}
pub fn barostat_berendsen(
&mut self,
p_target: f64,
compressibility: f64,
tau: f64,
dt: f64,
) -> Result<(), GeomError> {
if !(tau > 0.0) || !(compressibility > 0.0) {
return Err(GeomError::InvalidArgument("barostat_berendsen: bad parameters"));
}
let pressure = self.pressure_virial();
let mu = (1.0 - compressibility * dt / tau * (p_target - pressure)).max(0.0).cbrt();
let scaled = self.box_size * mu;
let shortest = scaled.x.min(scaled.y).min(scaled.z);
if self.periodic && self.cutoff > 0.5 * shortest {
return Err(GeomError::Degenerate("the barostat shrank the box below the cutoff"));
}
self.box_size = scaled;
for k in 0..self.pos.len() {
self.pos[k] = self.pos[k] * mu;
self.unwrapped[k] = self.unwrapped[k] * mu;
}
self.forces = self.compute_forces().0;
Ok(())
}
pub fn equilibrate(
&mut self,
steps: usize,
dt: f64,
t_target: f64,
rng: &mut Rng,
) -> Result<(), GeomError> {
if !(dt > 0.0) || t_target < 0.0 {
return Err(GeomError::InvalidArgument("equilibrate: bad parameters"));
}
for _ in 0..steps {
self.step_velocity_verlet(dt);
self.thermostat_langevin(t_target, 1.0, dt, rng);
}
self.remove_drift();
Ok(())
}
pub fn run_nve(&mut self, steps: usize, dt: f64) -> Result<Vec<MdSample>, GeomError> {
if !(dt > 0.0) || steps == 0 {
return Err(GeomError::InvalidArgument("run_nve: bad parameters"));
}
let mut out = Vec::with_capacity(steps + 1);
out.push(self.sample());
for _ in 0..steps {
self.step_velocity_verlet(dt);
out.push(self.sample());
}
Ok(out)
}
pub fn run_trajectory(
&mut self,
steps: usize,
dt: f64,
stride: usize,
) -> Result<(Vec<Vec<Vec3>>, Vec<Vec<Vec3>>), GeomError> {
if !(dt > 0.0) || steps == 0 || stride == 0 {
return Err(GeomError::InvalidArgument("run_trajectory: bad parameters"));
}
let mut positions = Vec::new();
let mut velocities = Vec::new();
for step in 0..=steps {
if step % stride == 0 {
positions.push(self.unwrapped.clone());
velocities.push(self.vel.clone());
}
if step < steps {
self.step_velocity_verlet(dt);
}
}
Ok((positions, velocities))
}
}
pub fn energy_drift(samples: &[MdSample]) -> Result<f64, GeomError> {
if samples.len() < 3 {
return Err(GeomError::InvalidArgument("energy_drift needs three samples"));
}
let n = samples.len() as f64;
let sx: f64 = samples.iter().map(|s| s.time).sum();
let sy: f64 = samples.iter().map(|s| s.total).sum();
let sxx: f64 = samples.iter().map(|s| s.time * s.time).sum();
let sxy: f64 = samples.iter().map(|s| s.time * s.total).sum();
let denominator = n * sxx - sx * sx;
if denominator.abs() < 1e-300 {
return Err(GeomError::Degenerate("the samples share one time"));
}
let slope = (n * sxy - sx * sy) / denominator;
let span = samples[samples.len() - 1].time - samples[0].time;
let scale = (sy / n).abs().max(1e-300);
Ok((slope * span / scale).abs())
}
impl MdSystem {
pub fn rdf(&self, bins: usize, r_max: f64) -> Result<Vec<f64>, GeomError> {
if bins == 0 || !(r_max > 0.0) {
return Err(GeomError::InvalidArgument("rdf: bad parameters"));
}
let shortest = self.box_size.x.min(self.box_size.y).min(self.box_size.z);
if self.periodic && r_max > 0.5 * shortest {
return Err(GeomError::InvalidArgument("the range exceeds half the box"));
}
let width = r_max / bins as f64;
let mut counts = vec![0.0f64; bins];
let n = self.pos.len();
for i in 0..n {
for j in (i + 1)..n {
let d = self.minimum_image(self.pos[i] - self.pos[j]);
let r = d.magnitude();
if r < r_max {
counts[(r / width) as usize] += 2.0;
}
}
}
let density = n as f64 / self.volume();
Ok(counts
.into_iter()
.enumerate()
.map(|(k, c)| {
let lo = k as f64 * width;
let hi = lo + width;
let shell = 4.0 / 3.0 * std::f64::consts::PI * (hi * hi * hi - lo * lo * lo);
c / (n as f64 * density * shell)
})
.collect())
}
pub fn structure_factor(&self, k_values: &[f64]) -> Result<Vec<f64>, GeomError> {
if k_values.iter().any(|k| !(*k > 0.0)) {
return Err(GeomError::InvalidArgument("every wavenumber must be positive"));
}
let n = self.pos.len();
let mut distances = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
distances.push(self.minimum_image(self.pos[i] - self.pos[j]).magnitude());
}
}
Ok(k_values
.iter()
.map(|k| {
let sum: f64 = distances
.iter()
.map(|r| {
let x = k * r;
if x.abs() < 1e-12 {
1.0
} else {
x.sin() / x
}
})
.sum();
1.0 + 2.0 * sum / n as f64
})
.collect())
}
pub fn maxwell_boltzmann_check(&self) -> Result<TestResult, GeomError> {
if self.pos.len() < 5 {
return Err(GeomError::InvalidArgument("too few particles to test"));
}
let m0 = self.mass[0];
if self.mass.iter().any(|m| (m - m0).abs() > 1e-12 * m0) {
return Err(GeomError::InvalidArgument("the masses are not all equal"));
}
let temperature = self.temperature();
if !(temperature > 0.0) {
return Err(GeomError::Degenerate("the system is at zero temperature"));
}
let a = (temperature / m0).sqrt();
let speeds: Vec<f64> = self.vel.iter().map(Vec3::magnitude).collect();
let cdf = move |v: f64| -> f64 {
if v <= 0.0 {
return 0.0;
}
let x = v / a;
crate::special::erf::erf(x / std::f64::consts::SQRT_2)
- (2.0 / std::f64::consts::PI).sqrt() * x * (-0.5 * x * x).exp()
};
Ok(ks_test_one_sample(&speeds, &cdf))
}
pub fn melting_indicator_lindemann(&self, traj: &[Vec<Vec3>]) -> Result<f64, GeomError> {
if traj.len() < 2 {
return Err(GeomError::InvalidArgument("the trajectory is too short"));
}
let n = self.pos.len();
if traj.iter().any(|frame| frame.len() != n) {
return Err(GeomError::InvalidArgument("the frames differ in length"));
}
let frames = traj.len() as f64;
let mut total = 0.0;
for i in 0..n {
let mean = traj
.iter()
.fold(Vec3::new(0.0, 0.0, 0.0), |acc, frame| acc + frame[i])
* (1.0 / frames);
let spread: f64 =
traj.iter().map(|frame| (frame[i] - mean).magnitude_squared()).sum::<f64>() / frames;
total += spread;
}
let rms = (total / n as f64).sqrt();
let mut nearest = f64::INFINITY;
for i in 0..n {
for j in (i + 1)..n {
let r = self.minimum_image(self.pos[i] - self.pos[j]).magnitude();
if r > 0.0 && r < nearest {
nearest = r;
}
}
}
if !nearest.is_finite() || nearest <= 0.0 {
return Err(GeomError::Degenerate("no neighbour distance to normalise by"));
}
Ok(rms / nearest)
}
}
impl MdSystem {
pub fn msd(traj: &[Vec<Vec3>]) -> Result<Vec<f64>, GeomError> {
if traj.len() < 2 || traj[0].is_empty() {
return Err(GeomError::InvalidArgument("the trajectory is too short"));
}
let n = traj[0].len();
if traj.iter().any(|frame| frame.len() != n) {
return Err(GeomError::InvalidArgument("the frames differ in length"));
}
let frames = traj.len();
Ok((0..frames)
.map(|lag| {
let origins = frames - lag;
let mut total = 0.0;
for start in 0..origins {
for i in 0..n {
total += (traj[start + lag][i] - traj[start][i]).magnitude_squared();
}
}
total / (origins * n) as f64
})
.collect())
}
pub fn diffusion_coefficient(msd: &[f64], dt: f64) -> Result<f64, GeomError> {
if msd.len() < 8 || !(dt > 0.0) {
return Err(GeomError::InvalidArgument("diffusion_coefficient: bad input"));
}
let lo = msd.len() / 4;
let hi = msd.len() * 3 / 4;
let points: Vec<(f64, f64)> =
(lo..hi).map(|k| (k as f64 * dt, msd[k])).collect();
let n = points.len() as f64;
let sx: f64 = points.iter().map(|p| p.0).sum();
let sy: f64 = points.iter().map(|p| p.1).sum();
let sxx: f64 = points.iter().map(|p| p.0 * p.0).sum();
let sxy: f64 = points.iter().map(|p| p.0 * p.1).sum();
let denominator = n * sxx - sx * sx;
if denominator.abs() < 1e-300 {
return Err(GeomError::Degenerate("the lags do not vary"));
}
Ok((n * sxy - sx * sy) / denominator / 6.0)
}
pub fn vacf(traj_vel: &[Vec<Vec3>]) -> Result<Vec<f64>, GeomError> {
if traj_vel.len() < 2 || traj_vel[0].is_empty() {
return Err(GeomError::InvalidArgument("the trajectory is too short"));
}
let n = traj_vel[0].len();
if traj_vel.iter().any(|frame| frame.len() != n) {
return Err(GeomError::InvalidArgument("the frames differ in length"));
}
let frames = traj_vel.len();
let raw: Vec<f64> = (0..frames)
.map(|lag| {
let origins = frames - lag;
let mut total = 0.0;
for start in 0..origins {
for i in 0..n {
total += traj_vel[start + lag][i].dot(&traj_vel[start][i]);
}
}
total / (origins * n) as f64
})
.collect();
if !(raw[0] > 0.0) {
return Err(GeomError::Degenerate("the trajectory has no motion"));
}
Ok(raw.iter().map(|c| c / raw[0]).collect())
}
pub fn vdos_from_vacf(vacf: &[f64], dt: f64) -> Result<Vec<f64>, GeomError> {
if vacf.len() < 2 || !(dt > 0.0) {
return Err(GeomError::InvalidArgument("vdos_from_vacf: bad input"));
}
let n = vacf.len();
Ok((0..n)
.map(|k| {
let omega = std::f64::consts::PI * k as f64 / (n as f64 * dt);
let mut total = 0.0;
for (t, c) in vacf.iter().enumerate() {
let weight = if t == 0 || t == n - 1 { 0.5 } else { 1.0 };
total += weight * c * (omega * t as f64 * dt).cos();
}
2.0 * total * dt
})
.collect())
}
}
#[must_use]
pub fn lj_reduced_units_note() -> &'static str {
"Lennard-Jones reduced units: lengths in sigma, energies in eps, masses \
in m, and Boltzmann's constant equal to one. Time is then \
sigma sqrt(m / eps), temperature is eps / k_B, pressure is eps / sigma^3 \
and number density is 1 / sigma^3. For argon (sigma = 3.4 A, \
eps / k_B = 120 K, m = 40 amu) one time unit is about 2.16 ps, so a step \
of 0.005 is about 10 fs."
}
#[must_use]
pub fn lj_phase_point(t_star: f64, rho_star: f64) -> &'static str {
if rho_star <= 0.0 || t_star <= 0.0 {
return "unphysical";
}
if rho_star > 0.94 || (t_star < 0.69 && rho_star > 0.84) {
return "solid";
}
if t_star > 1.32 && rho_star > 0.20 {
return "supercritical fluid";
}
if rho_star < 0.05 {
return "gas";
}
if rho_star > 0.6 {
return "liquid";
}
if t_star < 1.32 {
return "gas-liquid coexistence";
}
"fluid"
}
pub fn ewald_sum_energy_lite(
charges: &[f64],
pos: &[Vec3],
box_l: f64,
alpha: f64,
k_max: usize,
) -> Result<f64, GeomError> {
if charges.is_empty() || charges.len() != pos.len() {
return Err(GeomError::InvalidArgument("ewald: mismatched input"));
}
if !(box_l > 0.0) || !(alpha > 0.0) || k_max == 0 || k_max > 32 {
return Err(GeomError::InvalidArgument("ewald: bad parameters"));
}
let net: f64 = charges.iter().sum();
if net.abs() > 1e-9 * charges.iter().map(|q| q.abs()).sum::<f64>().max(1.0) {
return Err(GeomError::InvalidArgument("the system must be neutral"));
}
let n = charges.len();
let volume = box_l * box_l * box_l;
let mut real = 0.0;
let fold = |x: f64| x - box_l * (x / box_l).round();
for i in 0..n {
for j in (i + 1)..n {
let d = pos[i] - pos[j];
let r = Vec3::new(fold(d.x), fold(d.y), fold(d.z)).magnitude();
if r > 0.0 {
real += charges[i] * charges[j] * crate::special::erf::erfc(alpha * r) / r;
}
}
}
let mut reciprocal = 0.0;
let two_pi_over_l = 2.0 * std::f64::consts::PI / box_l;
let limit = k_max as isize;
for nx in -limit..=limit {
for ny in -limit..=limit {
for nz in -limit..=limit {
if nx == 0 && ny == 0 && nz == 0 {
continue;
}
let k = Vec3::new(nx as f64, ny as f64, nz as f64) * two_pi_over_l;
let k2 = k.magnitude_squared();
let (mut cos_sum, mut sin_sum) = (0.0, 0.0);
for (q, p) in charges.iter().zip(pos) {
let phase = k.dot(p);
cos_sum += q * phase.cos();
sin_sum += q * phase.sin();
}
let structure = cos_sum * cos_sum + sin_sum * sin_sum;
reciprocal += (-k2 / (4.0 * alpha * alpha)).exp() / k2 * structure;
}
}
}
reciprocal *= 2.0 * std::f64::consts::PI / volume;
let self_energy =
alpha / std::f64::consts::PI.sqrt() * charges.iter().map(|q| q * q).sum::<f64>();
Ok(real + reciprocal - self_energy)
}
pub fn harmonic_crystal_heat_capacity_check(
energies: &[f64],
temperature: f64,
particles: usize,
) -> Result<f64, GeomError> {
if energies.len() < 2 || particles == 0 || !(temperature > 0.0) {
return Err(GeomError::InvalidArgument("heat capacity check: bad input"));
}
let n = energies.len() as f64;
let mean: f64 = energies.iter().sum::<f64>() / n;
let variance: f64 = energies.iter().map(|e| (e - mean) * (e - mean)).sum::<f64>() / (n - 1.0);
Ok(variance / (temperature * temperature * particles as f64))
}
pub fn virial_coefficient_b2(
potential: &Potential,
t: f64,
r_max: f64,
n: usize,
) -> Result<f64, GeomError> {
if !(t > 0.0) || !(r_max > 0.0) || n < 2 || !n.is_multiple_of(2) {
return Err(GeomError::InvalidArgument("virial_coefficient_b2: bad parameters"));
}
let h = r_max / n as f64;
let f = |r: f64| -> f64 {
if r <= 0.0 {
return 0.0;
}
let u = potential.evaluate(r, 0.0, 0.0).0;
let mayer = if u / t > 700.0 { -1.0 } else { (-u / t).exp() - 1.0 };
mayer * r * r
};
let mut total = f(0.0) + f(r_max);
for k in 1..n {
let weight = if k.is_multiple_of(2) { 2.0 } else { 4.0 };
total += weight * f(k as f64 * h);
}
Ok(-2.0 * std::f64::consts::PI * total * h / 3.0)
}
pub fn mean_free_path(density: f64, sigma: f64) -> Result<f64, GeomError> {
if !(density > 0.0) || !(sigma > 0.0) {
return Err(GeomError::InvalidArgument("mean_free_path needs positive input"));
}
Ok(1.0 / (2f64.sqrt() * density * sigma))
}
pub fn collision_rate(density: f64, sigma: f64, mean_speed: f64) -> Result<f64, GeomError> {
if !(mean_speed > 0.0) {
return Err(GeomError::InvalidArgument("the mean speed must be positive"));
}
Ok(mean_speed / mean_free_path(density, sigma)?)
}
pub fn green_kubo_viscosity_lite(
stress_xy: &[f64],
dt: f64,
volume: f64,
temperature: f64,
) -> Result<f64, GeomError> {
if stress_xy.len() < 2 || !(dt > 0.0) || !(volume > 0.0) || !(temperature > 0.0) {
return Err(GeomError::InvalidArgument("green_kubo_viscosity_lite: bad input"));
}
let frames = stress_xy.len();
let correlation: Vec<f64> = (0..frames / 2)
.map(|lag| {
let origins = frames - lag;
stress_xy[..origins]
.iter()
.enumerate()
.map(|(t, s)| s * stress_xy[t + lag])
.sum::<f64>()
/ origins as f64
})
.collect();
let mut integral = 0.0;
for k in 1..correlation.len() {
if correlation[k] <= 0.0 {
break;
}
integral += 0.5 * (correlation[k - 1] + correlation[k]) * dt;
}
Ok(volume / temperature * integral)
}
pub fn umbrella_sampling_pmf(
histograms: &[Vec<f64>],
centers: &[f64],
k: f64,
bin_lo: f64,
bin_width: f64,
temperature: f64,
) -> Result<Vec<f64>, GeomError> {
if histograms.is_empty() || histograms.len() != centers.len() {
return Err(GeomError::InvalidArgument("umbrella_sampling_pmf: mismatched input"));
}
let bins = histograms[0].len();
if bins == 0 || histograms.iter().any(|h| h.len() != bins) {
return Err(GeomError::InvalidArgument("the histograms differ in length"));
}
if !(bin_width > 0.0) || !(k > 0.0) || !(temperature > 0.0) {
return Err(GeomError::InvalidArgument("umbrella_sampling_pmf: bad parameters"));
}
let windows = histograms.len();
let samples: Vec<f64> = histograms.iter().map(|h| h.iter().sum()).collect();
if samples.iter().any(|s| !(*s > 0.0)) {
return Err(GeomError::Degenerate("a window collected no samples"));
}
let x = |b: usize| bin_lo + (b as f64 + 0.5) * bin_width;
let bias: Vec<Vec<f64>> = (0..windows)
.map(|w| {
(0..bins)
.map(|b| {
let d = x(b) - centers[w];
0.5 * k * d * d / temperature
})
.collect()
})
.collect();
let total: Vec<f64> = (0..bins).map(|b| histograms.iter().map(|h| h[b]).sum()).collect();
let mut free = vec![0.0f64; windows];
for _ in 0..10_000 {
let probability: Vec<f64> = (0..bins)
.map(|b| {
let denominator: f64 =
(0..windows).map(|w| samples[w] * (free[w] - bias[w][b]).exp()).sum();
if denominator > 0.0 {
total[b] / denominator
} else {
0.0
}
})
.collect();
let mut updated = vec![0.0f64; windows];
for w in 0..windows {
let z: f64 = (0..bins).map(|b| probability[b] * (-bias[w][b]).exp()).sum();
if !(z > 0.0) {
return Err(GeomError::Degenerate("a window has no overlap with the histogram"));
}
updated[w] = -z.ln();
}
let anchor = updated[0];
for f in &mut updated {
*f -= anchor;
}
let change = (0..windows).map(|w| (updated[w] - free[w]).abs()).fold(0.0, f64::max);
free = updated;
if change < 1e-12 {
let mut pmf: Vec<f64> = (0..bins)
.map(|b| {
let denominator: f64 =
(0..windows).map(|w| samples[w] * (free[w] - bias[w][b]).exp()).sum();
if total[b] > 0.0 && denominator > 0.0 {
-temperature * (total[b] / denominator).ln()
} else {
f64::INFINITY
}
})
.collect();
let lowest = pmf.iter().copied().fold(f64::INFINITY, f64::min);
if lowest.is_finite() {
for value in &mut pmf {
*value -= lowest;
}
}
return Ok(pmf);
}
}
Err(GeomError::Degenerate("WHAM did not converge"))
}
pub fn steered_pull(
force_along: &dyn Fn(f64) -> f64,
start: f64,
speed: f64,
k: f64,
dt: f64,
steps: usize,
) -> Result<Vec<f64>, GeomError> {
if !(dt > 0.0) || !(k > 0.0) || steps == 0 {
return Err(GeomError::InvalidArgument("steered_pull: bad parameters"));
}
let mut x = start;
let mut work = 0.0;
let mut out = Vec::with_capacity(steps);
for step in 0..steps {
let centre = start + speed * step as f64 * dt;
let force = force_along(x) + k * (centre - x);
x += force * dt;
work += k * (centre - x) * speed * dt;
out.push(work);
}
Ok(out)
}
pub fn jarzynski_free_energy(work: &[f64], temperature: f64) -> Result<f64, GeomError> {
if work.is_empty() || !(temperature > 0.0) {
return Err(GeomError::InvalidArgument("jarzynski_free_energy: bad input"));
}
let smallest = work.iter().copied().fold(f64::INFINITY, f64::min);
let mean: f64 = work.iter().map(|w| (-(w - smallest) / temperature).exp()).sum::<f64>()
/ work.len() as f64;
Ok(smallest - temperature * mean.ln())
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn every_potential_force_is_the_negative_gradient_of_its_own_energy() {
let laws = [
Potential::LennardJones { eps: 1.0, sigma: 1.0 },
Potential::LennardJones { eps: 2.5, sigma: 0.8 },
Potential::Morse { d: 1.5, a: 2.0, r0: 1.2 },
Potential::Coulomb { ke: 1.0 },
Potential::LjCoulomb { eps: 1.0, sigma: 1.0, ke: 0.7 },
Potential::Harmonic { k: 3.0, r0: 1.1 },
];
for law in &laws {
for step in 1..=40 {
let r = 0.85 + 0.05 * f64::from(step);
let h = 1e-6;
let (_, force) = law.evaluate(r, 1.0, -1.0);
let up = law.evaluate(r + h, 1.0, -1.0).0;
let down = law.evaluate(r - h, 1.0, -1.0).0;
let numeric = -(up - down) / (2.0 * h);
let scale = force.abs().max(numeric.abs()).max(1.0);
assert!(
close(force, numeric, 1e-4 * scale),
"{law:?} at r = {r} gives force {force} against gradient {numeric}"
);
}
}
}
#[test]
fn the_lennard_jones_minimum_sits_where_theory_puts_it() {
for &sigma in &[0.5f64, 1.0, 2.0] {
for &eps in &[0.25f64, 1.0, 3.0] {
let law = Potential::LennardJones { eps, sigma };
let r_min = 2f64.powf(1.0 / 6.0) * sigma;
let (u, f) = law.evaluate(r_min, 0.0, 0.0);
assert!(close(u, -eps, 1e-12 * eps), "the well depth is {u}, not {eps}");
assert!(close(f, 0.0, 1e-9 * eps / sigma), "the force at the minimum is {f}");
assert!(close(law.evaluate(sigma, 0.0, 0.0).0, 0.0, 1e-12 * eps));
assert!(law.evaluate(0.9 * sigma, 0.0, 0.0).1 > 0.0);
assert!(law.evaluate(1.5 * sigma, 0.0, 0.0).1 < 0.0);
}
}
let morse = Potential::Morse { d: 2.0, a: 1.5, r0: 1.3 };
assert!(close(morse.evaluate(1.3, 0.0, 0.0).0, -2.0, 1e-12));
assert!(close(morse.evaluate(1.3, 0.0, 0.0).1, 0.0, 1e-12));
assert!(close(morse.evaluate(40.0, 0.0, 0.0).0, 0.0, 1e-9));
assert!(morse.evaluate(3.0, 0.0, 0.0).0 < 0.0);
}
#[test]
fn a_custom_law_is_used_as_given() {
let law = Potential::Custom(Arc::new(|r: f64| (r * r, -2.0 * r)));
assert!(close(law.evaluate(3.0, 0.0, 0.0).0, 9.0, 1e-12));
assert!(close(law.evaluate(3.0, 0.0, 0.0).1, -6.0, 1e-12));
assert!(!law.is_charged());
assert!(Potential::Coulomb { ke: 1.0 }.is_charged());
assert!(format!("{law:?}").contains("Custom"));
}
fn two_body(separation: f64, box_l: f64, cutoff: f64) -> MdSystem {
MdSystem::new(
vec![Vec3::new(1.0, 1.0, 1.0), Vec3::new(1.0 + separation, 1.0, 1.0)],
vec![Vec3::new(0.0, 0.0, 0.0); 2],
vec![1.0; 2],
Vec3::new(box_l, box_l, box_l),
true,
Potential::LennardJones { eps: 1.0, sigma: 1.0 },
cutoff,
)
.unwrap()
}
#[test]
fn the_minimum_image_picks_the_nearer_of_the_two_ways_round() {
let system = two_body(1.0, 10.0, 3.0);
let d = system.minimum_image(Vec3::new(6.0, -7.0, 2.0));
assert!(close(d.x, -4.0, 1e-12));
assert!(close(d.y, 3.0, 1e-12));
assert!(close(d.z, 2.0, 1e-12));
let mut rng = Rng::new(0x011D_0001);
for _ in 0..500 {
let raw = Vec3::new(
rng.next_f64() * 60.0 - 30.0,
rng.next_f64() * 60.0 - 30.0,
rng.next_f64() * 60.0 - 30.0,
);
let folded = system.minimum_image(raw);
for c in [folded.x, folded.y, folded.z] {
assert!(c.abs() <= 5.0 + 1e-9, "the folded component {c} is outside the box");
}
let shift = (raw.x - folded.x) / 10.0;
assert!(close(shift, shift.round(), 1e-9));
}
let open = MdSystem::new(
vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)],
vec![Vec3::new(0.0, 0.0, 0.0); 2],
vec![1.0; 2],
Vec3::new(10.0, 10.0, 10.0),
false,
Potential::LennardJones { eps: 1.0, sigma: 1.0 },
3.0,
)
.unwrap();
assert!(close(open.minimum_image(Vec3::new(6.0, 0.0, 0.0)).x, 6.0, 1e-12));
}
#[test]
fn the_pair_force_is_equal_and_opposite_and_matches_the_law() {
for step in 1..=20 {
let r = 0.9 + 0.05 * f64::from(step);
let system = two_body(r, 12.0, 2.5);
let f = system.forces();
assert!(close((f[0] + f[1]).magnitude(), 0.0, 1e-9), "the forces do not cancel");
let radial = Potential::LennardJones { eps: 1.0, sigma: 1.0 }.evaluate(r, 0.0, 0.0).1;
assert!(
close(f[0].x, -radial, 1e-9 * radial.abs().max(1.0)),
"at r = {r} the force is {} against {}",
f[0].x,
-radial
);
assert!(close(f[0].y, 0.0, 1e-12) && close(f[0].z, 0.0, 1e-12));
if r < 2f64.powf(1.0 / 6.0) {
assert!(f[0].x < 0.0 && f[1].x > 0.0, "the pair does not repel at r = {r}");
} else {
assert!(f[0].x > 0.0 && f[1].x < 0.0, "the pair does not attract at r = {r}");
}
}
let far = two_body(3.0, 12.0, 2.5);
assert!(close(far.forces()[0].magnitude(), 0.0, 1e-15));
assert!(close(far.potential_energy(), 0.0, 1e-15));
}
#[test]
fn the_shifted_potential_is_continuous_at_the_cutoff() {
let cutoff = 2.5;
assert!(close(two_body(cutoff + 1e-7, 20.0, cutoff).potential_energy(), 0.0, 1e-15));
let gap = |h: f64| two_body(cutoff - h, 20.0, cutoff).potential_energy().abs();
let coarse = gap(1e-6);
let fine = gap(5e-7);
assert!(coarse > 0.0, "the potential is flat at the cutoff, so nothing is being tested");
assert!(
close(coarse / fine, 2.0, 0.01),
"halving the step changed the gap by {} rather than two",
coarse / fine
);
let unshifted = Potential::LennardJones { eps: 1.0, sigma: 1.0 }
.evaluate(cutoff, 0.0, 0.0)
.0
.abs();
assert!(coarse < 1e-3 * unshifted, "the energy still steps by {coarse} at the cutoff");
}
#[test]
fn the_cell_list_and_the_all_pairs_loop_agree() {
let mut rng = Rng::new(0x011D_0002);
for trial in 0..4 {
let cells = 5 + trial % 2;
let mut system =
MdSystem::lattice_fcc(cells, 0.85, 1.0, 1.0, 1.0, &mut rng).unwrap();
for p in &mut system.pos {
*p = *p
+ Vec3::new(
rng.next_gaussian() * 0.08,
rng.next_gaussian() * 0.08,
rng.next_gaussian() * 0.08,
);
}
for k in 0..system.pos.len() {
system.pos[k] = system.wrap(system.pos[k]);
}
assert!(system.cell_counts().is_some(), "the cell path was not taken");
let (cell_forces, cell_energy, cell_virial) = system.compute_forces();
let (mut pair_forces, mut pair_energy, mut pair_virial) =
(vec![Vec3::new(0.0, 0.0, 0.0); system.len()], 0.0, 0.0);
let shift = system.potential.evaluate(system.cutoff, 0.0, 0.0).0;
let rc2 = system.cutoff * system.cutoff;
for i in 0..system.len() {
for j in (i + 1)..system.len() {
let d = system.minimum_image(system.pos[i] - system.pos[j]);
let r2 = d.magnitude_squared();
if r2 < rc2 && r2 > 0.0 {
let r = r2.sqrt();
let (u, f) = system.potential.evaluate(r, 0.0, 0.0);
pair_energy += u - shift;
pair_virial += f * r;
let force = d * (f / r);
pair_forces[i] = pair_forces[i] + force;
pair_forces[j] = pair_forces[j] - force;
}
}
}
let scale = pair_energy.abs().max(1.0);
assert!(
close(cell_energy, pair_energy, 1e-8 * scale),
"the cell list gives energy {cell_energy} against {pair_energy}"
);
assert!(close(cell_virial, pair_virial, 1e-8 * pair_virial.abs().max(1.0)));
for k in 0..system.len() {
assert!(
close((cell_forces[k] - pair_forces[k]).magnitude(), 0.0, 1e-8 * scale),
"the force on particle {k} differs between the two loops"
);
}
}
}
#[test]
fn the_forces_of_an_isolated_box_sum_to_zero() {
let mut rng = Rng::new(0x011D_0003);
for cells in [2usize, 3, 4] {
let system = MdSystem::lattice_fcc(cells, 0.7, 1.2, 1.0, 1.0, &mut rng).unwrap();
let total = system.forces().into_iter().fold(Vec3::new(0.0, 0.0, 0.0), |a, f| a + f);
assert!(
close(total.magnitude(), 0.0, 1e-8 * system.len() as f64),
"the net force on {} particles is {}",
system.len(),
total.magnitude()
);
}
}
#[test]
fn the_constructor_rejects_states_it_cannot_integrate() {
let good = || vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
let lj = || Potential::LennardJones { eps: 1.0, sigma: 1.0 };
let b = Vec3::new(10.0, 10.0, 10.0);
assert!(MdSystem::new(vec![], vec![], vec![], b, true, lj(), 2.5).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0)], vec![1.0; 2], b, true, lj(), 2.5).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![0.0; 2], b, true, lj(), 2.5).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![1.0; 2], b, true, lj(), 0.0).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![1.0; 2], Vec3::new(0.0, 1.0, 1.0), true, lj(), 0.5).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![1.0; 2], b, true, lj(), 5.1).is_err());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![1.0; 2], b, true, lj(), 5.0).is_ok());
assert!(MdSystem::new(good(), vec![Vec3::new(0.0, 0.0, 0.0); 2], vec![1.0; 2], b, false, lj(), 50.0).is_ok());
let mut rng = Rng::new(1);
assert!(MdSystem::lattice_fcc(0, 0.8, 1.0, 1.0, 1.0, &mut rng).is_err());
assert!(MdSystem::lattice_fcc(33, 0.8, 1.0, 1.0, 1.0, &mut rng).is_err());
assert!(MdSystem::lattice_fcc(2, 0.0, 1.0, 1.0, 1.0, &mut rng).is_err());
assert!(MdSystem::lattice_fcc(2, 0.8, -1.0, 1.0, 1.0, &mut rng).is_err());
}
#[test]
fn the_ewald_sum_reproduces_the_madelung_constant_of_rock_salt() {
const MADELUNG: f64 = 1.747_564_594_6;
for cells in [2usize, 4] {
let a = 1.0;
let side = cells as f64 * a;
let mut pos = Vec::new();
let mut charges = Vec::new();
for i in 0..cells {
for j in 0..cells {
for k in 0..cells {
pos.push(Vec3::new(i as f64 * a, j as f64 * a, k as f64 * a));
charges.push(if (i + j + k) % 2 == 0 { 1.0 } else { -1.0 });
}
}
}
let n = charges.len() as f64;
let energy = ewald_sum_energy_lite(&charges, &pos, side, 8.0 / side, 12).unwrap();
let madelung = -2.0 * energy / n;
assert!(
close(madelung, MADELUNG, 1e-5),
"the {cells}-cell lattice gives a Madelung constant of {madelung}"
);
}
}
#[test]
fn the_ewald_energy_does_not_depend_on_where_the_sum_is_split() {
let mut rng = Rng::new(0x011D_0040);
for _ in 0..4 {
let count = 8;
let side = 4.0;
let pos: Vec<Vec3> = (0..count)
.map(|_| {
Vec3::new(
rng.next_f64() * side,
rng.next_f64() * side,
rng.next_f64() * side,
)
})
.collect();
let mut charges: Vec<f64> = (0..count - 1).map(|_| rng.next_f64() * 2.0 - 1.0).collect();
let balance = -charges.iter().sum::<f64>();
charges.push(balance);
let reference = ewald_sum_energy_lite(&charges, &pos, side, 1.5, 10).unwrap();
for &alpha in &[1.0f64, 2.0, 2.5] {
let other = ewald_sum_energy_lite(&charges, &pos, side, alpha, 12).unwrap();
assert!(
close(other, reference, 1e-3 * reference.abs().max(1.0)),
"alpha {alpha} gives {other} against {reference}"
);
}
}
assert!(ewald_sum_energy_lite(&[1.0, 1.0], &[Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)], 4.0, 1.0, 4).is_err());
assert!(ewald_sum_energy_lite(&[], &[], 4.0, 1.0, 4).is_err());
assert!(ewald_sum_energy_lite(&[1.0, -1.0], &[Vec3::new(0.0, 0.0, 0.0)], 4.0, 1.0, 4).is_err());
let pair = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
assert!(ewald_sum_energy_lite(&[1.0, -1.0], &pair, 0.0, 1.0, 4).is_err());
assert!(ewald_sum_energy_lite(&[1.0, -1.0], &pair, 4.0, 0.0, 4).is_err());
assert!(ewald_sum_energy_lite(&[1.0, -1.0], &pair, 4.0, 1.0, 0).is_err());
assert!(ewald_sum_energy_lite(&[1.0, -1.0], &pair, 4.0, 1.0, 33).is_err());
}
#[test]
fn the_second_virial_coefficient_is_exact_for_a_hard_sphere() {
for &d in &[0.5f64, 1.0, 1.7] {
let hard = Potential::Custom(Arc::new(move |r: f64| {
if r < d {
(1e6, 0.0)
} else {
(0.0, 0.0)
}
}));
let expected = 2.0 * std::f64::consts::PI * d * d * d / 3.0;
for &t in &[0.5f64, 1.0, 5.0] {
let b2 = virial_coefficient_b2(&hard, t, 4.0, 40_000).unwrap();
assert!(
close(b2, expected, 1e-3 * expected),
"a hard sphere of diameter {d} at T = {t} gives B2 = {b2} against {expected}"
);
}
}
}
#[test]
fn the_lennard_jones_virial_changes_sign_at_the_boyle_temperature() {
let lj = Potential::LennardJones { eps: 1.0, sigma: 1.0 };
assert!(virial_coefficient_b2(&lj, 1.0, 8.0, 20_000).unwrap() < -1.0);
assert!(virial_coefficient_b2(&lj, 10.0, 8.0, 20_000).unwrap() > 0.5);
let (mut lo, mut hi) = (2.0f64, 6.0f64);
for _ in 0..50 {
let mid = 0.5 * (lo + hi);
if virial_coefficient_b2(&lj, mid, 8.0, 20_000).unwrap() < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
let boyle = 0.5 * (lo + hi);
assert!(close(boyle, 3.418, 0.02), "the Boyle temperature came out {boyle}");
let mut previous = f64::NEG_INFINITY;
for step in 1..=20 {
let t = 0.6 + 0.5 * f64::from(step);
let b2 = virial_coefficient_b2(&lj, t, 8.0, 20_000).unwrap();
assert!(b2 > previous, "B2 fell from {previous} to {b2} at T = {t}");
previous = b2;
}
assert!(virial_coefficient_b2(&lj, 0.0, 8.0, 100).is_err());
assert!(virial_coefficient_b2(&lj, 1.0, 0.0, 100).is_err());
assert!(virial_coefficient_b2(&lj, 1.0, 8.0, 101).is_err());
assert!(virial_coefficient_b2(&lj, 1.0, 8.0, 1).is_err());
}
#[test]
fn a_harmonic_crystal_obeys_dulong_and_petit() {
let mut rng = Rng::new(0x011D_0041);
let cells = 2usize;
let seed = MdSystem::lattice_fcc(cells, 1.0, 0.0, 1.0, 1.0, &mut rng).unwrap();
let a = seed.box_size.x / cells as f64;
let nearest = a / 2f64.sqrt();
let mut crystal = MdSystem::new(
seed.pos.clone(),
vec![Vec3::new(0.0, 0.0, 0.0); seed.len()],
vec![1.0; seed.len()],
seed.box_size,
true,
Potential::Harmonic { k: 40.0, r0: nearest },
nearest * 1.05,
)
.unwrap();
let n = crystal.len();
let temperature = 0.02;
let dt = 0.004;
for _ in 0..4_000 {
crystal.step_velocity_verlet(dt);
crystal.thermostat_langevin(temperature, 4.0, dt, &mut rng);
}
let mut energies = Vec::with_capacity(40_000);
for step in 0..200_000 {
crystal.step_velocity_verlet(dt);
crystal.thermostat_langevin(temperature, 4.0, dt, &mut rng);
if step % 5 == 0 {
let s = crystal.sample();
energies.push(s.total);
}
}
let capacity =
harmonic_crystal_heat_capacity_check(&energies, temperature, n).unwrap();
let expected = (6.0 * n as f64 - 3.0) / 2.0 / n as f64;
assert!(
close(capacity, expected, 0.15 * expected),
"the crystal gives {capacity} k per particle against {expected}"
);
assert!(expected < 3.0);
assert!(harmonic_crystal_heat_capacity_check(&[1.0], 1.0, 4).is_err());
assert!(harmonic_crystal_heat_capacity_check(&[1.0, 2.0], 0.0, 4).is_err());
assert!(harmonic_crystal_heat_capacity_check(&[1.0, 2.0], 1.0, 0).is_err());
let made: Vec<f64> = (0..1_001).map(|k| f64::from(k - 500) * 0.01).collect();
let mean: f64 = made.iter().sum::<f64>() / made.len() as f64;
let variance: f64 = made.iter().map(|e| (e - mean) * (e - mean)).sum::<f64>()
/ (made.len() as f64 - 1.0);
assert!(close(
harmonic_crystal_heat_capacity_check(&made, 0.5, 3).unwrap(),
variance / (0.25 * 3.0),
1e-9
));
}
#[test]
fn the_kinetic_theory_lengths_are_reciprocal_to_their_rates() {
for &density in &[0.1f64, 1.0, 25.0] {
for &sigma in &[0.05f64, 1.0, 3.0] {
let lambda = mean_free_path(density, sigma).unwrap();
assert!(close(lambda, 1.0 / (2f64.sqrt() * density * sigma), 1e-12));
for &speed in &[0.5f64, 4.0] {
let rate = collision_rate(density, sigma, speed).unwrap();
assert!(close(rate * lambda, speed, 1e-9 * speed));
assert!(close(rate, 2f64.sqrt() * density * sigma * speed, 1e-9 * rate));
}
}
}
assert!(close(
mean_free_path(2.0, 1.0).unwrap() * 2.0,
mean_free_path(1.0, 1.0).unwrap(),
1e-12
));
assert!(mean_free_path(0.0, 1.0).is_err());
assert!(mean_free_path(1.0, 0.0).is_err());
assert!(collision_rate(1.0, 1.0, 0.0).is_err());
}
#[test]
fn the_green_kubo_integral_recovers_a_known_correlation_time() {
let volume = 3.0;
let temperature = 2.0;
let sigma = 1.5;
let dt = 0.01;
let ou = |rng: &mut Rng, tau: f64, samples: usize| -> Vec<f64> {
let decay = (-dt / tau).exp();
let noise = sigma * (1.0 - decay * decay).sqrt();
let mut x = sigma * rng.next_gaussian();
(0..samples)
.map(|_| {
x = x * decay + noise * rng.next_gaussian();
x
})
.collect()
};
for &tau in &[0.3f64, 1.0] {
let mut rng = Rng::new(0x011D_0042 + (tau * 10.0) as u64);
let series = ou(&mut rng, tau, 30_000);
let eta = green_kubo_viscosity_lite(&series, dt, volume, temperature).unwrap();
let expected = volume / temperature * sigma * sigma * tau;
assert!(
close(eta, expected, 0.15 * expected),
"at tau = {tau} the integral gives {eta} against {expected}"
);
let _ = expected;
}
let tau = 5.0;
let window = 400usize;
let mut rng = Rng::new(0x011D_0044);
let captured = 1.0 - (-(window as f64 / 2.0) * dt / tau).exp();
assert!(captured < 0.45, "the window is not short enough to bias anything");
let full = volume / temperature * sigma * sigma * tau;
let mut short_total = 0.0;
for _ in 0..600 {
let piece = ou(&mut rng, tau, window);
short_total += green_kubo_viscosity_lite(&piece, dt, volume, temperature).unwrap();
}
let short = short_total / 600.0;
assert!(short > 0.0, "the truncated estimate collapsed to {short}");
assert!(
short < 0.6 * full,
"the truncated ensemble gives {short}, not clearly below the full {full}"
);
assert!(green_kubo_viscosity_lite(&[1.0], 0.01, 1.0, 1.0).is_err());
assert!(green_kubo_viscosity_lite(&[1.0, 2.0], 0.0, 1.0, 1.0).is_err());
assert!(green_kubo_viscosity_lite(&[1.0, 2.0], 0.01, 0.0, 1.0).is_err());
assert!(green_kubo_viscosity_lite(&[1.0, 2.0], 0.01, 1.0, 0.0).is_err());
}
#[test]
fn wham_inverts_a_potential_of_mean_force_it_was_never_told() {
let temperature = 0.8;
let k = 12.0;
let bins = 60;
let bin_lo = -3.0;
let bin_width = 0.1;
let x = |b: usize| bin_lo + (b as f64 + 0.5) * bin_width;
let truth = |v: f64| 4.0 * (v * v - 1.0) * (v * v - 1.0);
let centers: Vec<f64> = (0..13).map(|w| -2.4 + 0.4 * f64::from(w)).collect();
let histograms: Vec<Vec<f64>> = centers
.iter()
.map(|c| {
let raw: Vec<f64> = (0..bins)
.map(|b| {
let v = x(b);
let bias = 0.5 * k * (v - c) * (v - c);
(-(truth(v) + bias) / temperature).exp()
})
.collect();
let total: f64 = raw.iter().sum();
raw.into_iter().map(|p| p / total * 100_000.0).collect()
})
.collect();
let pmf = umbrella_sampling_pmf(
&histograms,
¢ers,
k,
bin_lo,
bin_width,
temperature,
)
.unwrap();
let true_curve: Vec<f64> = (0..bins).map(|b| truth(x(b))).collect();
let true_min = true_curve.iter().copied().fold(f64::INFINITY, f64::min);
for b in 0..bins {
let expected = true_curve[b] - true_min;
if x(b).abs() <= 2.4 {
assert!(
close(pmf[b], expected, 0.02 * expected.max(1.0)),
"at x = {} the PMF is {} against {expected}",
x(b),
pmf[b]
);
}
}
let centre = pmf[bins / 2];
assert!(close(centre, 4.0, 0.1), "the barrier came out {centre}");
assert!(umbrella_sampling_pmf(&[], &[], k, bin_lo, bin_width, temperature).is_err());
assert!(umbrella_sampling_pmf(&histograms, ¢ers[..2], k, bin_lo, bin_width, temperature).is_err());
assert!(umbrella_sampling_pmf(&histograms, ¢ers, 0.0, bin_lo, bin_width, temperature).is_err());
assert!(umbrella_sampling_pmf(&histograms, ¢ers, k, bin_lo, 0.0, temperature).is_err());
assert!(umbrella_sampling_pmf(&histograms, ¢ers, k, bin_lo, bin_width, 0.0).is_err());
let empty = vec![vec![0.0; bins]; centers.len()];
assert!(umbrella_sampling_pmf(&empty, ¢ers, k, bin_lo, bin_width, temperature).is_err());
let ragged = vec![vec![1.0; bins], vec![1.0; bins - 1]];
assert!(umbrella_sampling_pmf(&ragged, ¢ers[..2], k, bin_lo, bin_width, temperature).is_err());
}
#[test]
fn pulling_more_slowly_costs_less_work() {
let stiffness = 3.0;
let force = move |x: f64| -stiffness * x;
let distance = 1.0;
let mut previous = f64::INFINITY;
for shift in 0..5 {
let speed = 1.0 / f64::from(1 << shift);
let steps = 2_000 * (1 << shift);
let dt = distance / (speed * steps as f64);
let work = steered_pull(&force, 0.0, speed, 20.0, dt, steps).unwrap();
let total = *work.last().unwrap();
assert!(total > 0.0, "pulling uphill did no work");
assert!(total < previous, "the slower pull cost {total} against {previous}");
previous = total;
}
assert!(previous > 0.5 * stiffness * distance * distance * 0.5);
assert!(steered_pull(&force, 0.0, 1.0, 1.0, 0.0, 10).is_err());
assert!(steered_pull(&force, 0.0, 1.0, 0.0, 0.01, 10).is_err());
assert!(steered_pull(&force, 0.0, 1.0, 1.0, 0.01, 0).is_err());
}
#[test]
fn the_jarzynski_average_sits_below_the_mean_work() {
let mut rng = Rng::new(0x011D_0043);
let temperature = 0.7;
for spread in [0.0f64, 0.3, 1.5] {
let work: Vec<f64> =
(0..4_000).map(|_| 2.0 + spread * rng.next_gaussian()).collect();
let mean: f64 = work.iter().sum::<f64>() / work.len() as f64;
let free = jarzynski_free_energy(&work, temperature).unwrap();
assert!(free <= mean + 1e-9, "the estimate {free} exceeds the mean work {mean}");
if spread == 0.0 {
assert!(close(free, 2.0, 1e-12), "identical pulls gave {free}");
} else {
let expected = mean - spread * spread / (2.0 * temperature);
assert!(
close(free, expected, 0.15 * spread * spread),
"at spread {spread} the estimate is {free} against {expected}"
);
}
}
assert!(jarzynski_free_energy(&[], 1.0).is_err());
assert!(jarzynski_free_energy(&[1.0], 0.0).is_err());
}
#[test]
fn the_phase_classification_and_the_units_note_say_what_they_should() {
assert_eq!(lj_phase_point(0.5, 1.0), "solid");
assert_eq!(lj_phase_point(0.6, 0.9), "solid");
assert_eq!(lj_phase_point(2.0, 0.8), "supercritical fluid");
assert_eq!(lj_phase_point(1.0, 0.01), "gas");
assert_eq!(lj_phase_point(1.0, 0.7), "liquid");
assert_eq!(lj_phase_point(1.0, 0.3), "gas-liquid coexistence");
assert_eq!(lj_phase_point(1.5, 0.1), "fluid");
assert_eq!(lj_phase_point(-1.0, 0.5), "unphysical");
assert_eq!(lj_phase_point(1.0, 0.0), "unphysical");
let note = lj_reduced_units_note();
assert!(note.contains("sigma") && note.contains("Boltzmann"));
assert!(note.contains("argon"), "the note gives no worked conversion");
}
fn ideal_gas(count: usize, box_l: f64, rng: &mut Rng) -> MdSystem {
let pos = (0..count)
.map(|_| {
Vec3::new(
rng.next_f64() * box_l,
rng.next_f64() * box_l,
rng.next_f64() * box_l,
)
})
.collect();
MdSystem::new(
pos,
vec![Vec3::new(0.0, 0.0, 0.0); count],
vec![1.0; count],
Vec3::new(box_l, box_l, box_l),
true,
Potential::LennardJones { eps: 1.0, sigma: 1.0 },
0.3,
)
.unwrap()
}
#[test]
fn the_radial_distribution_of_an_ideal_gas_is_one_everywhere() {
let mut rng = Rng::new(0x011D_0020);
let system = ideal_gas(4_000, 14.0, &mut rng);
let g = system.rdf(20, 6.0).unwrap();
for (k, value) in g.iter().enumerate().skip(3) {
assert!(
close(*value, 1.0, 0.06),
"the ideal gas has g = {value} in bin {k}"
);
}
}
#[test]
fn the_radial_distribution_integrates_to_the_neighbour_count() {
let mut rng = Rng::new(0x011D_0021);
for trial in 0..4 {
let system = if trial % 2 == 0 {
ideal_gas(1_500, 12.0, &mut rng)
} else {
MdSystem::lattice_fcc(4, 0.85, 1.0, 1.0, 1.0, &mut rng).unwrap()
};
let r_max = (0.4 * system.box_size.x).min(3.0);
let bins = 60;
let g = system.rdf(bins, r_max).unwrap();
let density = system.len() as f64 / system.volume();
let width = r_max / bins as f64;
let integral: f64 = g
.iter()
.enumerate()
.map(|(k, value)| {
let lo = k as f64 * width;
let hi = lo + width;
value * 4.0 / 3.0 * std::f64::consts::PI * (hi * hi * hi - lo * lo * lo)
})
.sum::<f64>()
* density;
let mut direct = 0usize;
for i in 0..system.len() {
for j in 0..system.len() {
if i != j
&& system.minimum_image(system.pos[i] - system.pos[j]).magnitude() < r_max
{
direct += 1;
}
}
}
let expected = direct as f64 / system.len() as f64;
assert!(
close(integral, expected, 1e-9 * expected.max(1.0)),
"the integral gives {integral} neighbours against {expected} counted"
);
}
}
#[test]
fn a_crystal_shows_its_shells_and_a_liquid_shows_its_first_peak() {
let mut rng = Rng::new(0x011D_0022);
let crystal = MdSystem::lattice_fcc(4, 1.0, 0.0, 1.0, 1.0, &mut rng).unwrap();
let a = crystal.box_size.x / 4.0;
let bins = 200;
let r_max = 2.0;
let g = crystal.rdf(bins, r_max).unwrap();
let width = r_max / bins as f64;
let bin_of = |r: f64| (r / width) as usize;
for value in g.iter().take(bin_of(a / 2f64.sqrt()) - 1) {
assert!(close(*value, 0.0, 1e-12), "a crystal has density inside its first shell");
}
for shell in [a / 2f64.sqrt(), a, a * 1.5f64.sqrt()] {
if shell < r_max - width {
let k = bin_of(shell);
let peak = g[k.saturating_sub(1)].max(g[k]).max(g[k + 1]);
assert!(peak > 5.0, "the shell at {shell} peaks at only {peak}");
}
}
let mut liquid = MdSystem::lattice_fcc(4, 0.85, 1.5, 1.0, 1.0, &mut rng).unwrap();
liquid.equilibrate(1_500, 0.004, 0.9, &mut rng).unwrap();
let g = liquid.rdf(100, 3.0).unwrap();
let width = 3.0 / 100.0;
let (peak_bin, peak) =
g.iter().enumerate().fold((0usize, 0.0f64), |best, (k, v)| {
if *v > best.1 {
(k, *v)
} else {
best
}
});
let peak_r = (peak_bin as f64 + 0.5) * width;
assert!(
close(peak_r, 2f64.powf(1.0 / 6.0), 0.15),
"the liquid's first peak is at {peak_r}, not near the Lennard-Jones minimum"
);
assert!(peak > 1.5, "the liquid shows no structure at all: the peak is {peak}");
assert!(close(g[g.len() - 1], 1.0, 0.25));
assert!(liquid.rdf(0, 3.0).is_err());
assert!(liquid.rdf(10, 0.0).is_err());
assert!(liquid.rdf(10, liquid.box_size.x).is_err());
}
#[test]
fn the_structure_factor_tends_to_one_and_finds_a_crystals_spacing() {
let mut rng = Rng::new(0x011D_0023);
let gas = ideal_gas(800, 12.0, &mut rng);
let far = gas.structure_factor(&[60.0, 90.0, 140.0]).unwrap();
for s in &far {
assert!(close(*s, 1.0, 0.15), "S at large k is {s}");
}
let crystal = MdSystem::lattice_fcc(4, 1.0, 0.0, 1.0, 1.0, &mut rng).unwrap();
let spacing = crystal.box_size.x / 4.0 / 2f64.sqrt();
let k_peak = 2.0 * std::f64::consts::PI / spacing;
let smallest = 2.0 * std::f64::consts::PI / crystal.box_size.x;
assert!(smallest < 3.0, "the search window does not clear the forward peak");
let grid: Vec<f64> = (0..=100).map(|k| 3.0 + f64::from(k) * 0.15).collect();
let crystal_s = crystal.structure_factor(&grid).unwrap();
let gas_s = gas.structure_factor(&grid).unwrap();
let best = crystal_s
.iter()
.enumerate()
.fold((0usize, f64::NEG_INFINITY), |b, (k, v)| if *v > b.1 { (k, *v) } else { b });
assert!(
close(grid[best.0], k_peak, 1.5),
"the crystal peaks at k = {} rather than {k_peak}",
grid[best.0]
);
let mut sorted = crystal_s.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = sorted[sorted.len() / 2];
assert!(
best.1 > 3.0 * median && best.1 > 3.5,
"the crystal peak {} is not a peak against its own median {median}",
best.1
);
let gas_peak = gas_s.iter().copied().fold(f64::NEG_INFINITY, f64::max);
assert!(gas_peak < 2.0, "the ideal gas showed structure, peaking at {gas_peak}");
assert!(gas.structure_factor(&[0.0]).is_err());
assert!(gas.structure_factor(&[-1.0]).is_err());
}
#[test]
fn the_lindemann_ratio_separates_a_crystal_from_a_melt() {
let mut rng = Rng::new(0x011D_0024);
let mut cold = MdSystem::lattice_fcc(3, 1.05, 0.05, 1.0, 1.0, &mut rng).unwrap();
cold.equilibrate(600, 0.003, 0.05, &mut rng).unwrap();
let (cold_traj, _) = cold.run_trajectory(1_500, 0.003, 15).unwrap();
let cold_ratio = cold.melting_indicator_lindemann(&cold_traj).unwrap();
assert!(cold_ratio < 0.15, "a cold crystal reads {cold_ratio}");
let mut hot = MdSystem::lattice_fcc(3, 0.75, 3.0, 1.0, 1.0, &mut rng).unwrap();
hot.equilibrate(600, 0.003, 3.0, &mut rng).unwrap();
let (hot_traj, _) = hot.run_trajectory(1_500, 0.003, 15).unwrap();
let hot_ratio = hot.melting_indicator_lindemann(&hot_traj).unwrap();
assert!(hot_ratio > 0.15, "a hot liquid reads {hot_ratio}");
assert!(hot_ratio > 2.0 * cold_ratio);
let still = MdSystem::lattice_fcc(2, 1.0, 0.0, 1.0, 1.0, &mut rng).unwrap();
let frozen = vec![still.pos.clone(); 6];
assert!(close(still.melting_indicator_lindemann(&frozen).unwrap(), 0.0, 1e-12));
assert!(still.melting_indicator_lindemann(&frozen[..1]).is_err());
let ragged = vec![vec![Vec3::new(0.0, 0.0, 0.0)]; 3];
assert!(still.melting_indicator_lindemann(&ragged).is_err());
}
#[test]
fn the_mean_squared_displacement_is_ballistic_for_free_flight() {
let mut rng = Rng::new(0x011D_0030);
let count = 40;
let velocities: Vec<Vec3> = (0..count)
.map(|_| Vec3::new(rng.next_gaussian(), rng.next_gaussian(), rng.next_gaussian()))
.collect();
let dt = 0.05;
let frames = 30;
let traj: Vec<Vec<Vec3>> = (0..frames)
.map(|t| velocities.iter().map(|v| *v * (t as f64 * dt)).collect())
.collect();
let msd = MdSystem::msd(&traj).unwrap();
let mean_v2: f64 =
velocities.iter().map(Vec3::magnitude_squared).sum::<f64>() / count as f64;
assert!(close(msd[0], 0.0, 1e-15));
for lag in 1..frames {
let t = lag as f64 * dt;
assert!(
close(msd[lag], mean_v2 * t * t, 1e-9 * mean_v2 * t * t),
"at lag {lag} the MSD is {} against {}",
msd[lag],
mean_v2 * t * t
);
}
let vel_traj = vec![velocities.clone(); frames];
let vacf = MdSystem::vacf(&vel_traj).unwrap();
assert!(vacf.iter().all(|c| close(*c, 1.0, 1e-12)));
assert!(MdSystem::msd(&traj[..1]).is_err());
assert!(MdSystem::vacf(&vel_traj[..1]).is_err());
let still = vec![vec![Vec3::new(0.0, 0.0, 0.0); count]; 5];
assert!(MdSystem::vacf(&still).is_err());
}
#[test]
fn langevin_diffusion_matches_the_einstein_relation() {
for &gamma in &[1.0f64, 3.0] {
let mut rng = Rng::new(0x011D_0031 + gamma as u64);
let temperature = 1.0;
let count = 400;
let box_l = 4.0;
let mut system = MdSystem::new(
(0..count)
.map(|k| {
let g = box_l / 8.0;
Vec3::new(
(k % 8) as f64 * g,
((k / 8) % 8) as f64 * g,
(k / 64) as f64 * g,
)
})
.collect(),
(0..count)
.map(|_| {
Vec3::new(
rng.next_gaussian(),
rng.next_gaussian(),
rng.next_gaussian(),
)
})
.collect(),
vec![1.0; count],
Vec3::new(box_l, box_l, box_l),
true,
Potential::Custom(Arc::new(|_| (0.0, 0.0))),
0.1,
)
.unwrap();
assert!(close(system.potential_energy(), 0.0, 1e-15), "the walkers interact");
let dt = 0.01;
for _ in 0..400 {
system.step_velocity_verlet(dt);
system.thermostat_langevin(temperature, gamma, dt, &mut rng);
}
let frames = 400;
let stride = 8;
let mut positions = Vec::with_capacity(frames);
let mut velocities = Vec::with_capacity(frames);
for step in 0..frames * stride {
if step % stride == 0 {
positions.push(system.unwrapped.clone());
velocities.push(system.vel.clone());
}
system.step_velocity_verlet(dt);
system.thermostat_langevin(temperature, gamma, dt, &mut rng);
}
let sample_dt = dt * stride as f64;
let vacf = MdSystem::vacf(&velocities).unwrap();
for lag in 0..12 {
let expected = (-gamma * lag as f64 * sample_dt).exp();
assert!(
close(vacf[lag], expected, 0.05),
"at gamma {gamma} lag {lag} the VACF is {} against {expected}",
vacf[lag]
);
}
let msd = MdSystem::msd(&positions).unwrap();
let d = MdSystem::diffusion_coefficient(&msd, sample_dt).unwrap();
let expected = temperature / gamma;
assert!(
close(d, expected, 0.15 * expected),
"at gamma {gamma} the diffusion is {d} against {expected}"
);
let wrapped: Vec<Vec<Vec3>> = positions
.iter()
.map(|frame| frame.iter().map(|p| system.wrap(*p)).collect())
.collect();
let wrapped_msd = MdSystem::msd(&wrapped).unwrap();
let wrapped_d = MdSystem::diffusion_coefficient(&wrapped_msd, sample_dt).unwrap();
assert!(
wrapped_d < 0.2 * d,
"the wrapped trajectory still reports {wrapped_d} against the true {d}"
);
}
}
#[test]
fn the_vibrational_spectrum_transforms_the_correlations_it_is_given() {
let dt = 0.01;
let n = 4_000;
for &gamma in &[2.0f64, 6.0] {
let vacf: Vec<f64> = (0..n).map(|k| (-gamma * k as f64 * dt).exp()).collect();
let spectrum = MdSystem::vdos_from_vacf(&vacf, dt).unwrap();
for k in [0usize, 5, 20, 60, 150] {
let omega = std::f64::consts::PI * k as f64 / (n as f64 * dt);
let expected = 2.0 * gamma / (gamma * gamma + omega * omega);
assert!(
close(spectrum[k], expected, 0.02 * expected.max(0.05)),
"at gamma {gamma}, k = {k} the spectrum is {} against {expected}",
spectrum[k]
);
}
}
let omega0 = 7.0;
let vacf: Vec<f64> = (0..n).map(|k| (omega0 * k as f64 * dt).cos()).collect();
let spectrum = MdSystem::vdos_from_vacf(&vacf, dt).unwrap();
let best = spectrum
.iter()
.enumerate()
.fold((0usize, f64::NEG_INFINITY), |b, (k, v)| if *v > b.1 { (k, *v) } else { b });
let peak_omega = std::f64::consts::PI * best.0 as f64 / (n as f64 * dt);
assert!(
close(peak_omega, omega0, 0.1),
"an undamped oscillator peaks at {peak_omega} rather than {omega0}"
);
assert!(MdSystem::vdos_from_vacf(&[1.0], dt).is_err());
assert!(MdSystem::vdos_from_vacf(&vacf, 0.0).is_err());
assert!(MdSystem::diffusion_coefficient(&[0.0; 4], dt).is_err());
assert!(MdSystem::diffusion_coefficient(&[0.0; 20], 0.0).is_err());
}
fn harmonic_pair(separation: f64, k: f64, r0: f64) -> MdSystem {
MdSystem::new(
vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(separation, 0.0, 0.0)],
vec![Vec3::new(0.0, 0.0, 0.0); 2],
vec![1.0; 2],
Vec3::new(100.0, 100.0, 100.0),
false,
Potential::Harmonic { k, r0 },
50.0,
)
.unwrap()
}
#[test]
fn velocity_verlet_reproduces_the_exact_harmonic_solution() {
let k = 4.0;
let r0 = 1.0;
let amplitude = 0.2;
let mut system = harmonic_pair(r0 + amplitude, k, r0);
let omega = (2.0 * k).sqrt();
let dt = 1e-4;
let steps = 20_000;
for step in 1..=steps {
system.step_velocity_verlet(dt);
if step % 2_000 == 0 {
let t = step as f64 * dt;
let expected = r0 + amplitude * (omega * t).cos();
let actual = (system.pos[1] - system.pos[0]).x;
assert!(
close(actual, expected, 2e-4),
"at t = {t} the separation is {actual} against {expected}"
);
}
}
assert!(close(system.time, steps as f64 * dt, 1e-9));
}
#[test]
fn the_symplectic_integrator_oscillates_where_euler_runs_away() {
let k = 4.0;
let dt = 0.01;
let steps = 40_000;
let mut verlet = harmonic_pair(1.2, k, 1.0);
let samples = verlet.run_nve(steps, dt).unwrap();
let drift = energy_drift(&samples).unwrap();
assert!(drift < 1e-9, "the symplectic integrator drifted by {drift}");
let mut euler = harmonic_pair(1.2, k, 1.0);
let start = euler.sample().total;
let mut euler_samples = vec![euler.sample()];
for _ in 0..steps {
let forces = euler.forces();
for j in 0..euler.len() {
let a = forces[j] * (1.0 / euler.mass[j]);
euler.pos[j] = euler.pos[j] + euler.vel[j] * dt;
euler.vel[j] = euler.vel[j] + a * dt;
}
euler.time += dt;
euler_samples.push(euler.sample());
}
let euler_drift = energy_drift(&euler_samples).unwrap();
assert!(
euler_drift > 1e4 * drift.max(1e-12),
"Euler drifted by {euler_drift} against Verlet's {drift}, so the comparison is empty"
);
assert!(euler.sample().total > start, "Euler did not gain energy");
let spread = samples.iter().map(|s| s.total).fold(f64::NEG_INFINITY, f64::max)
- samples.iter().map(|s| s.total).fold(f64::INFINITY, f64::min);
assert!(spread > 0.0, "the total energy never moved, so nothing was measured");
}
#[test]
fn a_lennard_jones_liquid_conserves_energy_and_momentum_under_nve() {
let mut rng = Rng::new(0x011D_0010);
let mut system = MdSystem::lattice_fcc(3, 0.85, 1.5, 1.0, 1.0, &mut rng).unwrap();
system.equilibrate(400, 0.004, 0.9, &mut rng).unwrap();
let momentum_before = system.total_momentum();
let samples = system.run_nve(3_000, 0.004).unwrap();
let drift = energy_drift(&samples).unwrap();
assert!(drift < 1e-4, "the energy drifted by {drift} over three thousand steps");
let after = system.total_momentum();
assert!(
close((after - momentum_before).magnitude(), 0.0, 1e-9),
"the momentum moved by {}",
(after - momentum_before).magnitude()
);
assert!(samples.iter().all(|s| s.total.is_finite()));
assert!(system.temperature() > 0.2 && system.temperature() < 3.0);
}
#[test]
fn a_larger_step_costs_energy_conservation_in_the_expected_way() {
let mut spread = Vec::new();
for shift in 0..3 {
let dt = 0.02 / f64::from(1 << shift);
let mut system = harmonic_pair(1.3, 4.0, 1.0);
let samples = system.run_nve(4_000 * (1 << shift), dt).unwrap();
let hi = samples.iter().map(|s| s.total).fold(f64::NEG_INFINITY, f64::max);
let lo = samples.iter().map(|s| s.total).fold(f64::INFINITY, f64::min);
spread.push(hi - lo);
}
for k in 1..spread.len() {
let ratio = spread[k - 1] / spread[k];
assert!(
close(ratio, 4.0, 0.4),
"halving the step changed the energy spread by {ratio} rather than four"
);
}
}
#[test]
fn the_thermostats_reach_the_temperature_they_are_given() {
let mut rng = Rng::new(0x011D_0011);
for &target in &[0.4f64, 1.0, 2.2] {
let mut system = MdSystem::lattice_fcc(3, 0.8, 0.05, 1.0, 1.0, &mut rng).unwrap();
for _ in 0..600 {
system.step_velocity_verlet(0.004);
system.thermostat_berendsen(target, 0.1, 0.004);
}
let berendsen: f64 = (0..400)
.map(|_| {
system.step_velocity_verlet(0.004);
system.thermostat_berendsen(target, 0.1, 0.004);
system.temperature()
})
.sum::<f64>()
/ 400.0;
assert!(close(berendsen, target, 0.1 * target), "Berendsen reached {berendsen}");
let mut system = MdSystem::lattice_fcc(3, 0.8, 0.05, 1.0, 1.0, &mut rng).unwrap();
for _ in 0..1_500 {
system.step_velocity_verlet(0.004);
system.thermostat_langevin(target, 2.0, 0.004, &mut rng);
}
let langevin: f64 = (0..600)
.map(|_| {
system.step_velocity_verlet(0.004);
system.thermostat_langevin(target, 2.0, 0.004, &mut rng);
system.temperature()
})
.sum::<f64>()
/ 600.0;
assert!(close(langevin, target, 0.12 * target), "Langevin reached {langevin}");
let mut system = MdSystem::lattice_fcc(3, 0.8, 0.05, 1.0, 1.0, &mut rng).unwrap();
for _ in 0..4_000 {
system.step_velocity_verlet(0.004);
system.thermostat_nose_hoover(target, 40.0, 0.004);
}
let nose: f64 = (0..2_000)
.map(|_| {
system.step_velocity_verlet(0.004);
system.thermostat_nose_hoover(target, 40.0, 0.004);
system.temperature()
})
.sum::<f64>()
/ 2_000.0;
assert!(close(nose, target, 0.2 * target), "Nose-Hoover reached {nose}");
}
}
#[test]
fn the_langevin_thermostat_thermalises_a_free_gas_to_the_exact_distribution() {
for &gamma in &[0.5f64, 4.0] {
let mut rng = Rng::new(0x011D_0012 + (gamma * 10.0) as u64);
let target = 1.3;
let count = 400;
let mut system = MdSystem::new(
(0..count)
.map(|k| {
Vec3::new(
(k % 10) as f64 * 3.0,
((k / 10) % 10) as f64 * 3.0,
(k / 100) as f64 * 3.0,
)
})
.collect(),
vec![Vec3::new(0.0, 0.0, 0.0); count],
vec![1.0; count],
Vec3::new(30.0, 30.0, 30.0),
true,
Potential::Custom(Arc::new(|_| (0.0, 0.0))),
1.0,
)
.unwrap();
assert!(close(system.potential_energy(), 0.0, 1e-15), "the gas is not free");
for _ in 0..400 {
system.thermostat_langevin(target, gamma, 0.05, &mut rng);
}
let mut mean = 0.0;
for _ in 0..40 {
system.thermostat_langevin(target, gamma, 0.05, &mut rng);
mean += system.temperature();
}
mean /= 40.0;
assert!(
close(mean, target, 0.06 * target),
"at gamma = {gamma} the gas settled at {mean} rather than {target}"
);
let test = system.maxwell_boltzmann_check().unwrap();
assert!(
test.p_value > 0.01,
"the speeds failed a KS test against Maxwell-Boltzmann at p = {}",
test.p_value
);
}
}
#[test]
fn the_maxwell_boltzmann_check_rejects_a_distribution_that_is_merely_warm() {
let count = 300;
let speed = 1.0;
let mut rng = Rng::new(0x011D_0013);
let mut system = MdSystem::new(
(0..count)
.map(|k| Vec3::new((k % 10) as f64 * 3.0, ((k / 10) % 10) as f64 * 3.0, (k / 100) as f64 * 3.0))
.collect(),
(0..count)
.map(|_| {
let mut d = Vec3::new(
rng.next_gaussian(),
rng.next_gaussian(),
rng.next_gaussian(),
);
if d.magnitude() < 1e-9 {
d = Vec3::new(1.0, 0.0, 0.0);
}
d.normalized() * speed
})
.collect(),
vec![1.0; count],
Vec3::new(30.0, 30.0, 30.0),
true,
Potential::Custom(Arc::new(|_| (0.0, 0.0))),
1.0,
)
.unwrap();
let monodisperse = system.maxwell_boltzmann_check().unwrap();
assert!(
monodisperse.p_value < 1e-6,
"a monodisperse gas passed the test at p = {}",
monodisperse.p_value
);
let target = system.temperature();
for _ in 0..400 {
system.thermostat_langevin(target, 2.0, 0.05, &mut rng);
}
assert!(system.maxwell_boltzmann_check().unwrap().p_value > 0.01);
system.mass[0] = 2.0;
assert!(system.maxwell_boltzmann_check().is_err());
system.mass[0] = 1.0;
for v in &mut system.vel {
*v = Vec3::new(0.0, 0.0, 0.0);
}
assert!(system.maxwell_boltzmann_check().is_err());
}
#[test]
fn removing_the_drift_leaves_the_relative_motion_alone() {
let mut rng = Rng::new(0x011D_0014);
let mut system = MdSystem::lattice_fcc(2, 0.8, 1.0, 1.0, 1.0, &mut rng).unwrap();
let boost = Vec3::new(0.7, -0.3, 0.2);
for v in &mut system.vel {
*v = *v + boost;
}
let before: Vec<Vec3> = system.vel.clone();
let hot = system.temperature();
system.remove_drift();
assert!(close(system.total_momentum().magnitude(), 0.0, 1e-9));
for k in 1..system.len() {
let old = before[k] - before[0];
let new = system.vel[k] - system.vel[0];
assert!(close((old - new).magnitude(), 0.0, 1e-12));
}
assert!(system.temperature() < hot, "the drift did not inflate the temperature");
let once = system.vel.clone();
system.remove_drift();
for k in 0..system.len() {
assert!(close((once[k] - system.vel[k]).magnitude(), 0.0, 1e-12));
}
}
#[test]
fn the_degrees_of_freedom_account_for_the_conserved_momentum() {
let mut rng = Rng::new(0x011D_0015);
let periodic = MdSystem::lattice_fcc(2, 0.8, 1.0, 1.0, 1.0, &mut rng).unwrap();
assert!(close(periodic.degrees_of_freedom(), 3.0 * 32.0 - 3.0, 1e-12));
assert!(close(
periodic.temperature(),
2.0 * periodic.kinetic_energy() / (3.0 * 32.0 - 3.0),
1e-12
));
let open = MdSystem::new(
vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 0.0)],
vec![Vec3::new(1.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0)],
vec![1.0; 2],
Vec3::new(50.0, 50.0, 50.0),
false,
Potential::LennardJones { eps: 1.0, sigma: 1.0 },
3.0,
)
.unwrap();
assert!(close(open.degrees_of_freedom(), 6.0, 1e-12));
assert!(close(open.kinetic_energy(), 1.0, 1e-12));
assert!(close(open.temperature(), 2.0 / 6.0, 1e-12));
}
#[test]
fn the_barostat_moves_the_pressure_toward_its_target() {
let mut rng = Rng::new(0x011D_0016);
let mut system = MdSystem::lattice_fcc(3, 0.75, 1.0, 1.0, 1.0, &mut rng).unwrap();
system.equilibrate(300, 0.004, 1.0, &mut rng).unwrap();
let target = system.pressure_virial() + 1.0;
let start = (system.pressure_virial() - target).abs();
for _ in 0..400 {
system.step_velocity_verlet(0.004);
system.thermostat_berendsen(1.0, 0.1, 0.004);
system.barostat_berendsen(target, 0.05, 0.5, 0.004).unwrap();
}
let end = (system.pressure_virial() - target).abs();
assert!(end < start, "the pressure went from {start} away to {end} away");
assert!(close(system.len() as f64 / system.volume() * system.volume(), 108.0, 1e-9));
assert!(system.barostat_berendsen(1.0, 0.05, 0.0, 0.004).is_err());
assert!(system.barostat_berendsen(1.0, 0.0, 0.5, 0.004).is_err());
assert!(system.barostat_berendsen(1e6, 1.0, 1e-6, 1.0).is_err());
}
#[test]
fn energy_drift_measures_the_trend_and_not_the_wobble() {
let wobble: Vec<MdSample> = (0..400)
.map(|k| {
let t = k as f64 * 0.01;
let e = 100.0 + (t * 7.0).sin();
MdSample { time: t, kinetic: e, potential: 0.0, total: e, temperature: 1.0, pressure: 0.0 }
})
.collect();
let climb: Vec<MdSample> = (0..400)
.map(|k| {
let t = k as f64 * 0.01;
let e = 100.0 + 0.1 * t;
MdSample { time: t, kinetic: e, potential: 0.0, total: e, temperature: 1.0, pressure: 0.0 }
})
.collect();
let wobble_spread = 2.0;
let climb_spread = 0.4;
assert!(climb_spread < wobble_spread, "the fixture does not make the point");
assert!(energy_drift(&wobble).unwrap() < 1e-3);
assert!(close(energy_drift(&climb).unwrap(), 0.004, 1e-4));
assert!(energy_drift(&wobble[..2]).is_err());
let flat: Vec<MdSample> = vec![wobble[0]; 5];
assert!(energy_drift(&flat).is_err());
}
#[test]
fn the_fcc_lattice_has_the_density_and_neighbour_count_it_claims() {
let mut rng = Rng::new(0x011D_0004);
for cells in [2usize, 3, 4] {
for &density in &[0.6f64, 0.85, 1.1] {
let system = MdSystem::lattice_fcc(cells, density, 0.8, 1.0, 1.0, &mut rng).unwrap();
assert_eq!(system.len(), 4 * cells * cells * cells);
assert!(close(system.len() as f64 / system.volume(), density, 1e-9));
assert!(!system.is_empty());
let a = system.box_size.x / cells as f64;
let nearest = a / 2f64.sqrt();
let mut neighbours = 0;
for j in 1..system.len() {
let r = system.minimum_image(system.pos[0] - system.pos[j]).magnitude();
if r < nearest * 1.05 {
neighbours += 1;
}
}
assert_eq!(neighbours, 12, "an FCC site has twelve nearest neighbours");
assert!(close(system.total_momentum().magnitude(), 0.0, 1e-9));
assert!(close(system.temperature(), 0.8, 1e-9));
}
}
}
}