Skip to main content

volterra_solver/
engine.rs

1//! Unified active nematohydrodynamics engine on Riemannian 2-manifolds.
2//!
3//! Solves the nondimensionalised Beris-Edwards + Stokes system using
4//! operator splitting:
5//!
6//! ```text
7//! 1. Stokes:       (1/Er) Delta(Delta+K) psi = Pe curl(div(V(z)))
8//! 2. Advection:    z_adv = SemiLagrangian(z, u, dt)
9//! 3. Diffusion:    z_new = z_adv + dt * (Delta_B z_adv + La z_adv - Lc |z_adv|^2 z_adv)
10//! ```
11//!
12//! All parameters are dimensionless (Pe, Er, La, Lc). The timestep is
13//! auto-computed from the diffusive CFL bound.
14
15use cartan_core::Manifold;
16use cartan_dec::{Mesh, Operators};
17use volterra_core::NematicParams;
18use volterra_dec::connection_laplacian::{ConnectionLaplacian, molecular_field_conn};
19use volterra_dec::curved_stokes::{CurvedStokesSolver, nematic_vorticity_source};
20use volterra_dec::semi_lagrangian::SemiLagrangian;
21use volterra_dec::stokes_dec::VelocityFieldDec;
22use volterra_dec::QFieldDec;
23
24/// Per-snapshot statistics from the nematic engine.
25#[derive(Debug, Clone)]
26pub struct EngineStats {
27    /// Simulation time (nondimensionalised).
28    pub time: f64,
29    /// Mean scalar order parameter.
30    pub mean_s: f64,
31    /// RMS velocity magnitude.
32    pub velocity_rms: f64,
33    /// Number of vertices.
34    pub n_vertices: usize,
35}
36
37/// Active nematohydrodynamics engine on a Riemannian 2-manifold.
38pub struct NematicEngine {
39    /// Dimensionless parameters.
40    params: NematicParams,
41    /// Connection Laplacian (Bochner on L^2, spin-2 parallel transport).
42    conn_lap: ConnectionLaplacian,
43    /// Curved-surface Stokes solver (modified biharmonic).
44    stokes: CurvedStokesSolver,
45    /// Semi-Lagrangian advection operator.
46    semi_lag: SemiLagrangian,
47    /// Vertex coordinates in R^3.
48    coords: Vec<[f64; 3]>,
49    /// Dual cell areas (from star_0).
50    dual_areas: Vec<f64>,
51    /// Mesh boundary connectivity (for vorticity source computation).
52    simplices: Vec<[usize; 3]>,
53    boundaries: Vec<[usize; 2]>,
54    vertex_boundaries: Vec<Vec<usize>>,
55    /// Timestep (auto-computed from diffusive CFL).
56    dt: f64,
57    /// Number of vertices.
58    n_vertices: usize,
59}
60
61impl NematicEngine {
62    /// Construct the engine from a mesh, manifold, and dimensionless parameters.
63    ///
64    /// Precomputes all DEC operators, connection Laplacian, Stokes solver,
65    /// and semi-Lagrangian data structures. The timestep is auto-computed
66    /// from the diffusive CFL bound based on the mean edge length.
67    pub fn new<M: Manifold>(
68        mesh: Mesh<M, 3, 2>,
69        manifold: M,
70        params: NematicParams,
71        gaussian_curvature: Vec<f64>,
72    ) -> Result<Self, String> {
73        params.validate()?;
74
75        let ops = Operators::from_mesh_generic(&mesh, &manifold)
76            .map_err(|e| format!("DEC operator assembly: {e:?}"))?;
77
78        let nv = mesh.n_vertices();
79
80        // Extract vertex coordinates.
81        let coords: Vec<[f64; 3]> = volterra_dec::stokes_dec::extract_coords(&mesh);
82
83        // Hodge stars for the connection Laplacian.
84        let star0: Vec<f64> = (0..ops.hodge.star0().len())
85            .map(|i| ops.hodge.star0()[i]).collect();
86        let star1: Vec<f64> = (0..ops.hodge.star1().len())
87            .map(|i| ops.hodge.star1()[i]).collect();
88
89        // Connection Laplacian.
90        let conn_lap = ConnectionLaplacian::new(&mesh, &coords, &star0, &star1);
91
92        // Curved Stokes solver.
93        let stokes = CurvedStokesSolver::new(&ops, &mesh, &gaussian_curvature)?;
94
95        // Semi-Lagrangian advection.
96        let semi_lag = SemiLagrangian::new(coords.clone(), mesh.simplices.clone());
97
98        // Auto-compute timestep from mean edge length.
99        let ne = mesh.n_boundaries();
100        let mean_edge_len = if ne > 0 {
101            let total: f64 = (0..ne).map(|e| {
102                let [v0, v1] = mesh.boundaries[e];
103                let d = [
104                    coords[v1][0] - coords[v0][0],
105                    coords[v1][1] - coords[v0][1],
106                    coords[v1][2] - coords[v0][2],
107                ];
108                (d[0]*d[0] + d[1]*d[1] + d[2]*d[2]).sqrt()
109            }).sum();
110            total / ne as f64
111        } else {
112            0.01
113        };
114        let dt = params.dt_diffusive(mean_edge_len);
115
116        Ok(Self {
117            params,
118            conn_lap,
119            stokes,
120            semi_lag,
121            coords,
122            dual_areas: star0,
123            simplices: mesh.simplices.clone(),
124            boundaries: mesh.boundaries.clone(),
125            vertex_boundaries: mesh.vertex_boundaries.clone(),
126            dt,
127            n_vertices: nv,
128        })
129    }
130
131    /// The auto-computed timestep.
132    pub fn dt(&self) -> f64 { self.dt }
133
134    /// Override the timestep (use with caution).
135    pub fn set_dt(&mut self, dt: f64) { self.dt = dt; }
136
137    /// Number of vertices in the mesh.
138    pub fn n_vertices(&self) -> usize { self.n_vertices }
139
140    /// The dimensionless parameters.
141    pub fn params(&self) -> &NematicParams { &self.params }
142
143    /// Advance the nematic field by one timestep using operator splitting.
144    ///
145    /// 1. Stokes solve for velocity from active stress.
146    /// 2. Semi-Lagrangian advection (backward trace + barycentric interp).
147    /// 3. Diffusion + bulk LdG (explicit, connection Laplacian).
148    pub fn step(&self, q: &mut QFieldDec) -> VelocityFieldDec {
149        let nv = self.n_vertices;
150        let dt = self.dt;
151
152        // 1. Stokes: compute vorticity source and solve for stream function.
153        let source = nematic_vorticity_source(
154            q, self.params.pe,
155            // We need to pass the mesh data. Use stored simplices + coords.
156            &self.simplices, &self.coords, &self.dual_areas,
157        );
158        let (_psi, _vel) = self.stokes.solve(&source, self.params.er);
159
160        // For now, use the vorticity-based velocity from the old solver
161        // until CurvedStokesSolver's velocity extraction is wired up.
162        // Compute velocity from the stream function gradient.
163        let vel = self.compute_velocity_from_source(q);
164
165        // 2. Semi-Lagrangian advection.
166        let q_adv = self.semi_lag.advect(q, &vel, dt);
167
168        // 3. Diffusion + bulk LdG.
169        // In the complex representation, |z|^2 = q1^2 + q2^2 and
170        // Tr(Q^2) = 2*(q1^2 + q2^2) = 2*|z|^2.
171        // molecular_field_conn computes: bulk = -a_eff - 2*c*Tr(Q^2) = -a_eff - 4*c*|z|^2.
172        // The nondimensionalised equation wants: La - Lc*|z|^2.
173        // So we need: -a_eff = La and 4*c = Lc, i.e., c = Lc/4.
174        let h = molecular_field_conn(
175            &q_adv,
176            1.0,                    // K_frank = 1 (nondimensionalised elastic)
177            -self.params.la,        // a_eff = -La
178            self.params.lc / 4.0,   // c_landau = Lc/4 (complex rep factor correction)
179            &self.conn_lap,
180        );
181
182        // Euler step for diffusion + bulk.
183        for i in 0..nv {
184            q.q1[i] = q_adv.q1[i] + dt * h.q1[i];
185            q.q2[i] = q_adv.q2[i] + dt * h.q2[i];
186        }
187
188        vel
189    }
190
191    /// Temporary: compute velocity using the old Stokes solver approach.
192    fn compute_velocity_from_source(&self, q: &QFieldDec) -> VelocityFieldDec {
193        // Use the per-face gradient vorticity source.
194        let nv = self.n_vertices;
195        let pe = self.params.pe;
196        let er = self.params.er;
197
198        let mut omega = vec![0.0_f64; nv];
199        let mut areas = vec![0.0_f64; nv];
200
201        for &[i0, i1, i2] in &self.simplices {
202            let p0 = self.coords[i0];
203            let p1 = self.coords[i1];
204            let p2 = self.coords[i2];
205            let e01 = sub3(p1, p0);
206            let e02 = sub3(p2, p0);
207            let e12 = sub3(p2, p1);
208            let e20 = sub3(p0, p2);
209
210            let fn_vec = cross3(e01, e02);
211            let area2 = norm3(fn_vec);
212            if area2 < 1e-30 { continue; }
213            let fn_hat = scale3(fn_vec, 1.0 / area2);
214            let inv_2a = 1.0 / area2;
215
216            let rot_e12 = cross3(fn_hat, e12);
217            let rot_e20 = cross3(fn_hat, e20);
218            let rot_e01 = cross3(fn_hat, e01);
219
220            let gq1 = scale3(add3(add3(
221                scale3(rot_e12, q.q1[i0]),
222                scale3(rot_e20, q.q1[i1])),
223                scale3(rot_e01, q.q1[i2])), inv_2a);
224            let gq2 = scale3(add3(add3(
225                scale3(rot_e12, q.q2[i0]),
226                scale3(rot_e20, q.q2[i1])),
227                scale3(rot_e01, q.q2[i2])), inv_2a);
228
229            let fx = -pe * (gq1[0] + gq2[1]);
230            let fy = -pe * (gq2[0] - gq1[1]);
231
232            let circ_01 = fx * e01[0] + fy * e01[1];
233            let circ_12 = fx * e12[0] + fy * e12[1];
234            let circ_20 = fx * e20[0] + fy * e20[1];
235
236            let face_area = 0.5 * area2;
237            let third = face_area / 3.0;
238            areas[i0] += third;
239            areas[i1] += third;
240            areas[i2] += third;
241
242            omega[i0] += 0.5 * (circ_01 - circ_20);
243            omega[i1] += 0.5 * (circ_12 - circ_01);
244            omega[i2] += 0.5 * (circ_20 - circ_12);
245        }
246
247        for i in 0..nv {
248            if areas[i] > 1e-30 {
249                omega[i] /= er * areas[i];
250            }
251        }
252
253        // Solve for psi via the standard Poisson (temporarily using the
254        // omega directly as the velocity proxy until the full pipeline is wired).
255        // This is a simplified velocity that captures the flow direction.
256        let _ne = self.boundaries.len();
257        let mut vel = vec![[0.0_f64; 3]; nv];
258
259        // Use omega as a rough velocity magnitude indicator.
260        // The actual velocity extraction uses the stream function, but for
261        // the semi-Lagrangian to work we need SOME velocity.
262        // Use the per-face force directly as a tangent velocity estimate.
263        for &[i0, i1, i2] in &self.simplices {
264            let p0 = self.coords[i0];
265            let p1 = self.coords[i1];
266            let p2 = self.coords[i2];
267            let e01 = sub3(p1, p0);
268            let e02 = sub3(p2, p0);
269
270            let fn_vec = cross3(e01, e02);
271            let area2 = norm3(fn_vec);
272            if area2 < 1e-30 { continue; }
273            let fn_hat = scale3(fn_vec, 1.0 / area2);
274            let inv_2a = 1.0 / area2;
275
276            let e12 = sub3(p2, p1);
277            let e20 = sub3(p0, p2);
278            let rot_e12 = cross3(fn_hat, e12);
279            let rot_e20 = cross3(fn_hat, e20);
280            let rot_e01 = cross3(fn_hat, e01);
281
282            let gq1 = scale3(add3(add3(
283                scale3(rot_e12, q.q1[i0]),
284                scale3(rot_e20, q.q1[i1])),
285                scale3(rot_e01, q.q1[i2])), inv_2a);
286            let gq2 = scale3(add3(add3(
287                scale3(rot_e12, q.q2[i0]),
288                scale3(rot_e20, q.q2[i1])),
289                scale3(rot_e01, q.q2[i2])), inv_2a);
290
291            // Active force (tangent to surface).
292            let fx = -pe / er * (gq1[0] + gq2[1]);
293            let fy = -pe / er * (gq2[0] - gq1[1]);
294            let fz = 0.0;
295
296            // Project force onto tangent plane and distribute to vertices.
297            let f_tang = [fx - fn_hat[0] * (fx * fn_hat[0] + fy * fn_hat[1]),
298                          fy - fn_hat[1] * (fx * fn_hat[0] + fy * fn_hat[1]),
299                          fz - fn_hat[2] * (fx * fn_hat[0] + fy * fn_hat[1])];
300
301            for &vi in &[i0, i1, i2] {
302                vel[vi] = add3(vel[vi], scale3(f_tang, 1.0 / 3.0));
303            }
304        }
305
306        // Normalise by vertex valence.
307        for (v, boundaries) in vel.iter_mut().zip(&self.vertex_boundaries) {
308            let valence = boundaries.len().max(1) as f64;
309            *v = scale3(*v, 1.0 / valence);
310        }
311
312        VelocityFieldDec { v: vel, n_vertices: nv }
313    }
314
315    /// Run the engine for n_steps, calling the callback at each snapshot.
316    pub fn run(
317        &self,
318        q: &mut QFieldDec,
319        n_steps: usize,
320        snap_every: usize,
321        mut callback: impl FnMut(usize, &QFieldDec, &VelocityFieldDec, &EngineStats),
322    ) {
323        for step in 0..=n_steps {
324            if step % snap_every == 0 {
325                let vel = if step > 0 {
326                    self.compute_velocity_from_source(q)
327                } else {
328                    VelocityFieldDec::zeros(self.n_vertices)
329                };
330                let v_rms = (vel.v.iter()
331                    .map(|[x, y, z]| x*x + y*y + z*z)
332                    .sum::<f64>() / self.n_vertices as f64).sqrt();
333                let stats = EngineStats {
334                    time: step as f64 * self.dt,
335                    mean_s: q.mean_order_param(),
336                    velocity_rms: v_rms,
337                    n_vertices: self.n_vertices,
338                };
339                callback(step, q, &vel, &stats);
340            }
341            if step < n_steps {
342                self.step(q);
343            }
344        }
345    }
346}
347
348fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[0]-b[0], a[1]-b[1], a[2]-b[2]] }
349fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { [a[0]+b[0], a[1]+b[1], a[2]+b[2]] }
350fn scale3(a: [f64; 3], s: f64) -> [f64; 3] { [a[0]*s, a[1]*s, a[2]*s] }
351fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { a[0]*b[0] + a[1]*b[1] + a[2]*b[2] }
352fn norm3(a: [f64; 3]) -> f64 { dot3(a, a).sqrt() }
353fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
354    [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
355}