#![allow(clippy::needless_range_loop)]
use std::io::Write;
use std::path::Path;
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng};
use serde::{Deserialize, Serialize};
use volterra_core::ActiveNematicParams3D;
use volterra_fields::{QField3D, ScalarField3D};
use crate::beris_3d::{beris_edwards_rhs_3d, EulerIntegrator3D};
use crate::ch_3d::ch_step_etd_3d;
use crate::defects_3d::{scan_defects_3d, track_defect_events};
use crate::stokes_3d::stokes_solve_3d;
use cartan_geo::disclination::DisclinationLine;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapStats3D {
pub time: f64,
pub mean_s: f64,
pub biaxiality_p: f64,
pub n_disclination_lines: usize,
pub total_line_length: f64,
pub mean_line_curvature: f64,
pub n_events: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BechStats3D {
pub time: f64,
pub mean_s: f64,
pub biaxiality_p: f64,
pub mean_phi: f64,
pub n_disclination_lines: usize,
pub total_line_length: f64,
pub mean_line_curvature: f64,
pub n_events: usize,
}
pub fn run_dry_active_nematic_3d(
q_init: &QField3D,
p: &ActiveNematicParams3D,
n_steps: usize,
snap_every: usize,
out_dir: &Path,
track_defects: bool,
) -> (QField3D, Vec<SnapStats3D>) {
let mut q = q_init.clone();
let mut stats: Vec<SnapStats3D> = Vec::new();
let mut prev_lines: Option<Vec<DisclinationLine>> = None;
for step in 0..n_steps {
let t = step as f64 * p.dt;
crate::mol_field_3d::euler_step_fused_par(&mut q, p, t);
if p.noise_amp > 0.0 {
let amp = p.noise_amp * p.dt.sqrt();
let n_verts = q.len();
let mut rng = SmallRng::seed_from_u64(step as u64 ^ 0xdead_beef_cafe_1234);
for k in 0..n_verts {
let samples = box_muller_5(&mut rng);
for c in 0..5 {
q.q[k][c] += amp * samples[c];
}
}
}
if snap_every > 0 && (step + 1) % snap_every == 0 {
let snap_idx = (step + 1) / snap_every;
let t_snap = (step + 1) as f64 * p.dt;
let (lines, n_events) = if track_defects {
let current = scan_defects_3d(&q);
let n_ev = if let Some(ref prev) = prev_lines {
track_defect_events(prev, ¤t, snap_idx, p.dx).len()
} else {
0
};
prev_lines = Some(current.clone());
(current, n_ev)
} else {
(Vec::new(), 0)
};
let s = compute_snap_stats(&q, &lines, n_events, t_snap);
stats.push(s);
let npy_path = out_dir.join(format!("q_{step:06}.npy"));
if let Err(e) = write_npy(&npy_path, &q.q, p.nx, p.ny, p.nz, 5) {
eprintln!("[runner_3d] warn: failed to write {}: {e}", npy_path.display());
}
}
}
let stats_path = out_dir.join("stats.json");
if let Ok(json) = serde_json::to_string_pretty(&stats) {
let _ = std::fs::write(&stats_path, json);
}
(q, stats)
}
pub fn run_bech_3d(
q_init: &QField3D,
phi_init: &ScalarField3D,
p: &ActiveNematicParams3D,
n_steps: usize,
snap_every: usize,
out_dir: &Path,
track_defects: bool,
) -> (QField3D, ScalarField3D, Vec<BechStats3D>) {
let euler = EulerIntegrator3D;
let mut q = q_init.clone();
let mut phi = phi_init.clone();
let mut stats: Vec<BechStats3D> = Vec::new();
let mut prev_lines: Option<Vec<DisclinationLine>> = None;
for step in 0..n_steps {
let t = step as f64 * p.dt;
let (vel, _pressure) = stokes_solve_3d(&q, p);
let rhs = beris_edwards_rhs_3d(&q, Some(&vel), p, t);
q = euler.step(&q, p.dt, &rhs);
if p.noise_amp > 0.0 {
let amp = p.noise_amp * p.dt.sqrt();
let n_verts = q.len();
let mut rng = SmallRng::seed_from_u64(step as u64 ^ 0xdead_beef_cafe_5678);
for k in 0..n_verts {
let samples = box_muller_5(&mut rng);
for c in 0..5 {
q.q[k][c] += amp * samples[c];
}
}
}
phi = ch_step_etd_3d(&phi, &q, p, p.dt);
if snap_every > 0 && (step + 1) % snap_every == 0 {
let snap_idx = (step + 1) / snap_every;
let t_snap = (step + 1) as f64 * p.dt;
let (lines, n_events) = if track_defects {
let current = scan_defects_3d(&q);
let n_ev = if let Some(ref prev) = prev_lines {
track_defect_events(prev, ¤t, snap_idx, p.dx).len()
} else {
0
};
prev_lines = Some(current.clone());
(current, n_ev)
} else {
(Vec::new(), 0)
};
let s = compute_bech_stats(&q, &phi, &lines, n_events, t_snap);
stats.push(s);
let q_path = out_dir.join(format!("q_{step:06}.npy"));
if let Err(e) = write_npy(&q_path, &q.q, p.nx, p.ny, p.nz, 5) {
eprintln!("[runner_3d] warn: failed to write {}: {e}", q_path.display());
}
let phi_wrapped: Vec<[f64; 1]> = phi.phi.iter().map(|&v| [v]).collect();
let phi_path = out_dir.join(format!("phi_{step:06}.npy"));
if let Err(e) = write_npy(&phi_path, &phi_wrapped, p.nx, p.ny, p.nz, 1) {
eprintln!("[runner_3d] warn: failed to write {}: {e}", phi_path.display());
}
let vel_path = out_dir.join(format!("vel_{step:06}.npy"));
if let Err(e) = write_npy(&vel_path, &vel.u, p.nx, p.ny, p.nz, 3) {
eprintln!("[runner_3d] warn: failed to write {}: {e}", vel_path.display());
}
}
}
let stats_path = out_dir.join("stats.json");
if let Ok(json) = serde_json::to_string_pretty(&stats) {
let _ = std::fs::write(&stats_path, json);
}
(q, phi, stats)
}
fn box_muller_5(rng: &mut SmallRng) -> [f64; 5] {
let mut out = [0.0f64; 5];
let mut i = 0;
while i < 5 {
let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE); let u2: f64 = rng.random::<f64>();
let (z0, z1) = box_muller(u1, u2);
out[i] = z0;
i += 1;
if i < 5 {
out[i] = z1;
i += 1;
}
}
out
}
#[inline]
fn box_muller(u1: f64, u2: f64) -> (f64, f64) {
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f64::consts::PI * u2;
(r * theta.cos(), r * theta.sin())
}
fn compute_snap_stats(
q: &QField3D,
lines: &[DisclinationLine],
n_events: usize,
time: f64,
) -> SnapStats3D {
let mean_s = q.mean_s();
let biaxiality_p = q.biaxiality_p().iter().sum::<f64>() / q.len() as f64;
let total_length: f64 = lines.iter().map(|l| l.vertices.len() as f64).sum();
let mean_curv = if total_length > 0.0 {
lines.iter().flat_map(|l| l.curvatures.iter()).sum::<f64>() / total_length
} else {
0.0
};
SnapStats3D {
time,
mean_s,
biaxiality_p,
n_disclination_lines: lines.len(),
total_line_length: total_length,
mean_line_curvature: mean_curv,
n_events,
}
}
fn compute_bech_stats(
q: &QField3D,
phi: &ScalarField3D,
lines: &[DisclinationLine],
n_events: usize,
time: f64,
) -> BechStats3D {
let mean_s = q.mean_s();
let biaxiality_p = q.biaxiality_p().iter().sum::<f64>() / q.len() as f64;
let mean_phi = phi.mean();
let total_length: f64 = lines.iter().map(|l| l.vertices.len() as f64).sum();
let mean_curv = if total_length > 0.0 {
lines.iter().flat_map(|l| l.curvatures.iter()).sum::<f64>() / total_length
} else {
0.0
};
BechStats3D {
time,
mean_s,
biaxiality_p,
mean_phi,
n_disclination_lines: lines.len(),
total_line_length: total_length,
mean_line_curvature: mean_curv,
n_events,
}
}
fn write_npy<const N: usize>(
path: &Path,
data: &[[f64; N]],
nx: usize,
ny: usize,
nz: usize,
n_comp: usize,
) -> std::io::Result<()> {
let header_dict = format!(
"{{'descr': '<f8', 'fortran_order': False, 'shape': ({nx}, {ny}, {nz}, {n_comp}), }}"
);
let magic: &[u8] = b"\x93NUMPY\x01\x00";
let dict_plus_newline = header_dict.len() + 1; let header_len = {
let needed = dict_plus_newline;
let _rounded = needed.div_ceil(64) * 64;
let rem = (needed as isize).rem_euclid(64) as usize;
let pad_needed = if rem == 54 { 0 } else { (54 + 64 - rem) % 64 };
needed + pad_needed
};
let padding = header_len - dict_plus_newline;
let mut padded_header = header_dict.clone();
for _ in 0..padding {
padded_header.push(' ');
}
padded_header.push('\n');
debug_assert_eq!(padded_header.len(), header_len);
debug_assert_eq!((8 + 2 + header_len) % 64, 0);
let header_len_u16: u16 = header_len as u16;
let mut f = std::fs::File::create(path)?;
f.write_all(magic)?;
f.write_all(&header_len_u16.to_le_bytes())?;
f.write_all(padded_header.as_bytes())?;
for entry in data {
for &v in entry[..n_comp].iter() {
f.write_all(&v.to_le_bytes())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use volterra_core::ActiveNematicParams3D;
use volterra_fields::{QField3D, ScalarField3D};
#[test]
fn test_run_dry_active_nematic_3d_dry_smoke() {
let p = ActiveNematicParams3D::default_test(); let q_init = QField3D::random_perturbation(p.nx, p.ny, p.nz, p.dx, 0.01, 42);
let tmp = std::env::temp_dir().join("volterra_test_run");
std::fs::create_dir_all(&tmp).unwrap();
let (q_final, stats) = run_dry_active_nematic_3d(&q_init, &p, 5, 5, &tmp, false);
assert_eq!(q_final.len(), q_init.len());
assert_eq!(stats.len(), 1);
assert!(stats[0].mean_s >= 0.0);
}
#[test]
fn test_run_bech_3d_smoke() {
let p = ActiveNematicParams3D::default_test();
let q_init = QField3D::random_perturbation(p.nx, p.ny, p.nz, p.dx, 0.01, 42);
let phi_init = ScalarField3D::uniform(p.nx, p.ny, p.nz, p.dx, 0.3);
let tmp = std::env::temp_dir().join("volterra_test_full");
std::fs::create_dir_all(&tmp).unwrap();
let (q_f, phi_f, stats) = run_bech_3d(&q_init, &phi_init, &p, 5, 5, &tmp, false);
assert_eq!(q_f.len(), q_init.len());
assert!((phi_f.mean() - 0.3).abs() < 0.01, "mass roughly conserved, got mean={}", phi_f.mean());
assert_eq!(stats.len(), 1);
}
}