Skip to main content

volterra_solver/
active_nematic_engine.rs

1//! Active nematic engine on Riemannian 2-manifolds.
2//!
3//! Operator splitting per timestep:
4//! 1. STOKES:    u, p = stokes.solve(div_nabla(zeta * Q))
5//! 2. ADVECTION: z_adv = semi_lagrangian.advect(z, u, dt, lambda)
6//! 3. DIFFUSION: z_new = implicit_euler(z_adv, (1/Pe) * Delta_L, dt)
7//! 4. NORMALISE: z_new = z_new / |z_new|
8
9use num_complex::Complex;
10use nalgebra::SVector;
11use sprs::{CsMat, TriMat};
12
13use cartan_core::Manifold;
14use cartan_dec::line_bundle::{ConnectionAngles, BochnerLaplacian};
15use cartan_dec::mesh::Mesh;
16use cartan_dec::hodge::HodgeStar;
17
18use crate::nematic_field_2d::NematicField2D;
19use crate::stokes_trait::StokesSolver;
20use volterra_dec::semi_lagrangian::SemiLagrangian;
21use volterra_dec::stokes_dec::VelocityFieldDec;
22
23
24/// Nondimensionalised parameters for the active nematic engine.
25#[derive(Debug, Clone)]
26pub struct EngineParams {
27    /// Active Peclet number: Pe = |alpha| * eta_r^2 / mu.
28    pub pe: f64,
29    /// Tumbling parameter: 0 = corotational, 1 = flow-aligning.
30    pub lambda: f64,
31    /// Defect core size / domain size (for Ginzburg-Landau if not normalising).
32    pub epsilon: f64,
33    /// Timestep (free of diffusive CFL with implicit diffusion).
34    pub dt: f64,
35    /// Activity sign: +1 for contractile, -1 for extensile.
36    pub activity_sign: f64,
37}
38
39impl Default for EngineParams {
40    fn default() -> Self {
41        Self {
42            pe: 1.0,
43            lambda: 1.0,
44            epsilon: 0.01,
45            dt: 0.01,
46            activity_sign: -1.0,
47        }
48    }
49}
50
51/// Per-step diagnostics from the engine.
52#[derive(Debug, Clone)]
53pub struct StepDiagnostics {
54    /// Simulation time.
55    pub time: f64,
56    /// Mean scalar order parameter S.
57    pub mean_order: f64,
58    /// Stokes divergence residual.
59    pub stokes_residual: f64,
60    /// Step index.
61    pub step: usize,
62}
63
64/// Active nematic engine on a 2-manifold.
65///
66/// Implements the operator-split algorithm for active nematics on 2-manifolds.
67#[allow(dead_code)]
68pub struct ActiveNematicEngine {
69    /// Nondimensionalised parameters.
70    params: EngineParams,
71    /// Bochner Laplacian on L_2 (nematic bundle). Used for defect detection.
72    bochner: BochnerLaplacian<2>,
73    /// Semi-Lagrangian advector.
74    semi_lag: SemiLagrangian,
75    /// Stokes solver (trait object, either stream function or Killing).
76    stokes: Box<dyn StokesSolver>,
77    /// Precomputed implicit diffusion matrix: (I - dt/Pe * Delta_L)^{-1}.
78    /// Since we can't invert a complex sparse matrix easily, we store the
79    /// system matrix and solve per step via CG on real/imaginary parts.
80    diffusion_matrix: CsMat<f64>,
81    /// Connection angles for defect detection.
82    connection: ConnectionAngles,
83    /// Dual areas (star_0).
84    dual_areas: Vec<f64>,
85    /// Vertex coordinates for force computation.
86    coords: Vec<[f64; 3]>,
87    /// Mesh simplices for force computation.
88    simplices: Vec<[usize; 3]>,
89    /// Number of vertices.
90    n_vertices: usize,
91    /// Current step counter.
92    step: usize,
93    /// Current simulation time.
94    time: f64,
95}
96
97impl ActiveNematicEngine {
98    /// Create a new engine from mesh data and precomputed operators.
99    ///
100    /// `stokes` is a boxed trait object: either `KillingOperatorSolver` or
101    /// `StreamFunctionStokes`.
102    pub fn new<M: Manifold<Point = SVector<f64, 3>>>(
103        mesh: &Mesh<M, 3, 2>,
104        manifold: &M,
105        hodge: &HodgeStar,
106        params: EngineParams,
107        stokes: Box<dyn StokesSolver>,
108    ) -> Self {
109        let nv = mesh.n_vertices();
110        let connection = ConnectionAngles::from_mesh(mesh, manifold);
111        let bochner = BochnerLaplacian::<2>::from_mesh_data(mesh, hodge, &connection);
112
113        let dual_areas: Vec<f64> = (0..nv).map(|i| hodge.star0()[i]).collect();
114
115        let coords: Vec<[f64; 3]> = mesh.vertices.iter()
116            .map(|v| [v[0], v[1], v[2]])
117            .collect();
118
119        let simplices: Vec<[usize; 3]> = mesh.simplices.clone();
120
121        // Build the semi-Lagrangian advector.
122        let triangles: Vec<[usize; 3]> = simplices.clone();
123        let semi_lag = SemiLagrangian::new(coords.clone(), triangles);
124
125        // Build the implicit diffusion system matrix.
126        // (I - dt/Pe * Delta_L) where Delta_L is the Bochner Laplacian.
127        // Since Delta_L is complex Hermitian, and we separate real/imaginary:
128        // The real part of the Laplacian matrix operates on Re(z) and Im(z)
129        // independently (since the matrix is Hermitian, the real part is
130        // symmetric and the imaginary part is antisymmetric).
131        // For simplicity, store the real part of (I - dt/Pe * Delta_L).
132        let dt_over_pe = params.dt / params.pe;
133        let lap_mat = bochner.matrix();
134        let n = nv;
135        let mut triplets = TriMat::new((n, n));
136        // Identity.
137        for i in 0..n {
138            triplets.add_triplet(i, i, 1.0);
139        }
140        // -dt/Pe * Re(Delta_L)
141        for (col, col_view) in lap_mat.outer_iterator().enumerate() {
142            for (row, val) in col_view.iter() {
143                triplets.add_triplet(row, col, -dt_over_pe * val.re);
144            }
145        }
146        let diffusion_matrix = triplets.to_csc();
147
148        Self {
149            params,
150            bochner,
151            semi_lag,
152            stokes,
153            diffusion_matrix,
154            connection,
155            dual_areas,
156            coords,
157            simplices,
158            n_vertices: nv,
159            step: 0,
160            time: 0.0,
161        }
162    }
163
164    /// Perform one operator-split timestep.
165    ///
166    /// Returns diagnostics for this step.
167    pub fn step(&mut self, field: &mut NematicField2D) -> StepDiagnostics {
168        let dt = self.params.dt;
169
170        // 1. STOKES: compute active force and solve for velocity.
171        let force = self.compute_active_force(field);
172        let flow = self.stokes.solve(&force);
173
174        // 2. ADVECTION: semi-Lagrangian backtrack.
175        // Convert velocity to per-vertex [f64; 3].
176        let velocity: Vec<[f64; 3]> = flow.velocity_3d;
177        let (q1_adv, q2_adv) = self.advect_components(field, &velocity, dt);
178
179        // 3. DIFFUSION: implicit solve on real and imaginary parts.
180        let q1_diff = self.implicit_diffusion_solve(&q1_adv);
181        let q2_diff = self.implicit_diffusion_solve(&q2_adv);
182
183        // Update field.
184        for v in 0..self.n_vertices {
185            field.values_mut()[v] = Complex::new(q1_diff[v], q2_diff[v]);
186        }
187
188        // 4. NORMALISE.
189        field.normalise();
190
191        self.step += 1;
192        self.time += dt;
193
194        StepDiagnostics {
195            time: self.time,
196            mean_order: field.mean_scalar_order(),
197            stokes_residual: flow.div_residual,
198            step: self.step,
199        }
200    }
201
202    /// Compute the active force from the nematic field.
203    ///
204    /// f = activity_sign * Pe * div(Q) where Q is the Q-tensor.
205    /// Approximated per vertex using FEM-style gradient of Q over incident triangles.
206    fn compute_active_force(&self, field: &NematicField2D) -> Vec<[f64; 3]> {
207        let nv = self.n_vertices;
208        let mut force = vec![[0.0; 3]; nv];
209
210        let vals = field.values();
211        let zeta = self.params.activity_sign * self.params.pe;
212
213        // Simple approximation: compute gradient of Q components per face,
214        // distribute to vertices.
215        for &[i0, i1, i2] in &self.simplices {
216            let v0 = self.coords[i0];
217            let v1 = self.coords[i1];
218            let v2 = self.coords[i2];
219
220            let e01 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
221            let e02 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
222            let cross = [
223                e01[1] * e02[2] - e01[2] * e02[1],
224                e01[2] * e02[0] - e01[0] * e02[2],
225                e01[0] * e02[1] - e01[1] * e02[0],
226            ];
227            let area2 = (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt();
228            if area2 < 1e-30 {
229                continue;
230            }
231            let n = [cross[0] / area2, cross[1] / area2, cross[2] / area2];
232            let area = area2 / 2.0;
233
234            // FEM gradients (rotated edge / 2A).
235            let grad_phi = [
236                cross_with_normal(n, [v2[0] - v1[0], v2[1] - v1[1], v2[2] - v1[2]], area),
237                cross_with_normal(n, [v0[0] - v2[0], v0[1] - v2[1], v0[2] - v2[2]], area),
238                cross_with_normal(n, [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]], area),
239            ];
240
241            let q1 = [vals[i0].re, vals[i1].re, vals[i2].re];
242            let q2 = [vals[i0].im, vals[i1].im, vals[i2].im];
243
244            // Gradient of q1 and q2 on this face.
245            let mut grad_q1 = [0.0; 3];
246            let mut grad_q2 = [0.0; 3];
247            for local in 0..3 {
248                for d in 0..3 {
249                    grad_q1[d] += grad_phi[local][d] * q1[local];
250                    grad_q2[d] += grad_phi[local][d] * q2[local];
251                }
252            }
253
254            // Active force from div(Q): simplified as gradient-based approximation.
255            // Distribute to vertices (area / 3 weighting).
256            let w = zeta * area / 3.0;
257            for &vi in &[i0, i1, i2] {
258                for d in 0..3 {
259                    force[vi][d] += w * (grad_q1[d] + grad_q2[d]);
260                }
261            }
262        }
263
264        force
265    }
266
267    /// Advect field components using semi-Lagrangian method.
268    fn advect_components(
269        &self,
270        field: &NematicField2D,
271        velocity: &[[f64; 3]],
272        dt: f64,
273    ) -> (Vec<f64>, Vec<f64>) {
274        let qfield = field.to_qfield_dec();
275        let vel = VelocityFieldDec {
276            v: velocity.to_vec(),
277            n_vertices: self.n_vertices,
278        };
279
280        let q_adv = self.semi_lag.advect_with_params(&qfield, &vel, dt, self.params.lambda);
281        (q_adv.q1, q_adv.q2)
282    }
283
284    /// Solve (I - dt/Pe * Re(Delta_L)) * x = b via conjugate gradient.
285    fn implicit_diffusion_solve(&self, rhs: &[f64]) -> Vec<f64> {
286        let n = rhs.len();
287        let mut x = rhs.to_vec();
288        let mut r: Vec<f64> = {
289            let ax = sparse_matvec(&self.diffusion_matrix, &x);
290            rhs.iter().zip(&ax).map(|(b, a)| b - a).collect()
291        };
292        let mut p = r.clone();
293        let mut rs_old: f64 = r.iter().map(|ri| ri * ri).sum();
294
295        if rs_old.sqrt() < 1e-14 {
296            return x;
297        }
298
299        for _ in 0..1000 {
300            let ap = sparse_matvec(&self.diffusion_matrix, &p);
301            let pap: f64 = p.iter().zip(&ap).map(|(pi, api)| pi * api).sum();
302            if pap.abs() < 1e-30 {
303                break;
304            }
305            let alpha = rs_old / pap;
306
307            for i in 0..n {
308                x[i] += alpha * p[i];
309                r[i] -= alpha * ap[i];
310            }
311
312            let rs_new: f64 = r.iter().map(|ri| ri * ri).sum();
313            if rs_new.sqrt() < 1e-14 {
314                break;
315            }
316
317            let beta = rs_new / rs_old;
318            for i in 0..n {
319                p[i] = r[i] + beta * p[i];
320            }
321            rs_old = rs_new;
322        }
323
324        x
325    }
326
327    /// Current simulation time.
328    pub fn time(&self) -> f64 {
329        self.time
330    }
331
332    /// Current step count.
333    pub fn step_count(&self) -> usize {
334        self.step
335    }
336}
337
338/// n x e / (2*area): FEM gradient helper.
339fn cross_with_normal(n: [f64; 3], e: [f64; 3], area: f64) -> [f64; 3] {
340    let inv_2a = 0.5 / area;
341    [
342        inv_2a * (n[1] * e[2] - n[2] * e[1]),
343        inv_2a * (n[2] * e[0] - n[0] * e[2]),
344        inv_2a * (n[0] * e[1] - n[1] * e[0]),
345    ]
346}
347
348/// Sparse matrix-vector multiply.
349fn sparse_matvec(mat: &CsMat<f64>, x: &[f64]) -> Vec<f64> {
350    let nrows = mat.rows();
351    let mut y = vec![0.0; nrows];
352    for (col, col_view) in mat.outer_iterator().enumerate() {
353        let xc = x[col];
354        if xc.abs() < 1e-30 {
355            continue;
356        }
357        for (row, &val) in col_view.iter() {
358            y[row] += val * xc;
359        }
360    }
361    y
362}