volterra_solver/lib.rs
1#![allow(clippy::needless_range_loop)]
2// ~/volterra/volterra-solver/src/lib.rs
3
4//! # volterra-solver
5//!
6//! Nematohydrodynamics solvers for active nematics on Cartesian grids and
7//! Riemannian 2-manifolds.
8//!
9//! ## Modules
10//!
11//! | Symbol | Function |
12//! |--------|----------|
13//! | [`ActiveNematicEngine`] | Operator-split active nematic engine on 2-manifolds |
14//! | [`NematicField2D`] | Complex nematic field (Section\<2\> wrapper) |
15//! | [`StokesSolver`] | Trait with stream-function and Killing backends |
16//! | [`molecular_field`] | Landau-de Gennes H = -δF/δQ |
17//! | [`beris_edwards_rhs`] | Full ∂_t Q RHS (dry active model) |
18//! | [`EulerIntegrator`] | First-order Euler time step |
19//! | [`RK4Integrator`] | Fourth-order Runge-Kutta time step |
20//! | [`k0_convolution`] | K₀ Bessel kernel convolution for ℳ_SM (Component 2) |
21//! | [`scan_defects`] | Holonomy-based defect detection (wraps cartan-geo) |
22//! | [`DefectInfo`] | Position, charge, and frame of a detected disclination |
23//!
24//! ## Physics: dry active nematic (Component 1)
25//!
26//! The governing equation is:
27//!
28//! ```text
29//! ∂_t Q = Γ_r · H_active
30//!
31//! H_active = K_r ∇²Q + (ζ_eff/2 - a) Q - 2c tr(Q²) Q
32//! ```
33//!
34//! where `a = a_landau` (negative for the ordered phase), `c = c_landau > 0`,
35//! and `ζ_eff/2 - a` is the effective linear driving.
36//! When `ζ_eff > 2|a|` (equivalently `a_eff < 0`) the system enters the active
37//! turbulent phase with dense defects.
38//!
39//! Including flow (optional):
40//!
41//! ```text
42//! ∂_t Q = -u·∇Q + S(W,Q) + Γ_r H_active
43//! ```
44//!
45//! where the co-rotation/strain term is `S(W,Q) = λ(QW + WQ) - (QΩ + ΩQ)`.
46//!
47//! ## Physics: transfer map ℳ_SM (Component 2)
48//!
49//! The orientational transfer to the lyotropic lipid phase is:
50//!
51//! ```text
52//! Q^lip(x) = ℳ_SM(Q^rot)(x)
53//! = ∫ K₀(|x-x'|/ξ_l) Q^rot(x') d²x' / (2π ξ_l²)
54//! ```
55//!
56//! where K₀ is the modified Bessel function of the second kind, order 0,
57//! and ξ_l is the lipid coupling length. This is computed by [`k0_convolution`].
58//!
59//! ## References
60//!
61//! - Beris, A. N. & Edwards, B. J. (1994). *Thermodynamics of Flowing Systems*.
62//! - Doostmohammadi, A. et al. (2018). "Active nematics." *Nature Commun.* **9**, 3246.
63//! - Giomi, L. (2015). "Geometry and topology of turbulence in active nematics."
64//! *Phys. Rev. X* **5**, 031003.
65
66use rand::SeedableRng;
67use rand::rngs::SmallRng;
68use rand::Rng;
69use rustfft::{FftPlanner, num_complex::Complex};
70use volterra_core::{Integrator, ActiveNematicParams};
71use volterra_fields::{QField2D, ScalarField2D, VelocityField2D};
72
73use cartan_geo::holonomy::{Disclination, scan_disclinations};
74use cartan_manifolds::frame_field::FrameField3D;
75
76pub mod mol_field_3d;
77pub use mol_field_3d::{molecular_field_3d, molecular_field_3d_par, euler_step_fused_par, co_rotation_3d};
78
79pub mod beris_3d;
80pub use beris_3d::{beris_edwards_rhs_3d, beris_edwards_rhs_3d_par_dry, euler_step_par, EulerIntegrator3D, RK4Integrator3D};
81
82pub mod stokes_3d;
83pub use stokes_3d::stokes_solve_3d;
84
85pub mod ch_3d;
86pub use ch_3d::ch_step_etd_3d;
87
88pub mod defects_3d;
89pub use defects_3d::{scan_defects_3d, track_defect_events};
90
91pub mod runner_3d;
92pub use runner_3d::{run_dry_active_nematic_3d, run_bech_3d, SnapStats3D, BechStats3D};
93
94pub mod gauss_bonnet_3d;
95pub use gauss_bonnet_3d::gauss_bonnet_chi;
96
97pub mod runner_dec;
98pub use runner_dec::{run_dry_active_nematic_dec, SnapStatsDec};
99
100pub mod runner_dec_wet;
101pub use runner_dec_wet::run_wet_active_nematic_dec;
102
103pub mod engine;
104pub use engine::{NematicEngine, EngineStats};
105
106pub mod nematic_field_2d;
107pub use nematic_field_2d::NematicField2D;
108
109pub mod stokes_trait;
110pub use stokes_trait::{StokesSolver, StokesBackend, FlowField, KillingOperatorSolver, StreamFunctionStokes};
111
112pub mod active_nematic_engine;
113pub use active_nematic_engine::{ActiveNematicEngine, EngineParams, StepDiagnostics};
114
115// ─────────────────────────────────────────────────────────────────────────────
116// Molecular field: H = -δF/δQ
117// ─────────────────────────────────────────────────────────────────────────────
118
119/// Compute the active molecular field `H_active = -δF_active/δQ` at each vertex.
120///
121/// The active free energy density is:
122///
123/// ```text
124/// f = (K_r/2)|∇Q|² + (a_eff/2) tr(Q²) + (c/2)(tr(Q²))²
125/// ```
126///
127/// where `a_eff = a_landau - ζ_eff/2`. The molecular field (functional derivative):
128///
129/// ```text
130/// H_{active,α} = K_r ∇²q_α - 2a_eff q_α - 4c(q1²+q2²) q_α
131/// ```
132///
133/// This is the RHS driver in the dry active model `∂_t Q = Γ_r H_active`.
134pub fn molecular_field(q: &QField2D, params: &ActiveNematicParams) -> QField2D {
135 let a_eff = params.a_eff();
136 let c = params.c_landau;
137 let k_r = params.k_r;
138
139 let lap = q.laplacian();
140 let sq = q.order_param_sq(); // q1² + q2² at each vertex
141
142 let mut out = QField2D::zeros(q.nx, q.ny, q.dx);
143 for k in 0..q.len() {
144 let [q1, q2] = q.q[k];
145 let s2 = sq[k]; // q1² + q2²
146 out.q[k][0] = k_r * lap.q[k][0] - 2.0 * a_eff * q1 - 4.0 * c * s2 * q1;
147 out.q[k][1] = k_r * lap.q[k][1] - 2.0 * a_eff * q2 - 4.0 * c * s2 * q2;
148 }
149 out
150}
151
152// ─────────────────────────────────────────────────────────────────────────────
153// Co-rotation / strain coupling S(W, Q)
154// ─────────────────────────────────────────────────────────────────────────────
155
156/// Compute the co-rotation–strain coupling `S(W, Q)` at each vertex.
157///
158/// For a 2D Q-tensor `Q = [[q1,q2],[q2,-q1]]` and 2D velocity gradients,
159/// the coupling term (following Beris-Edwards with alignment parameter λ) is:
160///
161/// ```text
162/// S(W,Q) = λ(D·Q + Q·D) - (Ω·Q - Q·Ω)
163/// - λ tr(D·Q) I
164/// ```
165///
166/// where `D = (∇u + (∇u)^T)/2` (strain rate) and `Ω = (∇u - (∇u)^T)/2` (vorticity).
167///
168/// In 2D components (using central differences for ∇u):
169///
170/// ```text
171/// Dxx = ∂_x vx, Dyy = ∂_y vy, Dxy = (∂_x vy + ∂_y vx)/2
172/// Ωxy = (∂_x vy - ∂_y vx)/2 (antisymmetric part)
173/// ```
174///
175/// Returns a `QField2D` of the same shape as `q`.
176pub fn corotation_strain(q: &QField2D, v: &VelocityField2D, params: &ActiveNematicParams) -> QField2D {
177 let lambda = params.lambda;
178 let dx = q.dx;
179 let mut out = QField2D::zeros(q.nx, q.ny, q.dx);
180
181 for i in 0..q.nx {
182 for j in 0..q.ny {
183 let k = q.idx(i, j);
184
185 // Central differences for velocity gradients.
186 let ip = v.idx_i(i as i64 + 1, j as i64);
187 let im = v.idx_i(i as i64 - 1, j as i64);
188 let jp = v.idx_i(i as i64, j as i64 + 1);
189 let jm = v.idx_i(i as i64, j as i64 - 1);
190
191 let dvx_dx = (v.v[ip][0] - v.v[im][0]) / (2.0 * dx);
192 let dvx_dy = (v.v[jp][0] - v.v[jm][0]) / (2.0 * dx);
193 let dvy_dx = (v.v[ip][1] - v.v[im][1]) / (2.0 * dx);
194 let dvy_dy = (v.v[jp][1] - v.v[jm][1]) / (2.0 * dx);
195
196 // Strain rate tensor D (symmetric part of ∇u).
197 let d_xx = dvx_dx;
198 let d_yy = dvy_dy;
199 let d_xy = 0.5 * (dvx_dy + dvy_dx);
200
201 // Vorticity (antisymmetric part of ∇u): Ω_xy = (∂_x vy - ∂_y vx)/2.
202 let omega = 0.5 * (dvy_dx - dvx_dy);
203
204 // Q components.
205 let [q1, q2] = q.q[k];
206
207 // D·Q + Q·D for 2D Q = [[q1,q2],[q2,-q1]], D = [[dxx,dxy],[dxy,dyy]]:
208 // (D·Q)_11 = dxx*q1 + dxy*q2, (Q·D)_11 = q1*dxx + q2*dxy
209 // So (D·Q + Q·D)_11 = 2(dxx*q1 + dxy*q2)
210 // (D·Q)_12 = dxx*q2 - dxy*q1 + dxy*q1 - q2*dyy = (dxx-dyy)*q2
211 // Wait, let me compute properly:
212 // Q = [[q1,q2],[q2,-q1]], D = [[dxx,dxy],[dxy,dyy]]
213 // D·Q = [[dxx*q1+dxy*q2, dxx*q2-dxy*q1],[dxy*q1+dyy*q2, dxy*q2-dyy*q1]]
214 // Q·D = [[q1*dxx+q2*dxy, q1*dxy+q2*dyy],[q2*dxx-q1*dxy, q2*dxy-q1*dyy]]
215 // D·Q + Q·D (only need (0,0) and (0,1) for sym-traceless):
216 // (0,0): dxx*q1+dxy*q2 + q1*dxx+q2*dxy = 2(dxx*q1 + dxy*q2)
217 // (0,1): dxx*q2-dxy*q1 + q1*dxy+q2*dyy
218 // = dxx*q2 + dyy*q2 + dxy*(q1-q1) = (dxx+dyy)*q2 - 0... no
219 // = dxx*q2 - dxy*q1 + q1*dxy + q2*dyy = (dxx+dyy)*q2 = 0 (incompressible: dxx+dyy=0)
220 // For incompressible flow: dxx + dyy = 0 => d_yy = -d_xx
221 let d_yy_incomp = -d_xx; // enforce incompressibility
222
223 let dqdq_11 = 2.0 * (d_xx * q1 + d_xy * q2);
224 let dqdq_12 = (d_xx + d_yy_incomp) * q2; // = 0 (incompressible: d_xy - d_xy = 0)
225 // Actually: (D·Q+Q·D)_12 = (dxx+dyy)*q2 = 0 for incompressible flow.
226 // Let me redo without the incompressible assumption for generality:
227 // (0,1) component: (D·Q)_01 + (Q·D)_01
228 // = (d_xx*q2 - d_xy*q1) + (q1*d_xy + q2*d_yy)
229 // = q2*(d_xx + d_yy) + q1*(d_xy - d_xy) ... wait
230 // = d_xx*q2 - d_xy*q1 + q1*d_xy + q2*d_yy
231 // = q2*(d_xx + d_yy)
232 let dqdq_12_gen = q2 * (d_xx + d_yy);
233
234 // tr(D·Q) for subtracting: tr(D·Q) = dxx*q1 + dxy*q2 + dxy*q2 - dyy*q1
235 // = (dxx-dyy)*q1 + 2*dxy*q2
236 let tr_dq = (d_xx - d_yy) * q1 + 2.0 * d_xy * q2;
237
238 // Ω·Q - Q·Ω for Ω = [[0,omega],[-omega,0]]:
239 // (Ω·Q)_11 = 0*q1 + omega*q2 + (-omega)*q2 = 0... wait
240 // Ω = [[0, omega], [-omega, 0]] (2D antisymmetric)
241 // Ω·Q = [[omega*q2, -omega*q1],[... ]]
242 // Ω·Q (0,0) = 0*q1 + omega*q2 = omega*q2? No.
243 // Ω = [[0, omega],[-omega, 0]], Q = [[q1, q2],[q2,-q1]]
244 // (Ω·Q)_00 = 0*q1 + omega*q2 = omega*q2
245 // (Ω·Q)_01 = 0*q2 + omega*(-q1) = -omega*q1
246 // (Q·Ω)_00 = q1*0 + q2*(-omega) = -omega*q2
247 // (Q·Ω)_01 = q1*omega + q2*0 = omega*q1
248 // (Ω·Q - Q·Ω)_00 = omega*q2 - (-omega*q2) = 2*omega*q2
249 // (Ω·Q - Q·Ω)_01 = -omega*q1 - omega*q1 = -2*omega*q1
250 let oq_qo_11 = 2.0 * omega * q2;
251 let oq_qo_12 = -2.0 * omega * q1;
252
253 // S(W,Q) = λ(D·Q + Q·D) - (Ω·Q - Q·Ω) - λ tr(D·Q) I
254 // Component 1 (q1 direction):
255 out.q[k][0] =
256 lambda * (dqdq_11 - tr_dq) - oq_qo_11;
257 // Component 2 (q2 direction):
258 out.q[k][1] =
259 lambda * (dqdq_12_gen) - oq_qo_12;
260
261 let _ = dqdq_12; // suppress unused warning
262 }
263 }
264 out
265}
266
267// ─────────────────────────────────────────────────────────────────────────────
268// Beris-Edwards RHS
269// ─────────────────────────────────────────────────────────────────────────────
270
271/// Compute `dQ/dt` from the Beris-Edwards equation.
272///
273/// For the dry active model (v = None):
274///
275/// ```text
276/// dQ/dt = Γ_r · H_active
277/// ```
278///
279/// For the hydrodynamic model (v = Some(...)):
280///
281/// ```text
282/// dQ/dt = -u·∇Q + S(W,Q) + Γ_r · H_active
283/// ```
284///
285/// The velocity field `v` is not updated here (it must be provided as input,
286/// e.g., from a Stokes solver). For Component 1 validation of the dry scaling
287/// ρ_d ~ ζ_eff/K_r, pass `v = None`.
288pub fn beris_edwards_rhs(
289 q: &QField2D,
290 v: Option<&VelocityField2D>,
291 params: &ActiveNematicParams,
292) -> QField2D {
293 let h = molecular_field(q, params);
294 let mut rhs = h.scale(params.gamma_r);
295
296 if let Some(vel) = v {
297 let advection = vel.advect(q);
298 let corot = corotation_strain(q, vel, params);
299 // dQ/dt = -u·∇Q + S(W,Q) + Γ_r H
300 rhs = rhs.add(&corot).add(&advection.scale(-1.0));
301 }
302
303 rhs
304}
305
306// ─────────────────────────────────────────────────────────────────────────────
307// Time integrators
308// ─────────────────────────────────────────────────────────────────────────────
309
310/// First-order forward Euler integrator.
311///
312/// `Q_{n+1} = Q_n + dt · f(Q_n)`
313pub struct EulerIntegrator;
314
315impl Integrator<QField2D> for EulerIntegrator {
316 fn step<F>(&self, state: &QField2D, dt: f64, rhs: F) -> QField2D
317 where
318 F: Fn(&QField2D) -> QField2D,
319 {
320 let dq = rhs(state);
321 state.add(&dq.scale(dt))
322 }
323}
324
325/// Fourth-order Runge-Kutta integrator (RK4).
326///
327/// ```text
328/// k1 = f(Q_n)
329/// k2 = f(Q_n + dt/2 · k1)
330/// k3 = f(Q_n + dt/2 · k2)
331/// k4 = f(Q_n + dt · k3)
332/// Q_{n+1} = Q_n + dt/6 · (k1 + 2k2 + 2k3 + k4)
333/// ```
334pub struct RK4Integrator;
335
336impl Integrator<QField2D> for RK4Integrator {
337 fn step<F>(&self, state: &QField2D, dt: f64, rhs: F) -> QField2D
338 where
339 F: Fn(&QField2D) -> QField2D,
340 {
341 let k1 = rhs(state);
342 let k2 = rhs(&state.add(&k1.scale(dt / 2.0)));
343 let k3 = rhs(&state.add(&k2.scale(dt / 2.0)));
344 let k4 = rhs(&state.add(&k3.scale(dt)));
345
346 // Q_{n+1} = Q_n + dt/6 (k1 + 2k2 + 2k3 + k4)
347 let sum = k1
348 .add(&k2.scale(2.0))
349 .add(&k3.scale(2.0))
350 .add(&k4);
351 state.add(&sum.scale(dt / 6.0))
352 }
353}
354
355// ─────────────────────────────────────────────────────────────────────────────
356// K₀ Bessel kernel convolution for transfer map ℳ_SM (Component 2)
357// ─────────────────────────────────────────────────────────────────────────────
358
359/// Modified Bessel function K₀(x) for x > 0.
360///
361/// Uses the polynomial approximations from Abramowitz & Stegun §9.8:
362///
363/// - For 0 < x ≤ 2: `K₀(x) = -ln(x/2) I₀(x) + poly(x²)`
364/// - For x > 2: `K₀(x) = (π/(2x))^{1/2} exp(-x) poly(1/x)`
365///
366/// The approximation is accurate to ≈ 1.9e-7 (A&S 9.8.5 / 9.8.6).
367fn bessel_k0(x: f64) -> f64 {
368 if x <= 0.0 {
369 return f64::INFINITY; // K₀ diverges at 0
370 }
371 if x <= 2.0 {
372 // A&S 9.8.1: I₀(x), variable t = (x/3.75)²
373 let ti = x / 3.75;
374 let ti2 = ti * ti;
375 let i0 = 1.0
376 + 3.5156329 * ti2
377 + 3.0899424 * ti2 * ti2
378 + 1.2067492 * ti2.powi(3)
379 + 0.2659732 * ti2.powi(4)
380 + 0.0360768 * ti2.powi(5)
381 + 0.0045813 * ti2.powi(6);
382 // A&S 9.8.5: K₀(x) = -ln(x/2) I₀(x) + p(t), variable t = (x/2)²
383 let tk = x / 2.0;
384 let tk2 = tk * tk;
385 let p = -0.57721566
386 + 0.42278420 * tk2
387 + 0.23069756 * tk2 * tk2
388 + 0.03488590 * tk2.powi(3)
389 + 0.00262698 * tk2.powi(4)
390 + 0.00010750 * tk2.powi(5)
391 + 0.0000074 * tk2.powi(6);
392 -(x / 2.0).ln() * i0 + p
393 } else {
394 // A&S 9.8.6: asymptotic, variable t = 2/x
395 let t = 2.0 / x;
396 let poly = 1.25331414
397 - 0.07832358 * t
398 + 0.02189568 * t * t
399 - 0.01062446 * t.powi(3)
400 + 0.00587872 * t.powi(4)
401 - 0.00251540 * t.powi(5)
402 + 0.00053208 * t.powi(6);
403 ((-x).exp() / x.sqrt()) * poly
404 }
405}
406
407/// Compute the orientational transfer map `ℳ_SM(Q^rot)` via K₀ convolution.
408///
409/// ```text
410/// Q^lip(x) = ∫ K₀(|x-x'|/ξ_l) Q^rot(x') d²x' / (2π ξ_l²)
411/// ```
412///
413/// The integral is truncated at `r_max = 6 ξ_l` (the K₀ kernel decays as
414/// `exp(-r/ξ_l)`, so the tail beyond 6ξ_l contributes < 2.5e-3 of the total).
415/// Self-interaction (r = 0) is excluded since K₀(0) diverges: the singular
416/// part is integrable and represents the local self-energy, which is already
417/// captured by the Landau-de Gennes free energy. In practice this is handled
418/// by skipping r = 0 in the discrete sum (error O(dx²) for smooth Q^rot).
419///
420/// The result is the lipid Q-tensor `Q^lip`, the primary output of Component 2.
421pub fn k0_convolution(q_rot: &QField2D, params: &ActiveNematicParams) -> QField2D {
422 let xi_l = params.xi_l;
423 let dx = q_rot.dx;
424 let nx = q_rot.nx as i64;
425 let ny = q_rot.ny as i64;
426
427 // Kernel support: truncate at 6 ξ_l, but clamp to half the grid to avoid
428 // double-counting from periodic wrapping (cutoff must not exceed nx/2 or ny/2).
429 let cutoff_raw = (6.0 * xi_l / dx).ceil() as i64;
430 let cutoff = cutoff_raw
431 .min((q_rot.nx as i64) / 2)
432 .min((q_rot.ny as i64) / 2);
433 // Normalization: ∫ K₀(r/ξ_l) d²r = 2π ξ_l². Discrete: dx² * sum = 2π ξ_l².
434 let norm = 2.0 * std::f64::consts::PI * xi_l * xi_l;
435
436 let mut out = QField2D::zeros(q_rot.nx, q_rot.ny, dx);
437
438 for i in 0..nx {
439 for j in 0..ny {
440 let k_out = q_rot.idx_i(i, j);
441 let mut s0 = 0.0_f64;
442 let mut s1 = 0.0_f64;
443
444 // Use half-open range -cutoff..cutoff to avoid double-counting the
445 // Nyquist offset on a periodic grid (di=-N/2 and di=+N/2 are the same
446 // vertex, so using ..= would count it twice).
447 for di in -cutoff..cutoff {
448 let r_x = di as f64 * dx;
449 for dj in -cutoff..cutoff {
450 let r_y = dj as f64 * dx;
451 let r = (r_x * r_x + r_y * r_y).sqrt();
452 if r < 0.5 * dx {
453 // Skip self-interaction: K₀(0) diverges.
454 continue;
455 }
456 let k0_val = bessel_k0(r / xi_l);
457 let src = q_rot.idx_i(i + di, j + dj);
458 s0 += k0_val * q_rot.q[src][0];
459 s1 += k0_val * q_rot.q[src][1];
460 }
461 }
462
463 out.q[k_out][0] = s0 * dx * dx / norm;
464 out.q[k_out][1] = s1 * dx * dx / norm;
465 }
466 }
467
468 out
469}
470
471// ─────────────────────────────────────────────────────────────────────────────
472// Spectral Stokes solver (active stress → incompressible velocity field)
473// ─────────────────────────────────────────────────────────────────────────────
474
475/// Solve the 2D incompressible Stokes equation for the active velocity field.
476///
477/// Given the Q-tensor field, computes the incompressible flow driven by the
478/// active stress `σ^a = ζ_eff Q` via the stream-function biharmonic equation:
479///
480/// ```text
481/// η ∇⁴ψ = ∂_x F_y - ∂_y F_x, F = ∇·σ^a = ζ_eff ∇·Q
482/// ```
483///
484/// Solved spectrally (pseudospectral FFT on a periodic grid):
485///
486/// ```text
487/// ψ̂(k) = ζ [(k_y² - k_x²) q̂_2 + 2 k_x k_y q̂_1] / [η (k²)²]
488/// ```
489///
490/// The velocity components follow from `v_x = ∂_y ψ`, `v_y = -∂_x ψ`.
491/// The k=0 mode (uniform translation) is set to zero.
492///
493/// Returns a [`VelocityField2D`] on the same grid.
494pub fn stokes_solve(q: &QField2D, params: &ActiveNematicParams) -> VelocityField2D {
495 let nx = q.nx;
496 let ny = q.ny;
497 let n = nx * ny;
498 let dx = q.dx;
499 let eta = params.eta;
500 let ze = params.zeta_eff;
501
502 let mut planner = FftPlanner::<f64>::new();
503 let fft_x = planner.plan_fft_forward(nx);
504 let fft_y = planner.plan_fft_forward(ny);
505 let ifft_x = planner.plan_fft_inverse(nx);
506 let ifft_y = planner.plan_fft_inverse(ny);
507
508 // Wave-vector frequencies (radians/unit-length): k_j = 2π j / (N dx)
509 // Using the standard DFT ordering: j = 0..N/2, then -(N/2-1)..-1 wrapped.
510 let kx_vec: Vec<f64> = (0..nx).map(|i| {
511 let i = i as i64;
512 let n = nx as i64;
513 let i_shifted = if i <= n / 2 { i } else { i - n };
514 2.0 * std::f64::consts::PI * i_shifted as f64 / (nx as f64 * dx)
515 }).collect();
516 let ky_vec: Vec<f64> = (0..ny).map(|j| {
517 let j = j as i64;
518 let n = ny as i64;
519 let j_shifted = if j <= n / 2 { j } else { j - n };
520 2.0 * std::f64::consts::PI * j_shifted as f64 / (ny as f64 * dx)
521 }).collect();
522
523 // Helper: forward 2D FFT on a real-valued row-major field → Complex array.
524 // Layout: q[i * ny + j], i in 0..nx, j in 0..ny.
525 let fft2_real = |field: &[f64]| -> Vec<Complex<f64>> {
526 let mut buf: Vec<Complex<f64>> = field.iter().map(|&x| Complex::new(x, 0.0)).collect();
527 // FFT each row (along j / y direction).
528 for row in buf.chunks_mut(ny) {
529 fft_y.process(row);
530 }
531 // Transpose to ny×nx, FFT each row (= original column / x direction), transpose back.
532 let mut transposed: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
533 for i in 0..nx {
534 for j in 0..ny {
535 transposed[j * nx + i] = buf[i * ny + j];
536 }
537 }
538 for col in transposed.chunks_mut(nx) {
539 fft_x.process(col);
540 }
541 for i in 0..nx {
542 for j in 0..ny {
543 buf[i * ny + j] = transposed[j * nx + i];
544 }
545 }
546 buf
547 };
548
549 // Helper: inverse 2D FFT on complex array → real-valued field (normalized by N).
550 let ifft2_to_real = |buf: &mut Vec<Complex<f64>>| -> Vec<f64> {
551 // Transpose, IFFT each column (x), transpose back, then IFFT each row (y).
552 let mut transposed: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
553 for i in 0..nx {
554 for j in 0..ny {
555 transposed[j * nx + i] = buf[i * ny + j];
556 }
557 }
558 for col in transposed.chunks_mut(nx) {
559 ifft_x.process(col);
560 }
561 for i in 0..nx {
562 for j in 0..ny {
563 buf[i * ny + j] = transposed[j * nx + i];
564 }
565 }
566 for row in buf.chunks_mut(ny) {
567 ifft_y.process(row);
568 }
569 let norm = n as f64;
570 buf.iter().map(|c| c.re / norm).collect()
571 };
572
573 // Extract Q components into row-major flat arrays.
574 let q1_field: Vec<f64> = q.q.iter().map(|[q1, _]| *q1).collect();
575 let q2_field: Vec<f64> = q.q.iter().map(|[_, q2]| *q2).collect();
576
577 let q1_hat = fft2_real(&q1_field);
578 let q2_hat = fft2_real(&q2_field);
579
580 // Compute stream function ψ̂ and velocity components v̂_x, v̂_y in Fourier space.
581 let mut vx_hat: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
582 let mut vy_hat: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
583
584 let i_unit = Complex::new(0.0, 1.0);
585
586 for ii in 0..nx {
587 for jj in 0..ny {
588 let k = ii * ny + jj;
589 let kx = kx_vec[ii];
590 let ky = ky_vec[jj];
591 let k2 = kx * kx + ky * ky;
592 if k2 < 1e-14 {
593 // k=0: no uniform translation (set to zero).
594 vx_hat[k] = Complex::new(0.0, 0.0);
595 vy_hat[k] = Complex::new(0.0, 0.0);
596 continue;
597 }
598 let k4 = k2 * k2;
599 // ψ̂ = ζ [(k_y² - k_x²) q̂₂ + 2 k_x k_y q̂₁] / (η k⁴)
600 let rhs = ze * ((ky * ky - kx * kx) * q2_hat[k] + 2.0 * kx * ky * q1_hat[k]);
601 let psi_hat = rhs / (eta * k4);
602 // v̂_x = i k_y ψ̂, v̂_y = -i k_x ψ̂
603 vx_hat[k] = i_unit * ky * psi_hat;
604 vy_hat[k] = -i_unit * kx * psi_hat;
605 }
606 }
607
608 let vx_field = ifft2_to_real(&mut vx_hat);
609 let vy_field = ifft2_to_real(&mut vy_hat);
610
611 // Pack into VelocityField2D.
612 let v_data: Vec<[f64; 2]> = (0..n).map(|k| [vx_field[k], vy_field[k]]).collect();
613 VelocityField2D { v: v_data, nx, ny, dx }
614}
615
616/// Run the active nematic simulation with full hydrodynamic coupling.
617///
618/// At each time step the Stokes velocity field is re-computed from the active
619/// stress and fed back into the Beris-Edwards equation, enabling the
620/// flow-alignment instability that drives active turbulence.
621///
622/// The defect density in the turbulent steady state follows `ρ_d ~ ζ_eff / K_r`.
623///
624/// # Arguments
625///
626/// - `q_init`: Initial Q-tensor field.
627/// - `params`: Physical and numerical parameters.
628/// - `n_steps`: Total number of time steps.
629/// - `snap_every`: Steps between snapshot statistics.
630pub fn run_active_nematic_hydro(
631 q_init: &QField2D,
632 params: &ActiveNematicParams,
633 n_steps: usize,
634 snap_every: usize,
635) -> (QField2D, Vec<SnapStats>) {
636 let mut q = q_init.clone();
637 let mut stats = Vec::new();
638 let lx = params.nx as f64 * params.dx;
639 let ly = params.ny as f64 * params.dx;
640 let area = lx * ly;
641
642 let use_noise = params.noise_amp > 0.0;
643 let noise_scale = params.noise_amp * params.dt.sqrt();
644 let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
645 ^ (params.ny as u64).wrapping_mul(1442695040888963407)
646 ^ n_steps as u64;
647 let mut rng = SmallRng::seed_from_u64(seed);
648
649 // Euler-step loop: compute v from Stokes, then advance Q.
650 // (Stokes is linear and instantaneous, so it's re-solved each step.)
651 for step in 0..=n_steps {
652 if step % snap_every == 0 {
653 let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
654 let (n_plus, n_minus) = defect_count(&defects);
655 let n_defects = defects.len();
656 stats.push(SnapStats {
657 time: step as f64 * params.dt,
658 mean_s: q.mean_order_param(),
659 n_defects,
660 n_plus,
661 n_minus,
662 defect_density: n_defects as f64 / area,
663 });
664 }
665 if step < n_steps {
666 let v = stokes_solve(&q, params);
667 let p = params.clone();
668 let dq = beris_edwards_rhs(&q, Some(&v), &p);
669 // Euler step (fast; Stokes re-solve dominates cost).
670 q = q.add(&dq.scale(params.dt));
671 if use_noise {
672 for [q1, q2] in q.q.iter_mut() {
673 let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
674 let u2: f64 = rng.random::<f64>();
675 let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
676 let angle = std::f64::consts::TAU * u2;
677 *q1 += mag * angle.cos();
678 *q2 += mag * angle.sin();
679 }
680 }
681 }
682 }
683
684 (q, stats)
685}
686
687// ─────────────────────────────────────────────────────────────────────────────
688// Holonomy-based defect detection
689// ─────────────────────────────────────────────────────────────────────────────
690
691/// Information about a detected topological disclination.
692#[derive(Debug, Clone)]
693pub struct DefectInfo {
694 /// Grid coordinates of the plaquette lower-left corner (i, j).
695 pub plaquette: (usize, usize),
696 /// Rotation angle of the holonomy (in radians). Close to π for ±1/2 disclinations.
697 pub angle: f64,
698 /// Sign of the topological charge: +1 or -1.
699 /// Estimated from the direction of holonomy rotation axis (z-component sign).
700 pub charge_sign: i8,
701}
702
703/// Detect topological defects in a 2D Q-tensor field using holonomy.
704///
705/// Each Q-tensor is embedded as a 3D sym-traceless matrix, converted to an SO(3)
706/// frame via `q_to_frame`, and a plaquette-by-plaquette holonomy scan is performed.
707/// Plaquettes whose holonomy rotation angle exceeds `threshold` (default π/2)
708/// are reported as disclinations.
709///
710/// The defect charge sign is estimated from the sign of the z-component of the
711/// holonomy rotation axis: positive = +1/2, negative = -1/2.
712///
713/// # Arguments
714///
715/// - `q`: The 2D Q-tensor field.
716/// - `threshold`: Rotation angle threshold (radians). Use `π/2` as default.
717pub fn scan_defects(q: &QField2D, threshold: f64) -> Vec<DefectInfo> {
718 // Convert 2D Q-tensors to 3D Q-tensors then to SO(3) frames.
719 let q3d = q.to_q3d();
720 let frame_field = FrameField3D::from_q_field(&q3d);
721
722 // Run holonomy scan.
723 let raw: Vec<Disclination> =
724 scan_disclinations(&frame_field.frames, q.nx, q.ny, threshold);
725
726 // Convert to DefectInfo, estimating charge sign from holonomy axis.
727 raw.into_iter()
728 .map(|d| {
729 // Rotation axis = (H - H^T) / (2 sin θ).
730 // For θ ≈ π, sin(θ) ≈ 0, use the sign of H[2,1] - H[1,2] as a proxy.
731 let h = &d.holonomy;
732 let axis_z_proxy = h[(0, 1)] - h[(1, 0)]; // (H - H^T)_xy = 2 sin(θ) n_z
733 let charge_sign = if axis_z_proxy >= 0.0 { 1_i8 } else { -1_i8 };
734 DefectInfo {
735 plaquette: d.plaquette,
736 angle: d.angle,
737 charge_sign,
738 }
739 })
740 .collect()
741}
742
743/// Count +1/2 and -1/2 disclinations detected in the field.
744///
745/// Returns `(n_plus, n_minus)`. Topological charge conservation requires
746/// `n_plus == n_minus` for a system with periodic boundary conditions
747/// (net charge must vanish).
748pub fn defect_count(defects: &[DefectInfo]) -> (usize, usize) {
749 let n_plus = defects.iter().filter(|d| d.charge_sign > 0).count();
750 let n_minus = defects.iter().filter(|d| d.charge_sign < 0).count();
751 (n_plus, n_minus)
752}
753
754// ─────────────────────────────────────────────────────────────────────────────
755// Simulation runner (dry active nematic)
756// ─────────────────────────────────────────────────────────────────────────────
757
758/// Statistics collected at each snapshot during an active nematic run.
759#[derive(Debug, Clone)]
760pub struct SnapStats {
761 /// Simulation time.
762 pub time: f64,
763 /// Mean scalar order parameter.
764 pub mean_s: f64,
765 /// Total number of detected defects.
766 pub n_defects: usize,
767 /// Number of +1/2 disclinations.
768 pub n_plus: usize,
769 /// Number of -1/2 disclinations.
770 pub n_minus: usize,
771 /// Defect density ρ_d = n_defects / (Lx Ly).
772 pub defect_density: f64,
773}
774
775/// Run the dry active nematic simulation.
776///
777/// Evolves the Q-tensor field forward for `n_steps` time steps using the RK4
778/// integrator. Every `snap_every` steps, detects defects and records statistics.
779///
780/// # Arguments
781///
782/// - `q_init`: Initial Q-tensor field.
783/// - `params`: Physical and numerical parameters.
784/// - `n_steps`: Total number of time steps.
785/// - `snap_every`: Steps between snapshot statistics.
786///
787/// # Returns
788///
789/// A tuple `(q_final, stats)` where `q_final` is the final Q-tensor field and
790/// `stats` is a list of snapshot statistics.
791pub fn run_dry_active_nematic(
792 q_init: &QField2D,
793 params: &ActiveNematicParams,
794 n_steps: usize,
795 snap_every: usize,
796) -> (QField2D, Vec<SnapStats>) {
797 let integrator = RK4Integrator;
798 let mut q = q_init.clone();
799 let mut stats = Vec::new();
800 let lx = params.nx as f64 * params.dx;
801 let ly = params.ny as f64 * params.dx;
802 let area = lx * ly;
803
804 // Langevin noise: Q += noise_amp * sqrt(dt) * W(x,t) per component per vertex.
805 let use_noise = params.noise_amp > 0.0;
806 let noise_scale = params.noise_amp * params.dt.sqrt();
807 // Use a reproducible but unique seed derived from grid dimensions.
808 let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
809 ^ (params.ny as u64).wrapping_mul(1442695040888963407)
810 ^ n_steps as u64;
811 let mut rng = SmallRng::seed_from_u64(seed);
812
813 for step in 0..=n_steps {
814 if step % snap_every == 0 {
815 let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
816 let (n_plus, n_minus) = defect_count(&defects);
817 let n_defects = defects.len();
818 stats.push(SnapStats {
819 time: step as f64 * params.dt,
820 mean_s: q.mean_order_param(),
821 n_defects,
822 n_plus,
823 n_minus,
824 defect_density: n_defects as f64 / area,
825 });
826 }
827 if step < n_steps {
828 let p = params.clone();
829 q = integrator.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
830 // Langevin noise injection (Euler-Maruyama for the stochastic part).
831 // Box-Muller transform: two uniform samples -> two standard normals.
832 if use_noise {
833 let mut iter = q.q.iter_mut();
834 for [q1, q2] in iter.by_ref() {
835 let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
836 let u2: f64 = rng.random::<f64>();
837 let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
838 let angle = std::f64::consts::TAU * u2;
839 *q1 += mag * angle.cos();
840 *q2 += mag * angle.sin();
841 }
842 }
843 }
844 }
845
846 (q, stats)
847}
848
849// ─────────────────────────────────────────────────────────────────────────────
850// Cahn-Hilliard chemical potential (BECH Component)
851// ─────────────────────────────────────────────────────────────────────────────
852
853/// Compute the Cahn-Hilliard chemical potential μ_l at each vertex.
854///
855/// The full BECH chemical potential is:
856///
857/// ```text
858/// μ_l = a_ch φ_l + b_ch φ_l³ - κ_ch ∇²φ_l - χ_MS Tr(Q_lip²)
859/// ```
860///
861/// where `Tr(Q²) = 2(q₁² + q₂²)` for the 2D sym-traceless parametrisation.
862///
863/// The Maier-Saupe term `-χ_MS Tr(Q_lip²)` is the key coupling: it lowers
864/// the chemical potential in regions of high orientational order (large
865/// |Q_lip|), driving lipid accumulation wherever the rotor defect network
866/// has templated strong order in the lipid phase. At defect cores, where
867/// |Q_lip| → 0, the Maier-Saupe term vanishes and the double-well potential
868/// drives φ_l toward the disordered minimum, creating the concentration
869/// contrast that templates concentration-field closure on the defect scale ℓ_d.
870///
871/// The gradient term `-κ_ch ∇²φ_l` penalises sharp concentration fronts,
872/// regularising the chemical potential at the CH coherence scale ξ_CH.
873pub fn ch_chemical_potential(
874 phi: &ScalarField2D,
875 q_lip: &QField2D,
876 params: &ActiveNematicParams,
877) -> ScalarField2D {
878 let lap_phi = phi.laplacian();
879 let n = phi.len();
880 let mut mu = ScalarField2D::zeros(phi.nx, phi.ny, phi.dx);
881 for k in 0..n {
882 let p = phi.phi[k];
883 // Tr(Q²) = 2 Tr(Q_2D²) = 2(q1² + q2²) in the 2D parameterisation.
884 let tr_q2 = 2.0 * (q_lip.q[k][0] * q_lip.q[k][0]
885 + q_lip.q[k][1] * q_lip.q[k][1]);
886 mu.phi[k] = params.a_ch * p
887 + params.b_ch * p * p * p
888 - params.kappa_ch * lap_phi.phi[k]
889 - params.chi_ms * tr_q2;
890 }
891 mu
892}
893
894// ─────────────────────────────────────────────────────────────────────────────
895// ETD1 Cahn-Hilliard time step (BECH Component)
896// ─────────────────────────────────────────────────────────────────────────────
897
898/// Advance the lipid volume fraction φ_l by one time step using an
899/// Exponential Time Differencing (ETD1) scheme.
900///
901/// The Cahn-Hilliard equation is split into stiff-linear and nonlinear parts:
902///
903/// ```text
904/// ∂_t φ = L[φ] + N[φ, Q, v]
905/// ```
906///
907/// where the stiff linear operator is, in Fourier space:
908///
909/// ```text
910/// L̂_k = -M_l (a_ch k² + κ_ch k⁴)
911/// ```
912///
913/// and the nonlinear operator is:
914///
915/// ```text
916/// N = -v·∇φ + M_l ∇²(b_ch φ³ - χ_MS Tr(Q_lip²))
917/// ```
918///
919/// The ETD1 update reads:
920///
921/// ```text
922/// φ̂_k^{n+1} = exp(L̂_k Δt) φ̂_k^n + (exp(L̂_k Δt) - 1)/L̂_k · N̂_k^n
923/// ```
924///
925/// Because L̂_k < 0 for all k ≠ 0, the exponential factor is always
926/// a contraction. The factor `(e^x - 1)/x` is evaluated via its Taylor
927/// expansion for `|x| < 10⁻¹⁰` to avoid catastrophic cancellation.
928/// The k=0 mode is held fixed at the initial mean (Cahn-Hilliard conserves
929/// the total lipid mass ∫ φ d²x).
930///
931/// # Arguments
932///
933/// - `phi`: Current lipid volume-fraction field φ_l^n.
934/// - `q_lip`: Current lipid Q-tensor field (output of K₀ convolution).
935/// - `v`: Current velocity field (from Stokes solver).
936/// - `params`: Physical and numerical parameters.
937pub fn ch_step_etd(
938 phi: &ScalarField2D,
939 q_lip: &QField2D,
940 v: &VelocityField2D,
941 params: &ActiveNematicParams,
942) -> ScalarField2D {
943 let nx = phi.nx;
944 let ny = phi.ny;
945 let n = nx * ny;
946 let dx = phi.dx;
947 let dt = params.dt;
948
949 let mut planner = FftPlanner::<f64>::new();
950 let fft_x = planner.plan_fft_forward(nx);
951 let fft_y = planner.plan_fft_forward(ny);
952 let ifft_x = planner.plan_fft_inverse(nx);
953 let ifft_y = planner.plan_fft_inverse(ny);
954
955 // Wave vectors: k_j = 2π j / (N dx), standard DFT ordering.
956 let kx_vec: Vec<f64> = (0..nx).map(|i| {
957 let i = i as i64;
958 let nm = nx as i64;
959 let is = if i <= nm / 2 { i } else { i - nm };
960 2.0 * std::f64::consts::PI * is as f64 / (nx as f64 * dx)
961 }).collect();
962 let ky_vec: Vec<f64> = (0..ny).map(|j| {
963 let j = j as i64;
964 let nm = ny as i64;
965 let js = if j <= nm / 2 { j } else { j - nm };
966 2.0 * std::f64::consts::PI * js as f64 / (ny as f64 * dx)
967 }).collect();
968
969 // 2D FFT helper for real-valued row-major fields.
970 let fft2_real = |field: &[f64]| -> Vec<Complex<f64>> {
971 let mut buf: Vec<Complex<f64>> = field.iter().map(|&x| Complex::new(x, 0.0)).collect();
972 for row in buf.chunks_mut(ny) { fft_y.process(row); }
973 let mut tr: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
974 for i in 0..nx { for j in 0..ny { tr[j * nx + i] = buf[i * ny + j]; } }
975 for col in tr.chunks_mut(nx) { fft_x.process(col); }
976 for i in 0..nx { for j in 0..ny { buf[i * ny + j] = tr[j * nx + i]; } }
977 buf
978 };
979
980 // 2D IFFT helper: normalises by N.
981 let ifft2_to_real = |buf: &mut Vec<Complex<f64>>| -> Vec<f64> {
982 let mut tr: Vec<Complex<f64>> = vec![Complex::new(0.0, 0.0); n];
983 for i in 0..nx { for j in 0..ny { tr[j * nx + i] = buf[i * ny + j]; } }
984 for col in tr.chunks_mut(nx) { ifft_x.process(col); }
985 for i in 0..nx { for j in 0..ny { buf[i * ny + j] = tr[j * nx + i]; } }
986 for row in buf.chunks_mut(ny) { ifft_y.process(row); }
987 let norm = n as f64;
988 buf.iter().map(|c| c.re / norm).collect()
989 };
990
991 // ── Build the nonlinear source N in real space ───────────────────────
992 //
993 // N = -v·∇φ + M_l ∇²(b_ch φ³ - χ_MS Tr(Q_lip²))
994 //
995 // Step 1: compute pointwise nonlinear source f = b_ch φ³ - χ_MS Tr(Q²).
996 let mut nonlin_src = ScalarField2D::zeros(nx, ny, dx);
997 for k in 0..n {
998 let p = phi.phi[k];
999 let tr_q2 = 2.0 * (q_lip.q[k][0] * q_lip.q[k][0]
1000 + q_lip.q[k][1] * q_lip.q[k][1]);
1001 nonlin_src.phi[k] = params.b_ch * p * p * p - params.chi_ms * tr_q2;
1002 }
1003 // Step 2: M_l ∇²(nonlin_src) via 5-point Laplacian.
1004 let lap_nonlin = nonlin_src.laplacian();
1005 // Step 3: -v·∇φ advection term.
1006 let advection = v.advect_scalar(phi);
1007 // Assemble N = -v·∇φ + M_l ∇²f.
1008 let mut nonlin_real: Vec<f64> = vec![0.0; n];
1009 for k in 0..n {
1010 nonlin_real[k] = -advection.phi[k] + params.m_l * lap_nonlin.phi[k];
1011 }
1012
1013 // ── FFT φ and N ──────────────────────────────────────────────────────
1014 let mut phi_hat = fft2_real(&phi.phi);
1015 let nonlin_hat = fft2_real(&nonlin_real);
1016
1017 // ── ETD1 update in Fourier space ─────────────────────────────────────
1018 for ii in 0..nx {
1019 for jj in 0..ny {
1020 let idx = ii * ny + jj;
1021 let kx = kx_vec[ii];
1022 let ky = ky_vec[jj];
1023 let k2 = kx * kx + ky * ky;
1024
1025 if k2 < 1e-14 {
1026 // k = 0: conserve mean lipid mass (CH is mass-conserving).
1027 // phi_hat[0] unchanged.
1028 continue;
1029 }
1030
1031 let k4 = k2 * k2;
1032 // Linear operator eigenvalue: L_k = -M_l (a_ch k² + κ_ch k⁴) < 0.
1033 let lk = -params.m_l * (params.a_ch * k2 + params.kappa_ch * k4);
1034 let lk_dt = lk * dt;
1035 let exp_lk = lk_dt.exp(); // < 1 since L_k < 0
1036
1037 // (exp(L_k dt) - 1) / (L_k dt): evaluated via Taylor for |L_k dt| < 1e-10.
1038 let expm1_over_lk = if lk_dt.abs() < 1.0e-10 {
1039 // Taylor: (e^x - 1)/x = 1 + x/2 + x²/6 + ...
1040 dt * (1.0 + lk_dt / 2.0 + lk_dt * lk_dt / 6.0)
1041 } else {
1042 (exp_lk - 1.0) / lk
1043 };
1044
1045 // φ̂^{n+1} = e^{L_k dt} φ̂^n + (e^{L_k dt}-1)/L_k · N̂^n
1046 phi_hat[idx] = exp_lk * phi_hat[idx] + expm1_over_lk * nonlin_hat[idx];
1047 }
1048 }
1049
1050 let phi_new_vals = ifft2_to_real(&mut phi_hat);
1051 ScalarField2D { phi: phi_new_vals, nx, ny, dx }
1052}
1053
1054// ─────────────────────────────────────────────────────────────────────────────
1055// BECH simulation runner (full two-field coupled system)
1056// ─────────────────────────────────────────────────────────────────────────────
1057
1058/// Statistics collected at each snapshot during a BECH run.
1059#[derive(Debug, Clone)]
1060pub struct BechStats {
1061 /// Simulation time.
1062 pub time: f64,
1063 /// Mean scalar order parameter of the rotor Q-field.
1064 pub mean_s: f64,
1065 /// Total number of detected disclinations.
1066 pub n_defects: usize,
1067 /// Number of +1/2 disclinations.
1068 pub n_plus: usize,
1069 /// Number of -1/2 disclinations.
1070 pub n_minus: usize,
1071 /// Defect density ρ_d = n_defects / (Lx Ly).
1072 pub defect_density: f64,
1073 /// Mean lipid volume fraction ⟨φ_l⟩ (conserved; monitors numerical drift).
1074 pub mean_phi: f64,
1075 /// Variance of φ_l (grows as phase separation develops near defect cores).
1076 pub phi_variance: f64,
1077 /// Mean |∇φ_l|² (proxy for total CH interfacial area / lipid shell thickness).
1078 pub mean_grad_phi_sq: f64,
1079}
1080
1081/// Run the full Beris-Edwards-Cahn-Hilliard (BECH) simulation.
1082///
1083/// Couples the active rotor Q-tensor field (Beris-Edwards + Stokes) to the
1084/// lyotropic lipid volume fraction φ_l (Cahn-Hilliard + Maier-Saupe) via the
1085/// K₀ orientational transfer map. At each time step:
1086///
1087/// 1. **Stokes**: `v ← stokes_solve(Q^rot, params)` — compute the incompressible
1088/// velocity field driven by active stress σ^a = ζ_eff Q^rot.
1089/// 2. **Beris-Edwards (Euler)**: `Q^rot ← Q^rot + dt · [−v·∇Q^rot + S(W,Q^rot) + Γ_r H]`
1090/// — advance the rotor Q-field with flow.
1091/// 3. **Transfer map**: `Q^lip ← K₀ * Q^rot` — convolve to get the lipid orientational
1092/// field (Component 2 one-way coupling, valid when Da < 1 and Sp < 1).
1093/// 4. **CH-ETD1**: `φ_l ← ch_step_etd(φ_l, Q^lip, v, params)` — advance lipid
1094/// concentration with exact integration of the stiff linear part and
1095/// explicit Euler for the nonlinear Maier-Saupe + advection terms.
1096///
1097/// The Stokes step is re-solved every step because the active stress changes
1098/// at every BE step; Stokes is linear and instantaneous (Re ≪ 1 throughout).
1099/// The K₀ convolution is also re-evaluated every step to keep Q^lip in sync
1100/// with Q^rot.
1101///
1102/// # Arguments
1103///
1104/// - `q_init`: Initial rotor Q-tensor field.
1105/// - `phi_init`: Initial lipid volume-fraction field φ_l⁰.
1106/// - `params`: Physical and numerical parameters (including BECH fields).
1107/// - `n_steps`: Total number of time steps.
1108/// - `snap_every`: Steps between snapshot statistics.
1109///
1110/// # Returns
1111///
1112/// `(q_final, phi_final, stats)` where `stats` records defect and
1113/// concentration statistics at each snapshot.
1114pub fn run_bech(
1115 q_init: &QField2D,
1116 phi_init: &ScalarField2D,
1117 params: &ActiveNematicParams,
1118 n_steps: usize,
1119 snap_every: usize,
1120) -> (QField2D, ScalarField2D, Vec<BechStats>) {
1121 let mut q = q_init.clone();
1122 let mut phi = phi_init.clone();
1123 let mut stats: Vec<BechStats> = Vec::new();
1124
1125 let lx = params.nx as f64 * params.dx;
1126 let ly = params.ny as f64 * params.dx;
1127 let area = lx * ly;
1128
1129 // Langevin noise for the rotor Q-field (same convention as Component 1).
1130 let use_noise = params.noise_amp > 0.0;
1131 let noise_scale = params.noise_amp * params.dt.sqrt();
1132 let seed: u64 = (params.nx as u64).wrapping_mul(6364136223846793005)
1133 ^ (params.ny as u64).wrapping_mul(1442695040888963407)
1134 ^ n_steps as u64;
1135 let mut rng = SmallRng::seed_from_u64(seed);
1136
1137 for step in 0..=n_steps {
1138 // ── Snapshot ──────────────────────────────────────────────────────
1139 if step % snap_every == 0 {
1140 let defects = scan_defects(&q, std::f64::consts::PI / 2.0);
1141 let (n_plus, n_minus) = defect_count(&defects);
1142 let n_defects = defects.len();
1143 stats.push(BechStats {
1144 time: step as f64 * params.dt,
1145 mean_s: q.mean_order_param(),
1146 n_defects,
1147 n_plus,
1148 n_minus,
1149 defect_density: n_defects as f64 / area,
1150 mean_phi: phi.mean_value(),
1151 phi_variance: phi.variance(),
1152 mean_grad_phi_sq: phi.mean_gradient_sq(),
1153 });
1154 }
1155
1156 if step == n_steps { break; }
1157
1158 // ── 1. Stokes: v from active stress σ^a = ζ_eff Q^rot ─────────────
1159 let v = stokes_solve(&q, params);
1160
1161 // ── 2. Beris-Edwards Euler step (rotor field) ─────────────────────
1162 let dq = beris_edwards_rhs(&q, Some(&v), params);
1163 q = q.add(&dq.scale(params.dt));
1164
1165 // Langevin noise injection into Q^rot (Euler-Maruyama, Box-Muller).
1166 if use_noise {
1167 for [q1, q2] in q.q.iter_mut() {
1168 let u1: f64 = rng.random::<f64>().max(f64::MIN_POSITIVE);
1169 let u2: f64 = rng.random::<f64>();
1170 let mag = noise_scale * (-2.0 * u1.ln()).sqrt();
1171 let angle = std::f64::consts::TAU * u2;
1172 *q1 += mag * angle.cos();
1173 *q2 += mag * angle.sin();
1174 }
1175 }
1176
1177 // ── 3. Transfer map: Q^lip = K₀ * Q^rot ───────────────────────────
1178 let q_lip = k0_convolution(&q, params);
1179
1180 // ── 4. CH-ETD1 step (lipid concentration field) ───────────────────
1181 phi = ch_step_etd(&phi, &q_lip, &v, params);
1182 }
1183
1184 (q, phi, stats)
1185}
1186
1187// ─────────────────────────────────────────────────────────────────────────────
1188// Tests
1189// ─────────────────────────────────────────────────────────────────────────────
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194 use approx::assert_abs_diff_eq;
1195 use std::f64::consts::PI;
1196
1197 fn default_params() -> ActiveNematicParams {
1198 ActiveNematicParams::default_test()
1199 }
1200
1201 // ── Molecular field tests ────────────────────────────────────────────────
1202
1203 #[test]
1204 fn molecular_field_zero_at_zero_q() {
1205 // H(Q=0) = 0 (the Landau polynomial has no constant term).
1206 let q = QField2D::zeros(8, 8, 1.0);
1207 let h = molecular_field(&q, &default_params());
1208 for &[h1, h2] in &h.q {
1209 assert_abs_diff_eq!(h1, 0.0, epsilon = 1e-12);
1210 assert_abs_diff_eq!(h2, 0.0, epsilon = 1e-12);
1211 }
1212 }
1213
1214 #[test]
1215 fn molecular_field_linear_at_small_q() {
1216 // For small uniform Q, the cubic term is negligible and H ≈ -2 a_eff Q.
1217 let params = default_params();
1218 let a_eff = params.a_eff();
1219 let q0 = 0.001;
1220 let q = QField2D::uniform(8, 8, 1.0, [q0, 0.0]);
1221 let h = molecular_field(&q, ¶ms);
1222 // Only interior vertices; for a uniform field ∇²Q = 0.
1223 for &[h1, _] in &h.q {
1224 let expected = -2.0 * a_eff * q0;
1225 assert_abs_diff_eq!(h1, expected, epsilon = 1e-4 * expected.abs().max(1e-12));
1226 }
1227 }
1228
1229 // ── Integrators ─────────────────────────────────────────────────────────
1230
1231 #[test]
1232 fn euler_step_grows_unstable_mode() {
1233 // With a_eff < 0 (active turbulent), a small Q perturbation should grow.
1234 let params = default_params(); // a_eff = -0.5 - 1.0 = -1.5 < 0
1235 assert!(params.a_eff() < 0.0, "need active regime for this test");
1236
1237 let q = QField2D::uniform(8, 8, 1.0, [0.01, 0.0]);
1238 let euler = EulerIntegrator;
1239 let p = params.clone();
1240 let q_next = euler.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
1241 // The mode must grow (|q1| must increase from 0.01).
1242 let s_before = q.mean_order_param();
1243 let s_after = q_next.mean_order_param();
1244 assert!(s_after > s_before, "s_before={s_before}, s_after={s_after}");
1245 }
1246
1247 #[test]
1248 fn rk4_conserves_symmetry() {
1249 // A Q-field that starts sym-traceless must remain sym-traceless after RK4.
1250 let params = default_params();
1251 let q = QField2D::uniform(16, 16, 1.0, [0.1, 0.05]);
1252 let rk4 = RK4Integrator;
1253 let p = params.clone();
1254 let q_next = rk4.step(&q, params.dt, move |q_| beris_edwards_rhs(q_, None, &p));
1255 // Q-tensor stays in [[q1,q2],[q2,-q1]] form by construction; check norms are finite.
1256 assert!(q_next.max_norm().is_finite());
1257 assert!(q_next.max_norm() > 0.0);
1258 }
1259
1260 #[test]
1261 fn rk4_more_accurate_than_euler() {
1262 // Integrate a simple ODE y' = -y (decaying mode) for one step with both.
1263 // The exact solution after dt=0.1 is y(0.1) = y(0) * exp(-0.1).
1264 // RK4 should be closer to exact.
1265 //
1266 // Map to Q-field: use a uniform Q with Γ_r H ≈ -r*Q (effective rate r).
1267 // Use a stable (non-active) parameter set: a_eff > 0.
1268 let mut params = default_params();
1269 params.a_landau = 1.0; // large positive: stable ordered phase
1270 params.zeta_eff = 0.0; // no activity
1271 params.c_landau = 0.01; // small cubic term so regime is near linear
1272 params.gamma_r = 1.0;
1273 params.dt = 0.01;
1274 // a_eff = 1.0; linear decay rate = 2 * gamma_r * a_eff = 2.0
1275 let r = 2.0 * params.gamma_r * params.a_eff();
1276 let q0_val = 0.1_f64;
1277 let q0 = QField2D::uniform(4, 4, 1.0, [q0_val, 0.0]);
1278
1279 let p1 = params.clone();
1280 let euler = EulerIntegrator;
1281 let q_euler = euler.step(&q0, params.dt, move |q_| beris_edwards_rhs(q_, None, &p1));
1282
1283 let p2 = params.clone();
1284 let rk4 = RK4Integrator;
1285 let q_rk4 = rk4.step(&q0, params.dt, move |q_| beris_edwards_rhs(q_, None, &p2));
1286
1287 let exact = q0_val * (-r * params.dt).exp();
1288 let err_euler = (q_euler.q[0][0] - exact).abs();
1289 let err_rk4 = (q_rk4.q[0][0] - exact).abs();
1290 assert!(err_rk4 < err_euler, "RK4 err={err_rk4:.3e} must be < Euler err={err_euler:.3e}");
1291 }
1292
1293 // ── K₀ kernel ────────────────────────────────────────────────────────────
1294
1295 #[test]
1296 fn bessel_k0_large_x_decays() {
1297 // K₀(x) ~ sqrt(π/(2x)) exp(-x) for large x; check monotone decay.
1298 let x_vals = [1.0, 2.0, 5.0, 10.0];
1299 let k_vals: Vec<f64> = x_vals.iter().map(|&x| bessel_k0(x)).collect();
1300 for i in 1..k_vals.len() {
1301 assert!(k_vals[i] < k_vals[i - 1], "K₀ not monotone: {k_vals:?}");
1302 }
1303 }
1304
1305 #[test]
1306 fn bessel_k0_positive() {
1307 for x in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0] {
1308 assert!(bessel_k0(x) > 0.0);
1309 }
1310 }
1311
1312 #[test]
1313 fn k0_convolution_of_uniform_field() {
1314 // For a uniform Q^rot, K₀ convolution returns a uniform Q^lip (each
1315 // vertex gets the same weight sum). Verify the output is uniform and
1316 // that both components scale by the same ratio relative to the input.
1317 let params = {
1318 let mut p = ActiveNematicParams::default_test();
1319 p.nx = 32;
1320 p.ny = 32;
1321 p.xi_l = 3.0;
1322 p.dx = 1.0;
1323 p
1324 };
1325 let q0 = [0.15, -0.08];
1326 let q = QField2D::uniform(params.nx, params.ny, params.dx, q0);
1327 let q_lip = k0_convolution(&q, ¶ms);
1328
1329 // Output should be uniform: all vertices have the same value.
1330 let [v0_ref, v1_ref] = q_lip.q[0];
1331 for &[v0, v1] in &q_lip.q {
1332 assert_abs_diff_eq!(v0, v0_ref, epsilon = 1e-12);
1333 assert_abs_diff_eq!(v1, v1_ref, epsilon = 1e-12);
1334 }
1335
1336 // Both components scale by the same factor (ratio preserved).
1337 let ratio0 = v0_ref / q0[0];
1338 let ratio1 = v1_ref / q0[1];
1339 assert_abs_diff_eq!(ratio0, ratio1, epsilon = 1e-10);
1340
1341 // The scale factor should be positive and finite (kernel is positive definite).
1342 assert!(ratio0.is_finite() && ratio0 > 0.0);
1343
1344 // The discrete integral approximates ∫ K₀(r/ξ) d²r = 2π ξ²,
1345 // so ratio ≈ 2π ξ²/(2π ξ²) = 1 up to finite-cutoff and discretization
1346 // corrections. With cutoff clamped to nx/2=16 < 6ξ=18, the captured
1347 // integral is ≈ 95% of the full integral; allow 10% tolerance.
1348 assert_abs_diff_eq!(ratio0, 1.0, epsilon = 0.10);
1349 }
1350
1351 // ── Defect detection ─────────────────────────────────────────────────────
1352
1353 #[test]
1354 fn scan_defects_zero_field() {
1355 // The zero Q-tensor field has Q≡0 everywhere. q_to_frame at Q=0
1356 // returns an arbitrary frame (eigenvectors of the zero matrix), but
1357 // the holonomy of any constant frame field is identity: no defects.
1358 let q = QField2D::zeros(8, 8, 1.0);
1359 let defects = scan_defects(&q, PI / 2.0);
1360 // Either no defects, or if the zero eigenvector convention varies by vertex,
1361 // some plaquettes may be flagged. Accept either outcome (zero-field has no
1362 // physical defect).
1363 let _ = defects; // Just check it doesn't panic.
1364 }
1365
1366 #[test]
1367 fn scan_defects_uniform_field_no_defects() {
1368 // A uniform Q-tensor field with S > 0: all frames are identical → no defects.
1369 let q = QField2D::uniform(16, 16, 1.0, [0.3, 0.0]);
1370 let defects = scan_defects(&q, PI / 2.0);
1371 assert!(
1372 defects.is_empty(),
1373 "uniform Q-field should have no defects, got {}",
1374 defects.len()
1375 );
1376 }
1377
1378 // ── End-to-end: Component 1 ───────────────────────────────────────────────
1379
1380 #[test]
1381 fn run_dry_active_nematic_grows_order() {
1382 // Start from a small random perturbation in the active turbulent regime.
1383 // After a few steps, the order parameter should grow (instability).
1384 let params = ActiveNematicParams::default_test();
1385 assert!(params.a_eff() < 0.0);
1386
1387 let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.001, 7);
1388 let (q_final, stats) = run_dry_active_nematic(&q_init, ¶ms, 20, 5);
1389
1390 assert!(q_final.max_norm().is_finite());
1391 // The order parameter should have grown from ~0.001.
1392 assert!(
1393 stats.last().unwrap().mean_s > stats.first().unwrap().mean_s,
1394 "order parameter did not grow: {:?}",
1395 stats.iter().map(|s| s.mean_s).collect::<Vec<_>>()
1396 );
1397 }
1398
1399 // ── CH chemical potential ────────────────────────────────────────────────
1400
1401 #[test]
1402 fn ch_chemical_potential_zero_at_zero_fields() {
1403 // μ(φ=0, Q=0) = a_ch * 0 + b_ch * 0³ - κ_ch ∇²(0) - χ_ms * 0 = 0.
1404 let params = ActiveNematicParams::default_test();
1405 let phi = ScalarField2D::zeros(8, 8, 1.0);
1406 let q_lip = QField2D::zeros(8, 8, 1.0);
1407 let mu = ch_chemical_potential(&phi, &q_lip, ¶ms);
1408 for &v in &mu.phi {
1409 assert_abs_diff_eq!(v, 0.0, epsilon = 1e-12);
1410 }
1411 }
1412
1413 #[test]
1414 fn ch_chemical_potential_linear_at_small_phi_no_q() {
1415 // For small uniform φ, no Q: μ ≈ a_ch φ (gradient term vanishes for uniform φ).
1416 let params = ActiveNematicParams::default_test();
1417 let phi0 = 0.01_f64;
1418 let phi = ScalarField2D::uniform(8, 8, 1.0, phi0);
1419 let q_lip = QField2D::zeros(8, 8, 1.0);
1420 let mu = ch_chemical_potential(&phi, &q_lip, ¶ms);
1421 let expected = params.a_ch * phi0 + params.b_ch * phi0.powi(3);
1422 for &v in &mu.phi {
1423 assert_abs_diff_eq!(v, expected, epsilon = 1e-8 * expected.abs().max(1e-12));
1424 }
1425 }
1426
1427 #[test]
1428 fn ch_maier_saupe_lowers_chemical_potential() {
1429 // With Q > 0 and χ_ms > 0, the Maier-Saupe term -χ_ms Tr(Q²) < 0
1430 // lowers μ relative to the Q=0 case (lipid is drawn into ordered regions).
1431 let params = ActiveNematicParams::default_test();
1432 assert!(params.chi_ms > 0.0);
1433 let phi = ScalarField2D::uniform(8, 8, 1.0, 0.5);
1434 let q_zero = QField2D::zeros(8, 8, 1.0);
1435 let q_ord = QField2D::uniform(8, 8, 1.0, [0.3, 0.0]);
1436 let mu_no_q = ch_chemical_potential(&phi, &q_zero, ¶ms);
1437 let mu_with_q = ch_chemical_potential(&phi, &q_ord, ¶ms);
1438 // Maier-Saupe term = -χ_ms * 2(0.3² + 0) = -χ_ms * 0.18 < 0.
1439 assert!(
1440 mu_with_q.phi[0] < mu_no_q.phi[0],
1441 "Maier-Saupe term should lower μ: {} vs {}",
1442 mu_with_q.phi[0],
1443 mu_no_q.phi[0]
1444 );
1445 }
1446
1447 // ── ETD CH step ──────────────────────────────────────────────────────────
1448
1449 #[test]
1450 fn ch_step_etd_conserves_mean() {
1451 // The CH equation is mass-conserving: ⟨φ⟩ must not drift.
1452 let params = ActiveNematicParams::default_test();
1453 let phi0 = 0.4_f64;
1454 let phi = ScalarField2D::uniform(16, 16, 1.0, phi0);
1455 let q_lip = QField2D::uniform(16, 16, 1.0, [0.1, 0.05]);
1456 let v = VelocityField2D::zeros(16, 16, 1.0);
1457 let phi_new = ch_step_etd(&phi, &q_lip, &v, ¶ms);
1458 // Mean must be conserved to within floating-point precision.
1459 assert_abs_diff_eq!(phi_new.mean_value(), phi0, epsilon = 1e-10);
1460 }
1461
1462 #[test]
1463 fn ch_step_etd_uniform_phi_no_change() {
1464 // For a uniform φ field (∇φ = 0, ∇⁴φ = 0) with uniform Q and zero v,
1465 // the only nonlinear contribution is M_l ∇²(b_ch φ³ - χ_ms Tr(Q²)) = 0
1466 // (since everything is uniform). Only the k≠0 Fourier modes see the
1467 // stiff operator; for a truly uniform initial condition φ̂_k≠0 = 0,
1468 // so φ remains uniform after one ETD step.
1469 let params = ActiveNematicParams::default_test();
1470 let phi0 = 0.5_f64;
1471 let phi = ScalarField2D::uniform(16, 16, 1.0, phi0);
1472 let q_lip = QField2D::uniform(16, 16, 1.0, [0.2, 0.0]);
1473 let v = VelocityField2D::zeros(16, 16, 1.0);
1474 let phi_new = ch_step_etd(&phi, &q_lip, &v, ¶ms);
1475 for &val in &phi_new.phi {
1476 assert_abs_diff_eq!(val, phi0, epsilon = 1e-10);
1477 }
1478 }
1479
1480 #[test]
1481 fn ch_step_etd_output_finite() {
1482 // Run a few BECH steps from random initial conditions; all fields stay finite.
1483 let params = ActiveNematicParams::default_test();
1484 let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.05, 99);
1485 // Initialise φ near the equilibrium value sqrt(a_ch/b_ch) = 1.0 with small noise.
1486 let phi_vals: Vec<f64> = (0..16*16).map(|k| {
1487 let frac = k as f64 / (16.0 * 16.0);
1488 0.5 + 0.05 * (frac * 7.3).sin()
1489 }).collect();
1490 let phi_init = ScalarField2D { phi: phi_vals, nx: 16, ny: 16, dx: 1.0 };
1491 let (q_fin, phi_fin, stats) = run_bech(&q_init, &phi_init, ¶ms, 10, 5);
1492 assert!(q_fin.max_norm().is_finite());
1493 for &v in &phi_fin.phi {
1494 assert!(v.is_finite(), "phi_fin contains non-finite value");
1495 }
1496 assert_eq!(stats.len(), 3); // steps 0, 5, 10
1497 }
1498
1499 // ── BECH full run ────────────────────────────────────────────────────────
1500
1501 #[test]
1502 fn run_bech_phi_variance_grows() {
1503 // Starting from uniform φ with a non-uniform Q (from active turbulence),
1504 // the Maier-Saupe coupling should drive phase separation: Var[φ] increases.
1505 let params = ActiveNematicParams::default_test();
1506 // Use a larger χ_ms to make the effect visible in a short run.
1507 let mut p = params.clone();
1508 p.chi_ms = 2.0;
1509 p.nx = 16; p.ny = 16;
1510
1511 let q_init = QField2D::random_perturbation(16, 16, 1.0, 0.3, 17);
1512 let phi_init = ScalarField2D::uniform(16, 16, 1.0, 0.4);
1513 let (_, _, stats) = run_bech(&q_init, &phi_init, &p, 50, 10);
1514
1515 let var_init = stats.first().unwrap().phi_variance;
1516 let var_fin = stats.last().unwrap().phi_variance;
1517 // Variance should grow (or at minimum not shrink) as Maier-Saupe
1518 // drives accumulation near ordered regions.
1519 assert!(
1520 var_fin >= var_init - 1e-12,
1521 "Expected Var[φ] to grow under Maier-Saupe coupling: {var_init:.3e} → {var_fin:.3e}"
1522 );
1523 }
1524}