#![allow(clippy::needless_range_loop)]
use rustfft::{FftPlanner, num_complex::Complex};
use volterra_core::ActiveNematicParams3D;
use volterra_fields::{QField3D, ScalarField3D};
pub fn ch_step_etd_3d(
phi: &ScalarField3D,
q_lip: &QField3D,
p: &ActiveNematicParams3D,
dt: f64,
) -> ScalarField3D {
let nx = phi.nx;
let ny = phi.ny;
let nz = phi.nz;
let n = nx * ny * nz;
let dx = phi.dx;
let mut g = vec![0.0f64; n];
for k in 0..n {
let phi_v = phi.phi[k];
let [q11, q12, q13, q22, q23] = q_lip.q[k];
let q33 = -(q11 + q22);
let tr_q2 = q11 * q11
+ q22 * q22
+ q33 * q33
+ 2.0 * (q12 * q12 + q13 * q13 + q23 * q23);
g[k] = p.m_l * (p.a_ch * phi_v + p.b_ch * phi_v * phi_v * phi_v - p.chi_ms * tr_q2);
}
let mut planner = FftPlanner::<f64>::new();
let mut phi_hat: Vec<Complex<f64>> = phi.phi.iter().map(|&v| Complex::new(v, 0.0)).collect();
let mut g_hat: Vec<Complex<f64>> = g.iter().map(|&v| Complex::new(v, 0.0)).collect();
let fft_x = planner.plan_fft_forward(nx);
let fft_y = planner.plan_fft_forward(ny);
let fft_z = planner.plan_fft_forward(nz);
for data in [&mut phi_hat, &mut g_hat] {
for i in 0..nx {
for j in 0..ny {
let mut row: Vec<Complex<f64>> =
(0..nz).map(|l| data[phi.idx(i, j, l)]).collect();
fft_z.process(&mut row);
for l in 0..nz {
data[phi.idx(i, j, l)] = row[l];
}
}
}
for i in 0..nx {
for l in 0..nz {
let mut row: Vec<Complex<f64>> =
(0..ny).map(|j| data[phi.idx(i, j, l)]).collect();
fft_y.process(&mut row);
for j in 0..ny {
data[phi.idx(i, j, l)] = row[j];
}
}
}
for j in 0..ny {
for l in 0..nz {
let mut row: Vec<Complex<f64>> =
(0..nx).map(|i| data[phi.idx(i, j, l)]).collect();
fft_x.process(&mut row);
for i in 0..nx {
data[phi.idx(i, j, l)] = row[i];
}
}
}
}
let mut phi_hat_new: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx {
for j in 0..ny {
for l in 0..nz {
let k = phi.idx(i, j, l);
let kx = wavenumber(i, nx, dx);
let ky = wavenumber(j, ny, dx);
let kz = wavenumber(l, nz, dx);
let k2 = kx * kx + ky * ky + kz * kz;
let k4 = k2 * k2;
let big_l = -p.m_l * p.kappa_ch * k4;
let n_hat = g_hat[k] * Complex::new(-k2, 0.0);
if k4 < 1e-14 {
phi_hat_new[k] = phi_hat[k] + n_hat * Complex::new(dt, 0.0);
} else {
let e_l = (big_l * dt).exp();
phi_hat_new[k] = phi_hat[k] * Complex::new(e_l, 0.0)
+ n_hat * Complex::new((e_l - 1.0) / big_l, 0.0);
}
}
}
}
let ifft_x = planner.plan_fft_inverse(nx);
let ifft_y = planner.plan_fft_inverse(ny);
let ifft_z = planner.plan_fft_inverse(nz);
for i in 0..nx {
for j in 0..ny {
let mut row: Vec<Complex<f64>> =
(0..nz).map(|l| phi_hat_new[phi.idx(i, j, l)]).collect();
ifft_z.process(&mut row);
for l in 0..nz {
phi_hat_new[phi.idx(i, j, l)] = row[l];
}
}
}
for i in 0..nx {
for l in 0..nz {
let mut row: Vec<Complex<f64>> =
(0..ny).map(|j| phi_hat_new[phi.idx(i, j, l)]).collect();
ifft_y.process(&mut row);
for j in 0..ny {
phi_hat_new[phi.idx(i, j, l)] = row[j];
}
}
}
for j in 0..ny {
for l in 0..nz {
let mut row: Vec<Complex<f64>> =
(0..nx).map(|i| phi_hat_new[phi.idx(i, j, l)]).collect();
ifft_x.process(&mut row);
for i in 0..nx {
phi_hat_new[phi.idx(i, j, l)] = row[i];
}
}
}
let norm = 1.0 / (n as f64);
let mut phi_new = ScalarField3D::zeros(nx, ny, nz, dx);
for k in 0..n {
phi_new.phi[k] = phi_hat_new[k].re * norm;
}
phi_new
}
#[inline]
fn wavenumber(idx: usize, n: usize, dx: f64) -> f64 {
let m = if idx <= n / 2 {
idx as f64
} else {
idx as f64 - n as f64
};
let theta = 2.0 * std::f64::consts::PI * m / n as f64;
theta.sin() / dx
}
pub fn ch_step_etd_enriched_3d(
phi: &ScalarField3D,
q_lip: &QField3D,
p: &ActiveNematicParams3D,
dt: f64,
) -> ScalarField3D {
if p.kappa_w == 0.0 && p.kappa_bar_g == 0.0 {
return ch_step_etd_3d(phi, q_lip, p, dt);
}
let nx = phi.nx;
let ny = phi.ny;
let nz = phi.nz;
let n = nx * ny * nz;
let dx = phi.dx;
let eps = p.epsilon_ch;
let kappa_eff = p.kappa_eff();
let (kg_field, grad_field) = if p.kappa_bar_g != 0.0 {
crate::gauss_bonnet_3d::compute_kg_field(phi)
} else {
(vec![0.0f64; n], vec![0.0f64; n])
};
let mut g = vec![0.0f64; n];
for idx in 0..n {
let phi_v = phi.phi[idx];
let [q11, q12, q13, q22, q23] = q_lip.q[idx];
let q33 = -(q11 + q22);
let tr_q2 = q11*q11 + q22*q22 + q33*q33 + 2.0*(q12*q12 + q13*q13 + q23*q23);
let g_ch = p.m_l * (p.a_ch * phi_v + p.b_ch * phi_v.powi(3) - p.chi_ms * tr_q2);
let g_kg = if p.kappa_bar_g != 0.0 && eps > 0.0 {
p.m_l * p.kappa_bar_g * kg_field[idx] * grad_field[idx] / eps
} else {
0.0
};
g[idx] = g_ch + g_kg;
}
use rustfft::{FftPlanner, num_complex::Complex};
let mut planner = FftPlanner::<f64>::new();
let mut phi_hat: Vec<Complex<f64>> = phi.phi.iter().map(|&v| Complex::new(v, 0.0)).collect();
let mut g_hat: Vec<Complex<f64>> = g.iter().map(|&v| Complex::new(v, 0.0)).collect();
let fft_x = planner.plan_fft_forward(nx);
let fft_y = planner.plan_fft_forward(ny);
let fft_z = planner.plan_fft_forward(nz);
for data in [&mut phi_hat, &mut g_hat] {
for i in 0..nx { for j in 0..ny {
let mut row: Vec<Complex<f64>> = (0..nz).map(|l| data[phi.idx(i,j,l)]).collect();
fft_z.process(&mut row);
for l in 0..nz { data[phi.idx(i,j,l)] = row[l]; }
}}
for i in 0..nx { for l in 0..nz {
let mut row: Vec<Complex<f64>> = (0..ny).map(|j| data[phi.idx(i,j,l)]).collect();
fft_y.process(&mut row);
for j in 0..ny { data[phi.idx(i,j,l)] = row[j]; }
}}
for j in 0..ny { for l in 0..nz {
let mut row: Vec<Complex<f64>> = (0..nx).map(|i| data[phi.idx(i,j,l)]).collect();
fft_x.process(&mut row);
for i in 0..nx { data[phi.idx(i,j,l)] = row[i]; }
}}
}
let mut phi_hat_new: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx { for j in 0..ny { for l in 0..nz {
let idx = phi.idx(i, j, l);
let kx = wavenumber(i, nx, dx);
let ky = wavenumber(j, ny, dx);
let kz = wavenumber(l, nz, dx);
let k2 = kx*kx + ky*ky + kz*kz;
let k4 = k2*k2;
let big_l = -p.m_l * kappa_eff * k4;
let n_hat = g_hat[idx] * Complex::new(-k2, 0.0);
if k4 < 1e-14 {
phi_hat_new[idx] = phi_hat[idx] + n_hat * Complex::new(dt, 0.0);
} else {
let e_l = (big_l * dt).exp();
phi_hat_new[idx] = phi_hat[idx] * Complex::new(e_l, 0.0)
+ n_hat * Complex::new((e_l - 1.0) / big_l, 0.0);
}
}}}
let ifft_x = planner.plan_fft_inverse(nx);
let ifft_y = planner.plan_fft_inverse(ny);
let ifft_z = planner.plan_fft_inverse(nz);
for i in 0..nx { for j in 0..ny {
let mut row: Vec<Complex<f64>> = (0..nz).map(|l| phi_hat_new[phi.idx(i,j,l)]).collect();
ifft_z.process(&mut row);
for l in 0..nz { phi_hat_new[phi.idx(i,j,l)] = row[l]; }
}}
for i in 0..nx { for l in 0..nz {
let mut row: Vec<Complex<f64>> = (0..ny).map(|j| phi_hat_new[phi.idx(i,j,l)]).collect();
ifft_y.process(&mut row);
for j in 0..ny { phi_hat_new[phi.idx(i,j,l)] = row[j]; }
}}
for j in 0..ny { for l in 0..nz {
let mut row: Vec<Complex<f64>> = (0..nx).map(|i| phi_hat_new[phi.idx(i,j,l)]).collect();
ifft_x.process(&mut row);
for i in 0..nx { phi_hat_new[phi.idx(i,j,l)] = row[i]; }
}}
let norm = 1.0 / (n as f64);
let mut phi_new = ScalarField3D::zeros(nx, ny, nz, dx);
for idx in 0..n {
phi_new.phi[idx] = phi_hat_new[idx].re * norm;
}
phi_new
}
pub fn enriched_ch_dt_bound(p: &ActiveNematicParams3D) -> f64 {
if p.kappa_bar_g == 0.0 || p.m_l == 0.0 {
return f64::INFINITY;
}
let k_max = std::f64::consts::PI / p.dx;
0.1 * p.epsilon_ch.powi(3) / (p.m_l * p.kappa_bar_g.abs() * k_max.powi(6))
}
#[cfg(test)]
mod tests {
use super::*;
use volterra_core::ActiveNematicParams3D;
use volterra_fields::{QField3D, ScalarField3D};
#[test]
fn test_enriched_ch_matches_plain_when_no_curvature() {
let mut p = ActiveNematicParams3D::default_test();
p.kappa_w = 0.0;
p.kappa_bar_g = 0.0;
p.epsilon_ch = 1.0;
let phi = ScalarField3D::uniform(8, 8, 8, 1.0, 0.3);
let q_lip = QField3D::zeros(8, 8, 8, 1.0);
let phi_plain = ch_step_etd_3d(&phi, &q_lip, &p, p.dt);
let phi_enriched = ch_step_etd_enriched_3d(&phi, &q_lip, &p, p.dt);
let max_diff: f64 = phi_plain.phi.iter().zip(phi_enriched.phi.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff < 1e-12,
"enriched with zero curvature params must equal plain CH, max_diff={}",
max_diff
);
}
#[test]
fn test_enriched_ch_conserves_mass() {
let mut p = ActiveNematicParams3D::default_test();
p.a_ch = 0.0;
p.b_ch = 0.0;
p.chi_ms = 0.0;
p.kappa_w = 0.5;
p.kappa_bar_g = -0.1;
p.epsilon_ch = 1.5;
let n = 8usize;
let cx = n as f64 / 2.0;
let mut phi = ScalarField3D::zeros(n, n, n, 1.0);
for i in 0..n { for j in 0..n { for k in 0..n {
let r = ((i as f64 - cx).powi(2) + (j as f64 - cx).powi(2)
+ (k as f64 - cx).powi(2)).sqrt();
let vi = phi.idx(i,j,k);
phi.phi[vi] = 0.5*(1.0 + ((r - 3.0)/1.5).tanh());
}}}
let q_lip = QField3D::zeros(n, n, n, 1.0);
let phi_new = ch_step_etd_enriched_3d(&phi, &q_lip, &p, p.dt);
let delta = (phi_new.mean() - phi.mean()).abs();
assert!(delta < 1e-10, "enriched CH must conserve mass, delta={}", delta);
}
#[test]
fn test_enriched_ch_differs_with_nonzero_kappa_bar_g() {
let mut p = ActiveNematicParams3D::default_test();
p.a_ch = 0.0;
p.b_ch = 0.0;
p.chi_ms = 0.0;
p.kappa_w = 0.0;
p.c0_sp = 0.0;
p.kappa_bar_g = 0.5;
p.epsilon_ch = 1.5;
p.dt = 1e-4;
let n = 8usize;
let cx = n as f64 / 2.0;
let mut phi = ScalarField3D::zeros(n, n, n, 1.0);
for i in 0..n { for j in 0..n { for k in 0..n {
let r = ((i as f64 - cx).powi(2) + (j as f64 - cx).powi(2)
+ (k as f64 - cx).powi(2)).sqrt();
let vi = phi.idx(i,j,k);
phi.phi[vi] = 0.5*(1.0 + ((r - 3.0)/1.5).tanh());
}}}
let q_lip = QField3D::zeros(n, n, n, 1.0);
let phi_plain = ch_step_etd_3d(&phi, &q_lip, &p, p.dt);
let phi_enriched = ch_step_etd_enriched_3d(&phi, &q_lip, &p, p.dt);
let max_diff: f64 = phi_plain.phi.iter().zip(phi_enriched.phi.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff > 1e-14,
"enriched with nonzero kappa_bar_g + interface must differ from plain CH"
);
}
#[test]
fn test_enriched_ch_differs_with_nonzero_c0() {
let mut p = ActiveNematicParams3D::default_test();
p.a_ch = 0.0;
p.b_ch = 0.0;
p.chi_ms = 0.0;
p.kappa_w = 1.0;
p.c0_sp = -0.5;
p.epsilon_ch = 1.0;
p.kappa_bar_g = 0.0;
let n = 8usize;
let mut phi = ScalarField3D::zeros(n, n, n, 1.0);
for i in 0..n { for j in 0..n { for k in 0..n {
let vi = phi.idx(i,j,k);
phi.phi[vi] = 0.5 + 0.1*(i as f64).sin();
}}}
let q_lip = QField3D::zeros(n, n, n, 1.0);
let phi_plain = ch_step_etd_3d(&phi, &q_lip, &p, p.dt);
let phi_enriched = ch_step_etd_enriched_3d(&phi, &q_lip, &p, p.dt);
let max_diff: f64 = phi_plain.phi.iter().zip(phi_enriched.phi.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff > 1e-12,
"enriched with nonzero c0_sp must differ from plain CH"
);
}
#[test]
fn test_ch_etd_3d_conserves_mass() {
let mut p = ActiveNematicParams3D::default_test();
p.a_ch = 0.0;
p.b_ch = 0.0;
p.chi_ms = 0.0;
let phi = ScalarField3D::uniform(8, 8, 8, 1.0, 0.3);
let q_lip = QField3D::zeros(8, 8, 8, 1.0);
let phi_new = ch_step_etd_3d(&phi, &q_lip, &p, p.dt);
let mean_before = phi.mean();
let mean_after = phi_new.mean();
assert!(
(mean_after - mean_before).abs() < 1e-10,
"CH must conserve total concentration under pure linear diffusion, \
got delta={}",
(mean_after - mean_before).abs()
);
}
}