use ndarray::Array1;
use rustfft::FftPlanner;
use crate::compute::error::ComputeError;
use crate::compute::fit::forward_fft_onesided;
use crate::compute::result::ComputeResult;
use crate::compute::traits::Fit;
use molrs::signal as sig;
use molrs::units::constants::BOLTZMANN_REAL as K_B;
use molrs::units::constants::COULOMB_REAL as KAPPA;
const FOUR_PI_OVER_3: f64 = 4.1887902047863905;
#[derive(Debug, Clone)]
pub struct DielectricSpectrumResult {
pub frequencies: Array1<f64>,
pub eps_real: Array1<f64>,
pub eps_imag: Array1<f64>,
}
impl ComputeResult for DielectricSpectrumResult {}
type RawSpectrum = (Array1<f64>, Array1<f64>, Array1<f64>);
fn acf_to_spectrum(
planner: &mut FftPlanner<f64>,
acf: &Array1<f64>,
dt: f64,
n_pad: usize,
) -> RawSpectrum {
let acf_vec;
let acf_slice = match acf.as_slice() {
Some(s) => s,
None => {
acf_vec = acf.to_vec();
&acf_vec
}
};
let bins = forward_fft_onesided(planner, acf_slice, n_pad);
let frequencies = sig::frequency_grid(n_pad, dt);
let n_freq = frequencies.len();
let mut spec_re = Array1::zeros(n_freq);
let mut spec_im = Array1::zeros(n_freq);
for j in 0..n_freq {
let z = bins[j];
spec_re[j] = z.re * dt;
spec_im[j] = z.im * dt;
}
(frequencies, spec_re, spec_im)
}
fn taper_derivative_spectrum(acf: &Array1<f64>, dt: f64) -> RawSpectrum {
let max_lag = acf.len() - 1;
let mut tapered = acf.clone();
let denom = 2.0 * max_lag.max(1) as f64;
for k in 0..=max_lag {
let angle = std::f64::consts::PI * k as f64 / denom;
tapered[k] *= angle.cos().powi(2);
}
let mut deriv = Array1::<f64>::zeros(max_lag + 1);
for k in 1..max_lag {
deriv[k] = (tapered[k + 1] - tapered[k - 1]) / (2.0 * dt);
}
if max_lag >= 1 {
deriv[max_lag] = (tapered[max_lag] - tapered[max_lag - 1]) / dt;
}
let n_pad = (2 * (max_lag + 1)).next_power_of_two();
let mut planner = FftPlanner::new();
acf_to_spectrum(&mut planner, &deriv, dt, n_pad)
}
fn parse_window_type(s: &str) -> Result<sig::WindowType, ComputeError> {
match s {
"hann" => Ok(sig::WindowType::Hann),
"blackman" => Ok(sig::WindowType::Blackman),
"cosine_sq" => Ok(sig::WindowType::CosineSq),
other => Err(ComputeError::OutOfRange {
field: "window_type",
value: other.into(),
}),
}
}
fn windowed_acf_spectrum(
acf: &Array1<f64>,
dt: f64,
window_type: &str,
) -> Result<RawSpectrum, ComputeError> {
let max_lag = acf.len() - 1;
let wt = parse_window_type(window_type)?;
let acf_dyn = ndarray::ArrayD::from_shape_vec(ndarray::IxDyn(&[max_lag + 1]), acf.to_vec())
.map_err(|e| ComputeError::BadShape {
expected: "1d".into(),
got: e.to_string(),
})?;
let windowed = sig::apply_window(&acf_dyn, wt, 0).map_err(|e| ComputeError::OutOfRange {
field: "apply_window",
value: e.to_string(),
})?;
let windowed_1d: Array1<f64> = windowed.iter().copied().collect();
let n_pad = (2 * (max_lag + 1)).next_power_of_two();
let mut planner = FftPlanner::new();
Ok(acf_to_spectrum(&mut planner, &windowed_1d, dt, n_pad))
}
#[derive(Debug, Clone, Copy)]
pub struct EinsteinHelfandSpectrum {
pub dt: f64,
pub volume: f64,
pub temperature: f64,
pub epsilon_inf: f64,
pub zero_lag_variance: f64,
}
impl Fit for EinsteinHelfandSpectrum {
type Input<'a> = &'a Array1<f64>;
type Output = DielectricSpectrumResult;
fn fit<'a>(&self, acf: Self::Input<'a>) -> Result<Self::Output, ComputeError> {
if acf.len() < 2 {
return Err(ComputeError::EmptyInput);
}
validate_thermo(self.dt, self.volume, self.temperature)?;
let (frequencies, dre, dim) = taper_derivative_spectrum(acf, self.dt);
let prefactor = FOUR_PI_OVER_3 * KAPPA / (self.volume * K_B * self.temperature);
let n_freq = frequencies.len();
let mut eps_real = Array1::zeros(n_freq);
let mut eps_imag = Array1::zeros(n_freq);
for j in 0..n_freq {
eps_real[j] = self.epsilon_inf - prefactor * dre[j];
eps_imag[j] = prefactor * dim[j];
}
eps_real[0] = self.epsilon_inf + prefactor * self.zero_lag_variance;
eps_imag[0] = 0.0;
Ok(DielectricSpectrumResult {
frequencies,
eps_real,
eps_imag,
})
}
}
#[derive(Debug, Clone)]
pub struct GreenKuboSpectrum {
pub dt: f64,
pub volume: f64,
pub temperature: f64,
pub epsilon_inf: f64,
pub window_type: String,
}
impl Fit for GreenKuboSpectrum {
type Input<'a> = &'a Array1<f64>;
type Output = DielectricSpectrumResult;
fn fit<'a>(&self, acf: Self::Input<'a>) -> Result<Self::Output, ComputeError> {
if acf.len() < 2 {
return Err(ComputeError::EmptyInput);
}
validate_thermo(self.dt, self.volume, self.temperature)?;
let (frequencies, spec_re, spec_im) =
windowed_acf_spectrum(acf, self.dt, &self.window_type)?;
let sigma_prefactor = self.volume / (3.0 * K_B * self.temperature);
let eps0_factor = 4.0 * std::f64::consts::PI * KAPPA;
let n_freq = frequencies.len();
let mut eps_real = Array1::zeros(n_freq);
let mut eps_imag = Array1::zeros(n_freq);
for j in 0..n_freq {
let omega = frequencies[j];
if omega == 0.0 {
eps_real[j] = self.epsilon_inf;
eps_imag[j] = 0.0;
} else {
let sigma_re = sigma_prefactor * spec_re[j];
let sigma_im = sigma_prefactor * spec_im[j];
eps_real[j] = self.epsilon_inf + eps0_factor * sigma_im / omega;
eps_imag[j] = eps0_factor * sigma_re / omega;
}
}
Ok(DielectricSpectrumResult {
frequencies,
eps_real,
eps_imag,
})
}
}
fn validate_thermo(dt: f64, volume: f64, temperature: f64) -> Result<(), ComputeError> {
if dt <= 0.0 {
return Err(ComputeError::OutOfRange {
field: "dt",
value: dt.to_string(),
});
}
if volume <= 0.0 {
return Err(ComputeError::OutOfRange {
field: "volume",
value: volume.to_string(),
});
}
if temperature <= 0.0 {
return Err(ComputeError::OutOfRange {
field: "temperature",
value: temperature.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compute::traits::Compute;
use crate::compute::transport::{DebyeRelaxation, EwaldBoundary, GreenKuboConductivity};
use molrs::Frame;
use ndarray::Array2;
use rustfft::FftPlanner;
fn no_frames() -> Vec<&'static Frame> {
Vec::new()
}
fn rng_dipole(n: usize, seed: u64) -> Array2<f64> {
use rand::{RngExt, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let mut s = Array2::zeros((n, 3));
for t in 0..n {
for d in 0..3 {
s[[t, d]] = rng.random::<f64>() - 0.5;
}
}
s
}
#[allow(clippy::too_many_arguments)]
fn legacy_einstein_helfand(
dipole_moments: &Array2<f64>,
dt: f64,
volume: f64,
temperature: f64,
epsilon_inf: f64,
max_correlation_time: usize,
) -> (Array1<f64>, Array1<f64>, Array1<f64>) {
let n_frames = dipole_moments.shape()[0];
let max_lag = max_correlation_time.min(n_frames - 1);
let n = n_frames as f64;
let mut mean_m = [0.0_f64; 3];
for t in 0..n_frames {
for d in 0..3 {
mean_m[d] += dipole_moments[[t, d]];
}
}
for m in mean_m.iter_mut() {
*m /= n;
}
let mut m_sq = 0.0;
for t in 0..n_frames {
for d in 0..3 {
let dev = dipole_moments[[t, d]] - mean_m[d];
m_sq += dev * dev;
}
}
m_sq /= n;
let mut planner = FftPlanner::new();
let mut acf = Array1::<f64>::zeros(max_lag + 1);
for d in 0..3 {
let col: Array1<f64> = (0..n_frames)
.map(|t| dipole_moments[[t, d]] - mean_m[d])
.collect();
let component = sig::acf_fft_with_planner(&mut planner, &col, max_lag).unwrap();
for k in 0..=max_lag {
acf[k] += component[k];
}
}
for k in 0..=max_lag {
acf[k] /= (n_frames - k) as f64;
}
let denom = 2.0 * max_lag.max(1) as f64;
for k in 0..=max_lag {
let angle = std::f64::consts::PI * k as f64 / denom;
acf[k] *= angle.cos().powi(2);
}
let mut deriv = Array1::<f64>::zeros(max_lag + 1);
for k in 1..max_lag {
deriv[k] = (acf[k + 1] - acf[k - 1]) / (2.0 * dt);
}
if max_lag >= 1 {
deriv[max_lag] = (acf[max_lag] - acf[max_lag - 1]) / dt;
}
let n_pad = (2 * (max_lag + 1)).next_power_of_two();
let (frequencies, dre, dim) = acf_to_spectrum(&mut planner, &deriv, dt, n_pad);
let prefactor = FOUR_PI_OVER_3 * KAPPA / (volume * K_B * temperature);
let n_freq = frequencies.len();
let mut eps_real = Array1::zeros(n_freq);
let mut eps_imag = Array1::zeros(n_freq);
for j in 0..n_freq {
eps_real[j] = epsilon_inf - prefactor * dre[j];
eps_imag[j] = prefactor * dim[j];
}
eps_real[0] = epsilon_inf + prefactor * m_sq;
eps_imag[0] = 0.0;
(frequencies, eps_real, eps_imag)
}
fn legacy_gk_acf(current: &Array2<f64>, max_lag: usize) -> Array1<f64> {
let n_frames = current.shape()[0];
let start = 1;
let n_eff = n_frames - start;
let mut planner = FftPlanner::new();
let mut acf_sum = Array1::<f64>::zeros(max_lag + 1);
for d in 0..3 {
let col: Array1<f64> = (start..n_frames).map(|t| current[[t, d]]).collect();
let acf = sig::acf_fft_with_planner(&mut planner, &col, max_lag).unwrap();
for k in 0..=max_lag {
acf_sum[k] += acf[k];
}
}
for k in 0..=max_lag {
acf_sum[k] /= (n_eff - k) as f64;
}
acf_sum
}
fn legacy_green_kubo_from_acf(
acf: &Array1<f64>,
dt: f64,
volume: f64,
temperature: f64,
epsilon_inf: f64,
window_type: &str,
) -> (Array1<f64>, Array1<f64>, Array1<f64>) {
let max_lag = acf.len() - 1;
let wt = parse_window_type(window_type).unwrap();
let acf_dyn =
ndarray::ArrayD::from_shape_vec(ndarray::IxDyn(&[max_lag + 1]), acf.to_vec()).unwrap();
let windowed = sig::apply_window(&acf_dyn, wt, 0).unwrap();
let windowed_1d: Array1<f64> = windowed.iter().copied().collect();
let n_pad = (2 * (max_lag + 1)).next_power_of_two();
let mut planner = FftPlanner::new();
let (frequencies, spec_re, spec_im) =
acf_to_spectrum(&mut planner, &windowed_1d, dt, n_pad);
let sigma_prefactor = volume / (3.0 * K_B * temperature);
let eps0_factor = 4.0 * std::f64::consts::PI * KAPPA;
let n_freq = frequencies.len();
let mut eps_real = Array1::zeros(n_freq);
let mut eps_imag = Array1::zeros(n_freq);
for j in 0..n_freq {
let omega = frequencies[j];
if omega == 0.0 {
eps_real[j] = epsilon_inf;
eps_imag[j] = 0.0;
} else {
let sigma_re = sigma_prefactor * spec_re[j];
let sigma_im = sigma_prefactor * spec_im[j];
eps_real[j] = epsilon_inf + eps0_factor * sigma_im / omega;
eps_imag[j] = eps0_factor * sigma_re / omega;
}
}
(frequencies, eps_real, eps_imag)
}
#[test]
fn eh_fit_reproduces_legacy_bit_for_bit() {
let n = 256;
let dt = 0.001;
let (vol, temp, eps_inf) = (1000.0, 300.0, 1.5);
let mct = 50;
let dm = rng_dipole(n, 42);
let (freq_l, re_l, im_l) = legacy_einstein_helfand(&dm, dt, vol, temp, eps_inf, mct);
let raw = DebyeRelaxation {
volume: vol,
temperature: temp,
boundary: EwaldBoundary::TinFoil,
}
.compute(&no_frames(), (&dm, dt, mct))
.unwrap();
let fit = EinsteinHelfandSpectrum {
dt,
volume: vol,
temperature: temp,
epsilon_inf: eps_inf,
zero_lag_variance: raw.zero_lag_variance,
}
.fit(&raw.acf)
.unwrap();
assert_eq!(fit.frequencies, freq_l);
assert_eq!(fit.eps_real, re_l);
assert_eq!(fit.eps_imag, im_l);
}
#[test]
fn gk_fit_reproduces_legacy_bit_for_bit() {
let n = 256;
let dt = 0.001;
let (vol, temp, eps_inf) = (1000.0, 300.0, 1.0);
let mct = 50;
let mut current = rng_dipole(n, 7);
for d in 0..3 {
current[[0, d]] = f64::NAN;
}
let start = 1;
let effective_len = n - start;
let max_lag = mct.min(effective_len.saturating_sub(1));
let raw_acf = legacy_gk_acf(¤t, max_lag);
for window in ["hann", "blackman", "cosine_sq"] {
let (freq_l, re_l, im_l) =
legacy_green_kubo_from_acf(&raw_acf, dt, vol, temp, eps_inf, window);
let fit = GreenKuboSpectrum {
dt,
volume: vol,
temperature: temp,
epsilon_inf: eps_inf,
window_type: window.to_string(),
}
.fit(&raw_acf)
.unwrap();
assert_eq!(fit.frequencies, freq_l, "window={window}");
assert_eq!(fit.eps_real, re_l, "window={window}");
assert_eq!(fit.eps_imag, im_l, "window={window}");
}
}
#[test]
fn gk_raw_compute_acf_matches_legacy_fft_acf() {
let n = 256;
let dt = 0.001;
let mct = 50;
let mut current = rng_dipole(n, 7);
for d in 0..3 {
current[[0, d]] = f64::NAN;
}
let start = 1;
let effective_len = n - start;
let max_lag = mct.min(effective_len.saturating_sub(1));
let legacy = legacy_gk_acf(¤t, max_lag);
let post: Array2<f64> = current.slice(ndarray::s![start.., ..]).to_owned();
let raw = GreenKuboConductivity
.compute(&no_frames(), (&post, dt, max_lag))
.unwrap();
assert_eq!(raw.jacf.len(), legacy.len());
for k in 0..legacy.len() {
assert!((raw.jacf[k] - legacy[k]).abs() < 1e-9, "k={k}");
}
}
#[test]
fn eh_dc_bin_recovers_neumann_static() {
use crate::compute::static_dielectric_constant;
let n = 256;
let dt = 0.001;
let (vol, temp, eps_inf) = (1000.0, 300.0, 1.5);
let dm = rng_dipole(n, 99);
let static_eps = static_dielectric_constant(&dm, vol, temp, eps_inf).unwrap();
let raw = DebyeRelaxation {
volume: vol,
temperature: temp,
boundary: EwaldBoundary::TinFoil,
}
.compute(&no_frames(), (&dm, dt, 50))
.unwrap();
let fit = EinsteinHelfandSpectrum {
dt,
volume: vol,
temperature: temp,
epsilon_inf: eps_inf,
zero_lag_variance: raw.zero_lag_variance,
}
.fit(&raw.acf)
.unwrap();
assert!((fit.eps_real[0] - static_eps).abs() < 1e-10);
assert_eq!(fit.eps_imag[0], 0.0);
}
#[test]
fn eh_loss_finite_to_nyquist() {
let n = 2048;
let dt = 1.0;
let dm = rng_dipole(n, 11);
let raw = DebyeRelaxation {
volume: 1000.0,
temperature: 300.0,
boundary: EwaldBoundary::TinFoil,
}
.compute(&no_frames(), (&dm, dt, 100))
.unwrap();
let fit = EinsteinHelfandSpectrum {
dt,
volume: 1000.0,
temperature: 300.0,
epsilon_inf: 1.0,
zero_lag_variance: raw.zero_lag_variance,
}
.fit(&raw.acf)
.unwrap();
assert!(fit.eps_imag.iter().all(|x| x.is_finite()));
assert!(fit.eps_real.iter().all(|x| x.is_finite()));
}
#[test]
fn fits_reject_short_acf() {
let tiny = Array1::from_vec(vec![1.0]);
assert!(matches!(
EinsteinHelfandSpectrum {
dt: 1.0,
volume: 1.0,
temperature: 1.0,
epsilon_inf: 1.0,
zero_lag_variance: 1.0,
}
.fit(&tiny),
Err(ComputeError::EmptyInput)
));
assert!(matches!(
GreenKuboSpectrum {
dt: 1.0,
volume: 1.0,
temperature: 1.0,
epsilon_inf: 1.0,
window_type: "hann".into(),
}
.fit(&tiny),
Err(ComputeError::EmptyInput)
));
}
#[test]
fn gk_rejects_bad_window() {
let acf = Array1::from_vec(vec![1.0, 0.5, 0.25]);
assert!(matches!(
GreenKuboSpectrum {
dt: 1.0,
volume: 1.0,
temperature: 1.0,
epsilon_inf: 1.0,
window_type: "nope".into(),
}
.fit(&acf),
Err(ComputeError::OutOfRange { .. })
));
}
}