#![allow(clippy::needless_range_loop)]
use rand::SeedableRng;
use rand::rngs::SmallRng;
use rand::Rng;
use rustfft::{FftPlanner, num_complex::Complex};
use volterra_core::{Integrator, ActiveNematicParams};
use volterra_fields::{QField2D, ScalarField2D, VelocityField2D};
use cartan_geo::holonomy::{Disclination, scan_disclinations};
use cartan_manifolds::frame_field::FrameField3D;
pub mod mol_field_3d;
pub use mol_field_3d::{molecular_field_3d, molecular_field_3d_par, euler_step_fused_par, co_rotation_3d};
pub mod beris_3d;
pub use beris_3d::{beris_edwards_rhs_3d, beris_edwards_rhs_3d_par_dry, euler_step_par, EulerIntegrator3D, RK4Integrator3D};
pub mod stokes_3d;
pub use stokes_3d::stokes_solve_3d;
pub mod ch_3d;
pub use ch_3d::ch_step_etd_3d;
pub mod defects_3d;
pub use defects_3d::{scan_defects_3d, track_defect_events};
pub mod runner_3d;
pub use runner_3d::{run_dry_active_nematic_3d, run_bech_3d, SnapStats3D, BechStats3D};
pub mod gauss_bonnet_3d;
pub use gauss_bonnet_3d::gauss_bonnet_chi;
pub mod runner_dec;
pub use runner_dec::{run_dry_active_nematic_dec, SnapStatsDec};
pub mod runner_dec_wet;
pub use runner_dec_wet::run_wet_active_nematic_dec;
pub mod engine;
pub use engine::{NematicEngine, EngineStats};
pub mod nematic_field_2d;
pub use nematic_field_2d::NematicField2D;
pub mod stokes_trait;
pub use stokes_trait::{StokesSolver, StokesBackend, FlowField, KillingOperatorSolver, StreamFunctionStokes};
pub mod active_nematic_engine;
pub use active_nematic_engine::{ActiveNematicEngine, EngineParams, StepDiagnostics};
pub fn molecular_field(q: &QField2D, params: &ActiveNematicParams) -> QField2D {
let a_eff = params.a_eff();
let c = params.c_landau;
let k_r = params.k_r;
let lap = q.laplacian();
let sq = q.order_param_sq();
let mut out = QField2D::zeros(q.nx, q.ny, q.dx);
for k in 0..q.len() {
let [q1, q2] = q.q[k];
let s2 = sq[k]; out.q[k][0] = k_r * lap.q[k][0] - 2.0 * a_eff * q1 - 4.0 * c * s2 * q1;
out.q[k][1] = k_r * lap.q[k][1] - 2.0 * a_eff * q2 - 4.0 * c * s2 * q2;
}
out
}
pub fn corotation_strain(q: &QField2D, v: &VelocityField2D, params: &ActiveNematicParams) -> QField2D {
let lambda = params.lambda;
let dx = q.dx;
let mut out = QField2D::zeros(q.nx, q.ny, q.dx);
for i in 0..q.nx {
for j in 0..q.ny {
let k = q.idx(i, j);
let ip = v.idx_i(i as i64 + 1, j as i64);
let im = v.idx_i(i as i64 - 1, j as i64);
let jp = v.idx_i(i as i64, j as i64 + 1);
let jm = v.idx_i(i as i64, j as i64 - 1);
let dvx_dx = (v.v[ip][0] - v.v[im][0]) / (2.0 * dx);
let dvx_dy = (v.v[jp][0] - v.v[jm][0]) / (2.0 * dx);
let dvy_dx = (v.v[ip][1] - v.v[im][1]) / (2.0 * dx);
let dvy_dy = (v.v[jp][1] - v.v[jm][1]) / (2.0 * dx);
let d_xx = dvx_dx;
let d_yy = dvy_dy;
let d_xy = 0.5 * (dvx_dy + dvy_dx);
let omega = 0.5 * (dvy_dx - dvx_dy);
let [q1, q2] = q.q[k];
let d_yy_incomp = -d_xx;
let dqdq_11 = 2.0 * (d_xx * q1 + d_xy * q2);
let dqdq_12 = (d_xx + d_yy_incomp) * q2; let dqdq_12_gen = q2 * (d_xx + d_yy);
let tr_dq = (d_xx - d_yy) * q1 + 2.0 * d_xy * q2;
let oq_qo_11 = 2.0 * omega * q2;
let oq_qo_12 = -2.0 * omega * q1;
out.q[k][0] =
lambda * (dqdq_11 - tr_dq) - oq_qo_11;
out.q[k][1] =
lambda * (dqdq_12_gen) - oq_qo_12;
let _ = dqdq_12; }
}
out
}
pub fn beris_edwards_rhs(
q: &QField2D,
v: Option<&VelocityField2D>,
params: &ActiveNematicParams,
) -> QField2D {
let h = molecular_field(q, params);
let mut rhs = h.scale(params.gamma_r);
if let Some(vel) = v {
let advection = vel.advect(q);
let corot = corotation_strain(q, vel, params);
rhs = rhs.add(&corot).add(&advection.scale(-1.0));
}
rhs
}
pub struct EulerIntegrator;
impl Integrator<QField2D> for EulerIntegrator {
fn step<F>(&self, state: &QField2D, dt: f64, rhs: F) -> QField2D
where
F: Fn(&QField2D) -> QField2D,
{
let dq = rhs(state);
state.add(&dq.scale(dt))
}
}
pub struct RK4Integrator;
impl Integrator<QField2D> for RK4Integrator {
fn step<F>(&self, state: &QField2D, dt: f64, rhs: F) -> QField2D
where
F: Fn(&QField2D) -> QField2D,
{
let k1 = rhs(state);
let k2 = rhs(&state.add(&k1.scale(dt / 2.0)));
let k3 = rhs(&state.add(&k2.scale(dt / 2.0)));
let k4 = rhs(&state.add(&k3.scale(dt)));
let sum = k1
.add(&k2.scale(2.0))
.add(&k3.scale(2.0))
.add(&k4);
state.add(&sum.scale(dt / 6.0))
}
}
fn bessel_k0(x: f64) -> f64 {
if x <= 0.0 {
return f64::INFINITY; }
if x <= 2.0 {
let ti = x / 3.75;
let ti2 = ti * ti;
let i0 = 1.0
+ 3.5156329 * ti2
+ 3.0899424 * ti2 * ti2
+ 1.2067492 * ti2.powi(3)
+ 0.2659732 * ti2.powi(4)
+ 0.0360768 * ti2.powi(5)
+ 0.0045813 * ti2.powi(6);
let tk = x / 2.0;
let tk2 = tk * tk;
let p = -0.57721566
+ 0.42278420 * tk2
+ 0.23069756 * tk2 * tk2
+ 0.03488590 * tk2.powi(3)
+ 0.00262698 * tk2.powi(4)
+ 0.00010750 * tk2.powi(5)
+ 0.0000074 * tk2.powi(6);
-(x / 2.0).ln() * i0 + p
} else {
let t = 2.0 / x;
let poly = 1.25331414
- 0.07832358 * t
+ 0.02189568 * t * t
- 0.01062446 * t.powi(3)
+ 0.00587872 * t.powi(4)
- 0.00251540 * t.powi(5)
+ 0.00053208 * t.powi(6);
((-x).exp() / x.sqrt()) * poly
}
}
pub fn k0_convolution(q_rot: &QField2D, params: &ActiveNematicParams) -> QField2D {
let xi_l = params.xi_l;
let dx = q_rot.dx;
let nx = q_rot.nx as i64;
let ny = q_rot.ny as i64;
let cutoff_raw = (6.0 * xi_l / dx).ceil() as i64;
let cutoff = cutoff_raw
.min((q_rot.nx as i64) / 2)
.min((q_rot.ny as i64) / 2);
let norm = 2.0 * std::f64::consts::PI * xi_l * xi_l;
let mut out = QField2D::zeros(q_rot.nx, q_rot.ny, dx);
for i in 0..nx {
for j in 0..ny {
let k_out = q_rot.idx_i(i, j);
let mut s0 = 0.0_f64;
let mut s1 = 0.0_f64;
for di in -cutoff..cutoff {
let r_x = di as f64 * dx;
for dj in -cutoff..cutoff {
let r_y = dj as f64 * dx;
let r = (r_x * r_x + r_y * r_y).sqrt();
if r < 0.5 * dx {
continue;
}
let k0_val = bessel_k0(r / xi_l);
let src = q_rot.idx_i(i + di, j + dj);
s0 += k0_val * q_rot.q[src][0];
s1 += k0_val * q_rot.q[src][1];
}
}
out.q[k_out][0] = s0 * dx * dx / norm;
out.q[k_out][1] = s1 * dx * dx / norm;
}
}
out
}
pub fn stokes_solve(q: &QField2D, params: &ActiveNematicParams) -> VelocityField2D {
let nx = q.nx;
let ny = q.ny;
let n = nx * ny;
let dx = q.dx;
let eta = params.eta;
let ze = params.zeta_eff;
let mut planner = FftPlanner::<f64>::new();
let fft_x = planner.plan_fft_forward(nx);
let fft_y = planner.plan_fft_forward(ny);
let ifft_x = planner.plan_fft_inverse(nx);
let ifft_y = planner.plan_fft_inverse(ny);
let kx_vec: Vec<f64> = (0..nx).map(|i| {
let i = i as i64;
let n = nx as i64;
let i_shifted = if i <= n / 2 { i } else { i - n };
2.0 * std::f64::consts::PI * i_shifted as f64 / (nx as f64 * dx)
}).collect();
let ky_vec: Vec<f64> = (0..ny).map(|j| {
let j = j as i64;
let n = ny as i64;
let j_shifted = if j <= n / 2 { j } else { j - n };
2.0 * std::f64::consts::PI * j_shifted as f64 / (ny as f64 * dx)
}).collect();
let fft2_real = |field: &[f64]| -> Vec<Complex<f64>> {
let mut buf: Vec<Complex<f64>> = field.iter().map(|&x| Complex::new(x, 0.0)).collect();
for row in buf.chunks_mut(ny) {
fft_y.process(row);
}
let mut transposed: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx {
for j in 0..ny {
transposed[j * nx + i] = buf[i * ny + j];
}
}
for col in transposed.chunks_mut(nx) {
fft_x.process(col);
}
for i in 0..nx {
for j in 0..ny {
buf[i * ny + j] = transposed[j * nx + i];
}
}
buf
};
let ifft2_to_real = |buf: &mut Vec<Complex<f64>>| -> Vec<f64> {
let mut transposed: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx {
for j in 0..ny {
transposed[j * nx + i] = buf[i * ny + j];
}
}
for col in transposed.chunks_mut(nx) {
ifft_x.process(col);
}
for i in 0..nx {
for j in 0..ny {
buf[i * ny + j] = transposed[j * nx + i];
}
}
for row in buf.chunks_mut(ny) {
ifft_y.process(row);
}
let norm = n as f64;
buf.iter().map(|c| c.re / norm).collect()
};
let q1_field: Vec<f64> = q.q.iter().map(|[q1, _]| *q1).collect();
let q2_field: Vec<f64> = q.q.iter().map(|[_, q2]| *q2).collect();
let q1_hat = fft2_real(&q1_field);
let q2_hat = fft2_real(&q2_field);
let mut vx_hat: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
let mut vy_hat: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
let i_unit = Complex::new(0.0, 1.0);
for ii in 0..nx {
for jj in 0..ny {
let k = ii * ny + jj;
let kx = kx_vec[ii];
let ky = ky_vec[jj];
let k2 = kx * kx + ky * ky;
if k2 < 1e-14 {
vx_hat[k] = Complex::new(0.0, 0.0);
vy_hat[k] = Complex::new(0.0, 0.0);
continue;
}
let k4 = k2 * k2;
let rhs = ze * ((ky * ky - kx * kx) * q2_hat[k] + 2.0 * kx * ky * q1_hat[k]);
let psi_hat = rhs / (eta * k4);
vx_hat[k] = i_unit * ky * psi_hat;
vy_hat[k] = -i_unit * kx * psi_hat;
}
}
let vx_field = ifft2_to_real(&mut vx_hat);
let vy_field = ifft2_to_real(&mut vy_hat);
let v_data: Vec<[f64; 2]> = (0..n).map(|k| [vx_field[k], vy_field[k]]).collect();
VelocityField2D { v: v_data, nx, ny, dx }
}
pub fn run_active_nematic_hydro(
q_init: &QField2D,
params: &ActiveNematicParams,
n_steps: usize,
snap_every: usize,
) -> (QField2D, Vec<SnapStats>) {
let mut q = q_init.clone();
let mut stats = Vec::new();
let lx = params.nx as f64 * params.dx;
let ly = params.ny as f64 * params.dx;
let area = lx * ly;
let use_noise = params.noise_amp > 0.0;
let noise_scale = params.noise_amp * params.dt.sqrt();
let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
^ (params.ny as u64).wrapping_mul(1442695040888963407)
^ n_steps as u64;
let mut rng = SmallRng::seed_from_u64(seed);
for step in 0..=n_steps {
if step % snap_every == 0 {
let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
let (n_plus, n_minus) = defect_count(&defects);
let n_defects = defects.len();
stats.push(SnapStats {
time: step as f64 * params.dt,
mean_s: q.mean_order_param(),
n_defects,
n_plus,
n_minus,
defect_density: n_defects as f64 / area,
});
}
if step < n_steps {
let v = stokes_solve(&q, params);
let p = params.clone();
let dq = beris_edwards_rhs(&q, Some(&v), &p);
q = q.add(&dq.scale(params.dt));
if use_noise {
for [q1, q2] in q.q.iter_mut() {
let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
let u2: f64 = rng.random::<f64>();
let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
let angle = std::f64::consts::TAU * u2;
*q1 += mag * angle.cos();
*q2 += mag * angle.sin();
}
}
}
}
(q, stats)
}
#[derive(Debug, Clone)]
pub struct DefectInfo {
pub plaquette: (usize, usize),
pub angle: f64,
pub charge_sign: i8,
}
pub fn scan_defects(q: &QField2D, threshold: f64) -> Vec<DefectInfo> {
let q3d = q.to_q3d();
let frame_field = FrameField3D::from_q_field(&q3d);
let raw: Vec<Disclination> =
scan_disclinations(&frame_field.frames, q.nx, q.ny, threshold);
raw.into_iter()
.map(|d| {
let h = &d.holonomy;
let axis_z_proxy = h[(0, 1)] - h[(1, 0)]; let charge_sign = if axis_z_proxy >= 0.0 { 1_i8 } else { -1_i8 };
DefectInfo {
plaquette: d.plaquette,
angle: d.angle,
charge_sign,
}
})
.collect()
}
pub fn defect_count(defects: &[DefectInfo]) -> (usize, usize) {
let n_plus = defects.iter().filter(|d| d.charge_sign > 0).count();
let n_minus = defects.iter().filter(|d| d.charge_sign < 0).count();
(n_plus, n_minus)
}
#[derive(Debug, Clone)]
pub struct SnapStats {
pub time: f64,
pub mean_s: f64,
pub n_defects: usize,
pub n_plus: usize,
pub n_minus: usize,
pub defect_density: f64,
}
pub fn run_dry_active_nematic(
q_init: &QField2D,
params: &ActiveNematicParams,
n_steps: usize,
snap_every: usize,
) -> (QField2D, Vec<SnapStats>) {
let integrator = RK4Integrator;
let mut q = q_init.clone();
let mut stats = Vec::new();
let lx = params.nx as f64 * params.dx;
let ly = params.ny as f64 * params.dx;
let area = lx * ly;
let use_noise = params.noise_amp > 0.0;
let noise_scale = params.noise_amp * params.dt.sqrt();
let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
^ (params.ny as u64).wrapping_mul(1442695040888963407)
^ n_steps as u64;
let mut rng = SmallRng::seed_from_u64(seed);
for step in 0..=n_steps {
if step % snap_every == 0 {
let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
let (n_plus, n_minus) = defect_count(&defects);
let n_defects = defects.len();
stats.push(SnapStats {
time: step as f64 * params.dt,
mean_s: q.mean_order_param(),
n_defects,
n_plus,
n_minus,
defect_density: n_defects as f64 / area,
});
}
if step < n_steps {
let p = params.clone();
q = integrator.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
if use_noise {
let mut iter = q.q.iter_mut();
for [q1, q2] in iter.by_ref() {
let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
let u2: f64 = rng.random::<f64>();
let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
let angle = std::f64::consts::TAU * u2;
*q1 += mag * angle.cos();
*q2 += mag * angle.sin();
}
}
}
}
(q, stats)
}
pub fn ch_chemical_potential(
phi: &ScalarField2D,
q_lip: &QField2D,
params: &ActiveNematicParams,
) -> ScalarField2D {
let lap_phi = phi.laplacian();
let n = phi.len();
let mut mu = ScalarField2D::zeros(phi.nx, phi.ny, phi.dx);
for k in 0..n {
let p = phi.phi[k];
let tr_q2 = 2.0 * (q_lip.q[k][0] * q_lip.q[k][0]
+ q_lip.q[k][1] * q_lip.q[k][1]);
mu.phi[k] = params.a_ch * p
+ params.b_ch * p * p * p
- params.kappa_ch * lap_phi.phi[k]
- params.chi_ms * tr_q2;
}
mu
}
pub fn ch_step_etd(
phi: &ScalarField2D,
q_lip: &QField2D,
v: &VelocityField2D,
params: &ActiveNematicParams,
) -> ScalarField2D {
let nx = phi.nx;
let ny = phi.ny;
let n = nx * ny;
let dx = phi.dx;
let dt = params.dt;
let mut planner = FftPlanner::<f64>::new();
let fft_x = planner.plan_fft_forward(nx);
let fft_y = planner.plan_fft_forward(ny);
let ifft_x = planner.plan_fft_inverse(nx);
let ifft_y = planner.plan_fft_inverse(ny);
let kx_vec: Vec<f64> = (0..nx).map(|i| {
let i = i as i64;
let nm = nx as i64;
let is = if i <= nm / 2 { i } else { i - nm };
2.0 * std::f64::consts::PI * is as f64 / (nx as f64 * dx)
}).collect();
let ky_vec: Vec<f64> = (0..ny).map(|j| {
let j = j as i64;
let nm = ny as i64;
let js = if j <= nm / 2 { j } else { j - nm };
2.0 * std::f64::consts::PI * js as f64 / (ny as f64 * dx)
}).collect();
let fft2_real = |field: &[f64]| -> Vec<Complex<f64>> {
let mut buf: Vec<Complex<f64>> = field.iter().map(|&x| Complex::new(x, 0.0)).collect();
for row in buf.chunks_mut(ny) { fft_y.process(row); }
let mut tr: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx { for j in 0..ny { tr[j * nx + i] = buf[i * ny + j]; } }
for col in tr.chunks_mut(nx) { fft_x.process(col); }
for i in 0..nx { for j in 0..ny { buf[i * ny + j] = tr[j * nx + i]; } }
buf
};
let ifft2_to_real = |buf: &mut Vec<Complex<f64>>| -> Vec<f64> {
let mut tr: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
for i in 0..nx { for j in 0..ny { tr[j * nx + i] = buf[i * ny + j]; } }
for col in tr.chunks_mut(nx) { ifft_x.process(col); }
for i in 0..nx { for j in 0..ny { buf[i * ny + j] = tr[j * nx + i]; } }
for row in buf.chunks_mut(ny) { ifft_y.process(row); }
let norm = n as f64;
buf.iter().map(|c| c.re / norm).collect()
};
let mut nonlin_src = ScalarField2D::zeros(nx, ny, dx);
for k in 0..n {
let p = phi.phi[k];
let tr_q2 = 2.0 * (q_lip.q[k][0] * q_lip.q[k][0]
+ q_lip.q[k][1] * q_lip.q[k][1]);
nonlin_src.phi[k] = params.b_ch * p * p * p - params.chi_ms * tr_q2;
}
let lap_nonlin = nonlin_src.laplacian();
let advection = v.advect_scalar(phi);
let mut nonlin_real: Vec<f64> = vec![0.0; n];
for k in 0..n {
nonlin_real[k] = -advection.phi[k] + params.m_l * lap_nonlin.phi[k];
}
let mut phi_hat = fft2_real(&phi.phi);
let nonlin_hat = fft2_real(&nonlin_real);
for ii in 0..nx {
for jj in 0..ny {
let idx = ii * ny + jj;
let kx = kx_vec[ii];
let ky = ky_vec[jj];
let k2 = kx * kx + ky * ky;
if k2 < 1e-14 {
continue;
}
let k4 = k2 * k2;
let lk = -params.m_l * (params.a_ch * k2 + params.kappa_ch * k4);
let lk_dt = lk * dt;
let exp_lk = lk_dt.exp();
let expm1_over_lk = if lk_dt.abs() < 1.0e-10 {
dt * (1.0 + lk_dt / 2.0 + lk_dt * lk_dt / 6.0)
} else {
(exp_lk - 1.0) / lk
};
phi_hat[idx] = exp_lk * phi_hat[idx] + expm1_over_lk * nonlin_hat[idx];
}
}
let phi_new_vals = ifft2_to_real(&mut phi_hat);
ScalarField2D { phi: phi_new_vals, nx, ny, dx }
}
#[derive(Debug, Clone)]
pub struct BechStats {
pub time: f64,
pub mean_s: f64,
pub n_defects: usize,
pub n_plus: usize,
pub n_minus: usize,
pub defect_density: f64,
pub mean_phi: f64,
pub phi_variance: f64,
pub mean_grad_phi_sq: f64,
}
pub fn run_bech(
q_init: &QField2D,
phi_init: &ScalarField2D,
params: &ActiveNematicParams,
n_steps: usize,
snap_every: usize,
) -> (QField2D, ScalarField2D, Vec<BechStats>) {
let mut q = q_init.clone();
let mut phi = phi_init.clone();
let mut stats: Vec<BechStats> = Vec::new();
let lx = params.nx as f64 * params.dx;
let ly = params.ny as f64 * params.dx;
let area = lx * ly;
let use_noise = params.noise_amp > 0.0;
let noise_scale = params.noise_amp * params.dt.sqrt();
let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
^ (params.ny as u64).wrapping_mul(1442695040888963407)
^ n_steps as u64;
let mut rng = SmallRng::seed_from_u64(seed);
for step in 0..=n_steps {
if step % snap_every == 0 {
let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
let (n_plus, n_minus) = defect_count(&defects);
let n_defects = defects.len();
stats.push(BechStats {
time: step as f64 * params.dt,
mean_s: q.mean_order_param(),
n_defects,
n_plus,
n_minus,
defect_density: n_defects as f64 / area,
mean_phi: phi.mean_value(),
phi_variance: phi.variance(),
mean_grad_phi_sq: phi.mean_gradient_sq(),
});
}
if step == n_steps { break; }
let v = stokes_solve(&q, params);
let dq = beris_edwards_rhs(&q, Some(&v), params);
q = q.add(&dq.scale(params.dt));
if use_noise {
for [q1, q2] in q.q.iter_mut() {
let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
let u2: f64 = rng.random::<f64>();
let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
let angle = std::f64::consts::TAU * u2;
*q1 += mag * angle.cos();
*q2 += mag * angle.sin();
}
}
let q_lip = k0_convolution(&q, params);
phi = ch_step_etd(&phi, &q_lip, &v, params);
}
(q, phi, stats)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use std::f64::consts::PI;
fn default_params() -> ActiveNematicParams {
ActiveNematicParams::default_test()
}
#[test]
fn molecular_field_zero_at_zero_q() {
let q = QField2D::zeros(8, 8, 1.0);
let h = molecular_field(&q, &default_params());
for &[h1, h2] in &h.q {
assert_abs_diff_eq!(h1, 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(h2, 0.0, epsilon = 1e-12);
}
}
#[test]
fn molecular_field_linear_at_small_q() {
let params = default_params();
let a_eff = params.a_eff();
let q0 = 0.001;
let q = QField2D::uniform(8, 8, 1.0, [q0, 0.0]);
let h = molecular_field(&q, ¶ms);
for &[h1, _] in &h.q {
let expected = -2.0 * a_eff * q0;
assert_abs_diff_eq!(h1, expected, epsilon = 1e-4 * expected.abs().max(1e-12));
}
}
#[test]
fn euler_step_grows_unstable_mode() {
let params = default_params(); assert!(params.a_eff() < 0.0, "need active regime for this test");
let q = QField2D::uniform(8, 8, 1.0, [0.01, 0.0]);
let euler = EulerIntegrator;
let p = params.clone();
let q_next = euler.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
let s_before = q.mean_order_param();
let s_after = q_next.mean_order_param();
assert!(s_after > s_before, "s_before={s_before}, s_after={s_after}");
}
#[test]
fn rk4_conserves_symmetry() {
let params = default_params();
let q = QField2D::uniform(16, 16, 1.0, [0.1, 0.05]);
let rk4 = RK4Integrator;
let p = params.clone();
let q_next = rk4.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
assert!(q_next.max_norm().is_finite());
assert!(q_next.max_norm() > 0.0);
}
#[test]
fn rk4_more_accurate_than_euler() {
let mut params = default_params();
params.a_landau = 1.0; params.zeta_eff = 0.0; params.c_landau = 0.01; params.gamma_r = 1.0;
params.dt = 0.01;
let r = 2.0 * params.gamma_r * params.a_eff();
let q0_val = 0.1_f64;
let q0 = QField2D::uniform(4, 4, 1.0, [q0_val, 0.0]);
let p1 = params.clone();
let euler = EulerIntegrator;
let q_euler = euler.step(&q0, params.dt, move |q_| beris_edwards_rhs(q_, None, &p1));
let p2 = params.clone();
let rk4 = RK4Integrator;
let q_rk4 = rk4.step(&q0, params.dt, move |q_| beris_edwards_rhs(q_, None, &p2));
let exact = q0_val * (-r * params.dt).exp();
let err_euler = (q_euler.q[0][0] - exact).abs();
let err_rk4 = (q_rk4.q[0][0] - exact).abs();
assert!(err_rk4 < err_euler, "RK4 err={err_rk4:.3e} must be < Euler err={err_euler:.3e}");
}
#[test]
fn bessel_k0_large_x_decays() {
let x_vals = [1.0, 2.0, 5.0, 10.0];
let k_vals: Vec<f64> = x_vals.iter().map(|&x| bessel_k0(x)).collect();
for i in 1..k_vals.len() {
assert!(k_vals[i] < k_vals[i - 1], "K₀ not monotone: {k_vals:?}");
}
}
#[test]
fn bessel_k0_positive() {
for x in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] {
assert!(bessel_k0(x) > 0.0);
}
}
#[test]
fn k0_convolution_of_uniform_field() {
let params = {
let mut p = ActiveNematicParams::default_test();
p.nx = 32;
p.ny = 32;
p.xi_l = 3.0;
p.dx = 1.0;
p
};
let q0 = [0.15, -0.08];
let q = QField2D::uniform(params.nx, params.ny, params.dx, q0);
let q_lip = k0_convolution(&q, ¶ms);
let [v0_ref, v1_ref] = q_lip.q[0];
for &[v0, v1] in &q_lip.q {
assert_abs_diff_eq!(v0, v0_ref, epsilon = 1e-12);
assert_abs_diff_eq!(v1, v1_ref, epsilon = 1e-12);
}
let ratio0 = v0_ref / q0[0];
let ratio1 = v1_ref / q0[1];
assert_abs_diff_eq!(ratio0, ratio1, epsilon = 1e-10);
assert!(ratio0.is_finite() && ratio0 > 0.0);
assert_abs_diff_eq!(ratio0, 1.0, epsilon = 0.10);
}
#[test]
fn scan_defects_zero_field() {
let q = QField2D::zeros(8, 8, 1.0);
let defects = scan_defects(&q, PI / 2.0);
let _ = defects; }
#[test]
fn scan_defects_uniform_field_no_defects() {
let q = QField2D::uniform(16, 16, 1.0, [0.3, 0.0]);
let defects = scan_defects(&q, PI / 2.0);
assert!(
defects.is_empty(),
"uniform Q-field should have no defects, got {}",
defects.len()
);
}
#[test]
fn run_dry_active_nematic_grows_order() {
let params = ActiveNematicParams::default_test();
assert!(params.a_eff() < 0.0);
let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.001, 7);
let (q_final, stats) = run_dry_active_nematic(&q_init, ¶ms, 20, 5);
assert!(q_final.max_norm().is_finite());
assert!(
stats.last().unwrap().mean_s > stats.first().unwrap().mean_s,
"order parameter did not grow: {:?}",
stats.iter().map(|s| s.mean_s).collect::<Vec<_>>()
);
}
#[test]
fn ch_chemical_potential_zero_at_zero_fields() {
let params = ActiveNematicParams::default_test();
let phi = ScalarField2D::zeros(8, 8, 1.0);
let q_lip = QField2D::zeros(8, 8, 1.0);
let mu = ch_chemical_potential(&phi, &q_lip, ¶ms);
for &v in &mu.phi {
assert_abs_diff_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ch_chemical_potential_linear_at_small_phi_no_q() {
let params = ActiveNematicParams::default_test();
let phi0 = 0.01_f64;
let phi = ScalarField2D::uniform(8, 8, 1.0, phi0);
let q_lip = QField2D::zeros(8, 8, 1.0);
let mu = ch_chemical_potential(&phi, &q_lip, ¶ms);
let expected = params.a_ch * phi0 + params.b_ch * phi0.powi(3);
for &v in &mu.phi {
assert_abs_diff_eq!(v, expected, epsilon = 1e-8 * expected.abs().max(1e-12));
}
}
#[test]
fn ch_maier_saupe_lowers_chemical_potential() {
let params = ActiveNematicParams::default_test();
assert!(params.chi_ms > 0.0);
let phi = ScalarField2D::uniform(8, 8, 1.0, 0.5);
let q_zero = QField2D::zeros(8, 8, 1.0);
let q_ord = QField2D::uniform(8, 8, 1.0, [0.3, 0.0]);
let mu_no_q = ch_chemical_potential(&phi, &q_zero, ¶ms);
let mu_with_q = ch_chemical_potential(&phi, &q_ord, ¶ms);
assert!(
mu_with_q.phi[0] < mu_no_q.phi[0],
"Maier-Saupe term should lower μ: {} vs {}",
mu_with_q.phi[0],
mu_no_q.phi[0]
);
}
#[test]
fn ch_step_etd_conserves_mean() {
let params = ActiveNematicParams::default_test();
let phi0 = 0.4_f64;
let phi = ScalarField2D::uniform(16, 16, 1.0, phi0);
let q_lip = QField2D::uniform(16, 16, 1.0, [0.1, 0.05]);
let v = VelocityField2D::zeros(16, 16, 1.0);
let phi_new = ch_step_etd(&phi, &q_lip, &v, ¶ms);
assert_abs_diff_eq!(phi_new.mean_value(), phi0, epsilon = 1e-10);
}
#[test]
fn ch_step_etd_uniform_phi_no_change() {
let params = ActiveNematicParams::default_test();
let phi0 = 0.5_f64;
let phi = ScalarField2D::uniform(16, 16, 1.0, phi0);
let q_lip = QField2D::uniform(16, 16, 1.0, [0.2, 0.0]);
let v = VelocityField2D::zeros(16, 16, 1.0);
let phi_new = ch_step_etd(&phi, &q_lip, &v, ¶ms);
for &val in &phi_new.phi {
assert_abs_diff_eq!(val, phi0, epsilon = 1e-10);
}
}
#[test]
fn ch_step_etd_output_finite() {
let params = ActiveNematicParams::default_test();
let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.05, 99);
let phi_vals: Vec<f64> = (0..16*16).map(|k| {
let frac = k as f64 / (16.0 * 16.0);
0.5 + 0.05 * (frac * 7.3).sin()
}).collect();
let phi_init = ScalarField2D { phi: phi_vals, nx: 16, ny: 16, dx: 1.0 };
let (q_fin, phi_fin, stats) = run_bech(&q_init, &phi_init, ¶ms, 10, 5);
assert!(q_fin.max_norm().is_finite());
for &v in &phi_fin.phi {
assert!(v.is_finite(), "phi_fin contains non-finite value");
}
assert_eq!(stats.len(), 3); }
#[test]
fn run_bech_phi_variance_grows() {
let params = ActiveNematicParams::default_test();
let mut p = params.clone();
p.chi_ms = 2.0;
p.nx = 16; p.ny = 16;
let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.3, 17);
let phi_init = ScalarField2D::uniform(16, 16, 1.0, 0.4);
let (_, _, stats) = run_bech(&q_init, &phi_init, &p, 50, 10);
let var_init = stats.first().unwrap().phi_variance;
let var_fin = stats.last().unwrap().phi_variance;
assert!(
var_fin >= var_init - 1e-12,
"Expected Var[φ] to grow under Maier-Saupe coupling: {var_init:.3e} → {var_fin:.3e}"
);
}
}