Skip to main content

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, &params);
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, &params);
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, &params, 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, &params);
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, &params);
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, &params);
1437        let mu_with_q = ch_chemical_potential(&phi, &q_ord, &params);
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, &params);
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, &params);
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, &params, 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}