use std::f64::consts::PI;
fn normalise2(v: [f64; 2]) -> [f64; 2] {
let n = (v[0] * v[0] + v[1] * v[1]).sqrt();
if n < 1e-14 {
[1.0, 0.0]
} else {
[v[0] / n, v[1] / n]
}
}
fn wrap(x: f64, box_size: f64) -> f64 {
x.rem_euclid(box_size)
}
fn min_image(dx: f64, box_size: f64) -> f64 {
let dx = dx.rem_euclid(box_size);
if dx > box_size / 2.0 {
dx - box_size
} else {
dx
}
}
#[derive(Debug, Clone)]
pub struct ActiveParticle {
pub pos: [f64; 2],
pub vel: [f64; 2],
pub orientation: f64,
pub speed: f64,
pub noise: f64,
}
impl ActiveParticle {
pub fn new(pos: [f64; 2], speed: f64, noise: f64) -> Self {
let vel = [speed, 0.0];
Self {
pos,
vel,
orientation: 0.0,
speed,
noise,
}
}
pub fn update_orientation(&mut self, neighbors: &[f64], eta: f64, _dt: f64) {
if neighbors.is_empty() {
return;
}
let (sin_avg, cos_avg) = neighbors.iter().fold((0.0_f64, 0.0_f64), |(s, c), &th| {
(s + th.sin(), c + th.cos())
});
let mean_angle = sin_avg.atan2(cos_avg);
let xi: f64 = ((self.pos[0] * 31.7 + self.pos[1] * 17.3).sin() * 0.5).clamp(-0.5, 0.5);
self.orientation = mean_angle + eta * xi;
}
pub fn move_forward(&mut self, dt: f64) {
self.vel = [
self.speed * self.orientation.cos(),
self.speed * self.orientation.sin(),
];
self.pos[0] += self.vel[0] * dt;
self.pos[1] += self.vel[1] * dt;
}
pub fn direction(&self) -> [f64; 2] {
[self.orientation.cos(), self.orientation.sin()]
}
}
#[derive(Debug, Clone)]
pub struct VicsekModel {
pub particles: Vec<ActiveParticle>,
pub radius: f64,
pub box_size: f64,
}
impl VicsekModel {
pub fn new(n: usize, speed: f64, noise: f64, radius: f64, box_size: f64) -> Self {
let mut particles = Vec::with_capacity(n);
let cols = (n as f64).sqrt().ceil() as usize;
let spacing = box_size / cols as f64;
for k in 0..n {
let ix = k % cols;
let iy = k / cols;
let pos = [(ix as f64 + 0.5) * spacing, (iy as f64 + 0.5) * spacing];
let mut p = ActiveParticle::new(pos, speed, noise);
p.orientation = 2.0 * PI * (k as f64) / (n as f64);
p.vel = [speed * p.orientation.cos(), speed * p.orientation.sin()];
particles.push(p);
}
Self {
particles,
radius,
box_size,
}
}
pub fn step(&mut self, dt: f64) {
let n = self.particles.len();
let orientations: Vec<f64> = self.particles.iter().map(|p| p.orientation).collect();
let positions: Vec<[f64; 2]> = self.particles.iter().map(|p| p.pos).collect();
for i in 0..n {
let nb_idx = neighbors_within_radius(&positions, i, self.radius, self.box_size);
let nb_angles: Vec<f64> = nb_idx.iter().map(|&j| orientations[j]).collect();
let eta = self.particles[i].noise;
self.particles[i].update_orientation(&nb_angles, eta, dt);
}
for i in 0..n {
let bs = self.box_size;
self.particles[i].move_forward(dt);
self.particles[i].pos[0] = wrap(self.particles[i].pos[0], bs);
self.particles[i].pos[1] = wrap(self.particles[i].pos[1], bs);
}
}
pub fn order_parameter(&self) -> f64 {
if self.particles.is_empty() {
return 0.0;
}
let speed = self.particles[0].speed;
let velocities: Vec<[f64; 2]> = self.particles.iter().map(|p| p.vel).collect();
vicsek_order_parameter(&velocities, speed)
}
pub fn mean_velocity(&self) -> [f64; 2] {
let n = self.particles.len() as f64;
if n == 0.0 {
return [0.0, 0.0];
}
let (sx, sy) = self
.particles
.iter()
.fold((0.0_f64, 0.0_f64), |(sx, sy), p| {
(sx + p.vel[0], sy + p.vel[1])
});
[sx / n, sy / n]
}
}
pub fn vicsek_order_parameter(velocities: &[[f64; 2]], speed: f64) -> f64 {
if velocities.is_empty() || speed < 1e-15 {
return 0.0;
}
let n = velocities.len() as f64;
let (sx, sy) = velocities
.iter()
.fold((0.0_f64, 0.0_f64), |(sx, sy), v| (sx + v[0], sy + v[1]));
(sx * sx + sy * sy).sqrt() / (n * speed)
}
pub fn neighbors_within_radius(
positions: &[[f64; 2]],
i: usize,
radius: f64,
box_size: f64,
) -> Vec<usize> {
let pi = positions[i];
let r2 = radius * radius;
positions
.iter()
.enumerate()
.filter_map(|(j, &pj)| {
let dx = min_image(pj[0] - pi[0], box_size);
let dy = min_image(pj[1] - pi[1], box_size);
if dx * dx + dy * dy <= r2 {
Some(j)
} else {
None
}
})
.collect()
}
#[derive(Debug, Clone)]
pub struct RunAndTumble {
pub pos: [f64; 3],
pub vel: [f64; 3],
pub speed: f64,
pub tumble_rate: f64,
}
impl RunAndTumble {
pub fn new(speed: f64, tumble_rate: f64) -> Self {
Self {
pos: [0.0; 3],
vel: [speed, 0.0, 0.0],
speed,
tumble_rate,
}
}
pub fn step(&mut self, dt: f64) {
let p_tumble = (self.tumble_rate * dt).min(1.0);
let hash = (self.pos[0] * 173.13 + self.pos[1] * 97.7 + self.pos[2] * 53.3)
.sin()
.abs();
if hash < p_tumble {
self.tumble();
}
self.pos[0] += self.vel[0] * dt;
self.pos[1] += self.vel[1] * dt;
self.pos[2] += self.vel[2] * dt;
}
pub fn tumble(&mut self) {
let seed = self.pos[0] * 23.7 + self.pos[1] * 11.3 + self.vel[0] * 7.9;
let theta = seed.sin().abs() * PI;
let phi = seed.cos().abs() * 2.0 * PI;
self.vel = [
self.speed * theta.sin() * phi.cos(),
self.speed * theta.sin() * phi.sin(),
self.speed * theta.cos(),
];
}
pub fn diffusion_coeff(&self) -> f64 {
if self.tumble_rate < 1e-15 {
return f64::INFINITY;
}
self.speed * self.speed / (3.0 * self.tumble_rate)
}
}
pub fn active_pressure(rho: f64, speed: f64, tau: f64) -> f64 {
rho * speed * speed * tau / 3.0
}
#[derive(Debug, Clone)]
pub struct ActiveNematic {
pub directors: Vec<[f64; 2]>,
pub defects: Vec<[f64; 2]>,
}
impl ActiveNematic {
pub fn new(n: usize) -> Self {
let directors = vec![[1.0_f64, 0.0_f64]; n];
Self {
directors,
defects: Vec::new(),
}
}
pub fn add_director(&mut self, d: [f64; 2]) {
self.directors.push(normalise2(d));
}
pub fn nematic_order_parameter(&self) -> f64 {
s_order_parameter_2d(&self.directors)
}
pub fn detect_defects(&self) -> usize {
self.defects.len()
}
}
pub fn s_order_parameter_2d(directors: &[[f64; 2]]) -> f64 {
if directors.is_empty() {
return 0.0;
}
let n = directors.len() as f64;
let sum: f64 = directors
.iter()
.map(|d| {
let nd = normalise2(*d);
let theta = nd[1].atan2(nd[0]);
(2.0 * theta).cos()
})
.sum();
sum / n
}
pub fn motility_induced_phase_separation(phi: f64, pe: f64) -> bool {
let phi = phi.clamp(0.0, 1.0 - 1e-10);
let pe_c = (1.0 + 2.0 * phi) / (1.0 - phi).powi(3);
pe > pe_c
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn test_active_particle_new() {
let p = ActiveParticle::new([1.0, 2.0], 0.5, 0.1);
assert_eq!(p.pos, [1.0, 2.0]);
assert!((p.speed - 0.5).abs() < EPS);
}
#[test]
fn test_move_forward_changes_position() {
let mut p = ActiveParticle::new([0.0, 0.0], 1.0, 0.0);
p.orientation = 0.0; p.move_forward(0.1);
assert!((p.pos[0] - 0.1).abs() < 1e-10, "pos[0]={}", p.pos[0]);
assert!(p.pos[1].abs() < 1e-10, "pos[1]={}", p.pos[1]);
}
#[test]
fn test_move_forward_y_direction() {
let mut p = ActiveParticle::new([0.0, 0.0], 1.0, 0.0);
p.orientation = PI / 2.0; p.move_forward(1.0);
assert!(
p.pos[0].abs() < 1e-10,
"pos[0] should be ~0, got {}",
p.pos[0]
);
assert!((p.pos[1] - 1.0).abs() < 1e-10, "pos[1]={}", p.pos[1]);
}
#[test]
fn test_direction_unit_vector() {
let p = ActiveParticle::new([0.0, 0.0], 1.0, 0.1);
let d = p.direction();
let norm = (d[0] * d[0] + d[1] * d[1]).sqrt();
assert!((norm - 1.0).abs() < EPS, "direction norm={norm}");
}
#[test]
fn test_update_orientation_single_neighbor() {
let mut p = ActiveParticle::new([0.0, 0.0], 1.0, 0.0);
p.update_orientation(&[PI / 4.0], 0.0, 0.01);
assert!((p.orientation - PI / 4.0).abs() < 1e-10);
}
#[test]
fn test_vicsek_model_has_n_particles() {
let model = VicsekModel::new(10, 0.5, 0.1, 1.0, 10.0);
assert_eq!(model.particles.len(), 10);
}
#[test]
fn test_vicsek_order_parameter_in_range() {
let model = VicsekModel::new(20, 0.5, 0.1, 2.0, 10.0);
let op = model.order_parameter();
assert!((0.0..=1.0 + EPS).contains(&op), "order parameter={op}");
}
#[test]
fn test_vicsek_step_does_not_panic() {
let mut model = VicsekModel::new(10, 0.5, 0.1, 1.0, 10.0);
model.step(0.1);
}
#[test]
fn test_vicsek_positions_wrapped_in_box() {
let mut model = VicsekModel::new(10, 1.0, 0.0, 10.0, 5.0);
for _ in 0..20 {
model.step(0.1);
}
for p in &model.particles {
assert!(p.pos[0] >= 0.0 && p.pos[0] < model.box_size);
assert!(p.pos[1] >= 0.0 && p.pos[1] < model.box_size);
}
}
#[test]
fn test_vicsek_fully_aligned_order_parameter_near_one() {
let n = 5;
let speed = 1.0;
let velocities: Vec<[f64; 2]> = vec![[speed, 0.0]; n];
let op = vicsek_order_parameter(&velocities, speed);
assert!((op - 1.0).abs() < 1e-10, "op={op}");
}
#[test]
fn test_vicsek_mean_velocity_direction() {
let mut model = VicsekModel::new(4, 1.0, 0.0, 10.0, 20.0);
for p in model.particles.iter_mut() {
p.orientation = 0.0;
p.vel = [1.0, 0.0];
}
let mv = model.mean_velocity();
assert!((mv[0] - 1.0).abs() < EPS, "mean_vel_x={}", mv[0]);
}
#[test]
fn test_neighbors_includes_self() {
let positions = vec![[0.0, 0.0], [5.0, 5.0], [10.0, 10.0]];
let nb = neighbors_within_radius(&positions, 0, 1.0, 20.0);
assert!(nb.contains(&0), "neighbors should include self");
}
#[test]
fn test_neighbors_finds_close_particle() {
let positions = vec![[0.0, 0.0], [0.5, 0.0]];
let nb = neighbors_within_radius(&positions, 0, 1.0, 20.0);
assert!(nb.contains(&1), "should find particle at 0.5");
}
#[test]
fn test_neighbors_excludes_distant_particle() {
let positions = vec![[0.0, 0.0], [5.0, 0.0]];
let nb = neighbors_within_radius(&positions, 0, 1.0, 20.0);
assert!(!nb.contains(&1), "should not find distant particle");
}
#[test]
fn test_neighbors_periodic_bc() {
let positions = vec![[0.1, 0.0], [19.9, 0.0]];
let nb = neighbors_within_radius(&positions, 0, 1.0, 20.0);
assert!(nb.contains(&1), "periodic neighbour not found");
}
#[test]
fn test_run_tumble_new() {
let rt = RunAndTumble::new(10.0, 1.0);
assert_eq!(rt.pos, [0.0, 0.0, 0.0]);
assert!((rt.vel[0] - 10.0).abs() < EPS);
}
#[test]
fn test_run_tumble_moves_forward() {
let mut rt = RunAndTumble::new(1.0, 0.0); let pos_before = rt.pos;
rt.step(0.1);
let moved = rt
.pos
.iter()
.zip(pos_before.iter())
.any(|(a, b)| (a - b).abs() > 1e-12);
assert!(moved, "particle should have moved");
}
#[test]
fn test_diffusion_coeff_positive() {
let rt = RunAndTumble::new(1.0, 1.0);
assert!(rt.diffusion_coeff() > 0.0);
}
#[test]
fn test_diffusion_coeff_formula() {
let v = 2.0_f64;
let lam = 3.0_f64;
let rt = RunAndTumble::new(v, lam);
let expected = v * v / (3.0 * lam);
assert!((rt.diffusion_coeff() - expected).abs() < EPS);
}
#[test]
fn test_diffusion_coeff_decreases_with_tumble_rate() {
let d1 = RunAndTumble::new(1.0, 1.0).diffusion_coeff();
let d2 = RunAndTumble::new(1.0, 2.0).diffusion_coeff();
assert!(d1 > d2, "higher tumble rate → lower diffusivity");
}
#[test]
fn test_active_pressure_increases_with_speed() {
let p1 = active_pressure(1e15, 1.0, 1.0);
let p2 = active_pressure(1e15, 2.0, 1.0);
assert!(p2 > p1, "active pressure should increase with speed");
}
#[test]
fn test_active_pressure_formula() {
let rho = 1.0;
let v = 2.0;
let tau = 3.0;
let expected = rho * v * v * tau / 3.0;
let got = active_pressure(rho, v, tau);
assert!((got - expected).abs() < EPS);
}
#[test]
fn test_s_order_fully_aligned() {
let dirs: Vec<[f64; 2]> = vec![[1.0, 0.0]; 10];
let s = s_order_parameter_2d(&dirs);
assert!((s - 1.0).abs() < 1e-10, "S={s}");
}
#[test]
fn test_s_order_in_range() {
let dirs: Vec<[f64; 2]> = vec![[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]];
let s = s_order_parameter_2d(&dirs);
assert!((-0.5 - 1e-10..=1.0 + 1e-10).contains(&s), "S={s}");
}
#[test]
fn test_s_order_empty_returns_zero() {
assert_eq!(s_order_parameter_2d(&[]), 0.0);
}
#[test]
fn test_active_nematic_new() {
let an = ActiveNematic::new(5);
assert_eq!(an.directors.len(), 5);
}
#[test]
fn test_active_nematic_add_director() {
let mut an = ActiveNematic::new(0);
an.add_director([1.0, 0.5]);
assert_eq!(an.directors.len(), 1);
}
#[test]
fn test_active_nematic_order_parameter_range() {
let an = ActiveNematic::new(10);
let s = an.nematic_order_parameter();
assert!((-0.5 - 1e-10..=1.0 + 1e-10).contains(&s), "S={s}");
}
#[test]
fn test_active_nematic_no_defects_initially() {
let an = ActiveNematic::new(10);
assert_eq!(an.detect_defects(), 0);
}
#[test]
fn test_mips_high_pe_triggers() {
assert!(motility_induced_phase_separation(0.3, 1000.0));
}
#[test]
fn test_mips_low_pe_no_separation() {
assert!(!motility_induced_phase_separation(0.1, 1.0));
}
#[test]
fn test_vicsek_order_empty() {
assert_eq!(vicsek_order_parameter(&[], 1.0), 0.0);
}
}