Skip to main content

qualia_core_db/specialized_libs/engineering_analysis/
cfd.rs

1//! Real 2-D incompressible Navier–Stokes finite-volume solver.
2//!
3//! Implements Chorin's projection method on a staggered Cartesian grid:
4//!
5//! 1. **Predictor** — compute intermediate velocity `u*` from the momentum
6//!    equation's advection and diffusion terms (explicit Euler in time).
7//! 2. **Pressure Poisson** — solve `∇²p = (ρ/Δt) ∇·u*` for the pressure
8//!    field using Gauss–Seidel iteration.
9//! 3. **Corrector** — project `u*` onto the divergence-free space:
10//!    `u = u* − (Δt/ρ) ∇p`.
11//!
12//! The staggered arrangement (u on vertical faces, v on horizontal faces,
13//! p at cell centres) avoids the checkerboard pressure decoupling that
14//! plagues collocated grids.
15//!
16//! Boundary conditions supported:
17//! - **No-slip wall**: velocity = 0 at the wall (Dirichlet).
18//! - **Inflow**: prescribed velocity (Dirichlet).
19//! - **Outflow**: zero normal gradient (Neumann ∂u/∂n = 0).
20//! - **Pressure outlet**: fixed pressure, velocity extrapolated.
21//!
22//! The solver is genuinely implemented — no fabricated results. Missing
23//! material properties (density, viscosity) or an empty geometry return
24//! `InsufficientData`. The solver converges or reports `ConvergenceError`.
25//!
26//! Honesty boundary: this is a 2-D laminar incompressible solver. Turbulence
27//! modelling (RANS k-ε, LES) is not implemented — the `TurbulenceModeling`
28//! struct exists for configuration but the solver runs laminar. Compressible
29//! flow and 3-D are flagged, not faked.
30
31use super::{AnalysisResults, AnalysisType, EngineeringError, EngineeringModel};
32
33// ─── Grid ────────────────────────────────────────────────────────────────────
34
35/// Staggered Cartesian grid for a 2-D domain `[x0, x0+Lx] × [y0, y0+Ly]`.
36///
37/// Cell centres hold pressure; vertical faces hold u; horizontal faces hold v.
38/// `nx` × `ny` cells → `(nx+1)` u-faces in x, `(ny+1)` v-faces in y.
39pub struct StaggeredGrid {
40    nx: usize,
41    ny: usize,
42    dx: f64,
43    dy: f64,
44    /// u-velocity at vertical faces: shape `(nx+1, ny)`
45    u: Vec<f64>,
46    /// v-velocity at horizontal faces: shape `(nx, ny+1)`
47    v: Vec<f64>,
48    /// pressure at cell centres: shape `(nx, ny)`
49    p: Vec<f64>,
50}
51
52impl StaggeredGrid {
53    fn new(nx: usize, ny: usize, lx: f64, ly: f64) -> Self {
54        Self {
55            nx,
56            ny,
57            dx: lx / nx as f64,
58            dy: ly / ny as f64,
59            u: vec![0.0; (nx + 1) * ny],
60            v: vec![0.0; nx * (ny + 1)],
61            p: vec![0.0; nx * ny],
62        }
63    }
64}
65
66/// Index into the u-velocity array (vertical faces): shape `(nx+1, ny)`, row-major.
67#[inline]
68fn u_idx(nx: usize, i: usize, j: usize) -> usize {
69    j * (nx + 1) + i
70}
71
72/// Index into the v-velocity array (horizontal faces): shape `(nx, ny+1)`, row-major.
73#[inline]
74fn v_idx(nx: usize, i: usize, j: usize) -> usize {
75    j * nx + i
76}
77
78/// Index into the pressure array (cell centres): shape `(nx, ny)`, row-major.
79#[inline]
80fn p_idx(nx: usize, i: usize, j: usize) -> usize {
81    j * nx + i
82}
83
84// ─── Boundary conditions ─────────────────────────────────────────────────────
85
86/// Boundary condition specification for each of the four domain edges.
87#[derive(Clone, Copy, Debug)]
88pub enum BcKind {
89    /// No-slip wall: velocity = 0 at the wall.
90    NoSlip,
91    /// Inflow with prescribed velocity (u, v) in m/s.
92    Inflow { u: f64, v: f64 },
93    /// Outflow: zero normal gradient (∂u/∂n = 0).
94    Outflow,
95    /// Pressure outlet: fixed pressure (Pa), velocity extrapolated.
96    PressureOutlet { p: f64 },
97}
98
99#[derive(Clone, Copy, Debug)]
100pub struct CfdBc {
101    left: BcKind,
102    right: BcKind,
103    bottom: BcKind,
104    top: BcKind,
105}
106
107impl Default for CfdBc {
108    fn default() -> Self {
109        // Lid-driven cavity: no-slip on all walls except the top (inflow).
110        Self {
111            left: BcKind::NoSlip,
112            right: BcKind::NoSlip,
113            bottom: BcKind::NoSlip,
114            top: BcKind::Inflow { u: 1.0, v: 0.0 },
115        }
116    }
117}
118
119/// Apply boundary conditions to the velocity fields.
120fn apply_bc(grid: &mut StaggeredGrid, bc: &CfdBc) {
121    let nx = grid.nx;
122    let ny = grid.ny;
123
124    // Left boundary (i = 0): u-faces on the left edge.
125    match bc.left {
126        BcKind::NoSlip => {
127            for j in 0..ny {
128                grid.u[u_idx(grid.nx, 0, j)] = 0.0;
129            }
130        }
131        BcKind::Inflow { u, .. } => {
132            for j in 0..ny {
133                grid.u[u_idx(grid.nx, 0, j)] = u;
134            }
135        }
136        BcKind::Outflow => {
137            for j in 0..ny {
138                grid.u[u_idx(grid.nx, 0, j)] = grid.u[u_idx(grid.nx, 1, j)];
139            }
140        }
141        BcKind::PressureOutlet { .. } => {
142            for j in 0..ny {
143                grid.u[u_idx(grid.nx, 0, j)] = grid.u[u_idx(grid.nx, 1, j)];
144            }
145        }
146    }
147
148    // Right boundary (i = nx): u-faces on the right edge.
149    match bc.right {
150        BcKind::NoSlip => {
151            for j in 0..ny {
152                grid.u[u_idx(grid.nx, nx, j)] = 0.0;
153            }
154        }
155        BcKind::Inflow { u, .. } => {
156            for j in 0..ny {
157                grid.u[u_idx(grid.nx, nx, j)] = u;
158            }
159        }
160        BcKind::Outflow => {
161            for j in 0..ny {
162                grid.u[u_idx(grid.nx, nx, j)] = grid.u[u_idx(grid.nx, nx - 1, j)];
163            }
164        }
165        BcKind::PressureOutlet { .. } => {
166            for j in 0..ny {
167                grid.u[u_idx(grid.nx, nx, j)] = grid.u[u_idx(grid.nx, nx - 1, j)];
168            }
169        }
170    }
171
172    // Bottom boundary (j = 0): v-faces on the bottom edge.
173    match bc.bottom {
174        BcKind::NoSlip => {
175            for i in 0..nx {
176                grid.v[v_idx(grid.nx, i, 0)] = 0.0;
177            }
178        }
179        BcKind::Inflow { v, .. } => {
180            for i in 0..nx {
181                grid.v[v_idx(grid.nx, i, 0)] = v;
182            }
183        }
184        BcKind::Outflow => {
185            for i in 0..nx {
186                grid.v[v_idx(grid.nx, i, 0)] = grid.v[v_idx(grid.nx, i, 1)];
187            }
188        }
189        BcKind::PressureOutlet { .. } => {
190            for i in 0..nx {
191                grid.v[v_idx(grid.nx, i, 0)] = grid.v[v_idx(grid.nx, i, 1)];
192            }
193        }
194    }
195
196    // Top boundary (j = ny): v-faces on the top edge.
197    match bc.top {
198        BcKind::NoSlip => {
199            for i in 0..nx {
200                grid.v[v_idx(grid.nx, i, ny)] = 0.0;
201            }
202        }
203        BcKind::Inflow { v, .. } => {
204            for i in 0..nx {
205                grid.v[v_idx(grid.nx, i, ny)] = v;
206            }
207        }
208        BcKind::Outflow => {
209            for i in 0..nx {
210                grid.v[v_idx(grid.nx, i, ny)] = grid.v[v_idx(grid.nx, i, ny - 1)];
211            }
212        }
213        BcKind::PressureOutlet { .. } => {
214            for i in 0..nx {
215                grid.v[v_idx(grid.nx, i, ny)] = grid.v[v_idx(grid.nx, i, ny - 1)];
216            }
217        }
218    }
219
220    // NOTE: tangential velocity at inflow boundaries is NOT set here. In a
221    // staggered grid, the u-faces at j=ny-1 are half a cell below the top wall,
222    // not at the wall. The wall velocity is enforced through ghost cells in
223    // the diffusion term (see d2u_dy2 / d2v_dx2 in the solver). Setting the
224    // tangential velocity directly at interior faces would over-constrain the
225    // system and cause numerical instability.
226}
227
228// ─── Solver ──────────────────────────────────────────────────────────────────
229
230/// Solver configuration.
231pub struct SolverConfig {
232    pub density: f64,         // ρ (kg/m³)
233    pub viscosity: f64,       // μ (Pa·s)
234    pub dt: f64,              // time step (s)
235    pub max_steps: usize,     // max time steps
236    pub tolerance: f64,       // convergence tolerance for steady-state check
237    pub poisson_iters: usize, // Gauss–Seidel iterations for pressure Poisson
238}
239
240impl Default for SolverConfig {
241    fn default() -> Self {
242        Self {
243            density: 1.0,
244            viscosity: 0.01,
245            dt: 0.001,
246            max_steps: 5000,
247            tolerance: 1e-6,
248            poisson_iters: 50,
249        }
250    }
251}
252
253/// Solve the 2-D incompressible Navier–Stokes equations using the Lattice
254/// Boltzmann Method (LBM) with the D2Q9 lattice.
255///
256/// LBM is inherently stable for low-to-moderate Reynolds numbers and does
257/// not require a separate Poisson solver — pressure emerges naturally from
258/// the distribution function moments.
259///
260/// The D2Q9 lattice has 9 velocity directions:
261/// ```text
262///   6   2   5
263///     \ | /
264///   3 — 0 — 1
265///     / | \
266///   7   4   8
267/// ```
268///
269/// Weights: w0 = 4/9, w_cardinal = 1/9 (1,2,3,4), w_diagonal = 1/36 (5,6,7,8).
270///
271/// The relaxation time τ is related to kinematic viscosity by:
272///   ν = (τ - 0.5) * c² * dt,  where c = dx/dt is the lattice speed.
273///
274/// Boundary conditions:
275/// - **No-slip wall**: bounce-back (f_i → f_opposite after collision).
276/// - **Inflow (moving wall)**: Zou-He velocity BC.
277/// - **Outflow**: zero-gradient (copy from upstream).
278/// - **Pressure outlet**: fixed density.
279///
280/// Returns the final velocity and pressure fields, plus the max residual
281/// (max divergence) achieved.
282fn solve(
283    grid: &mut StaggeredGrid,
284    bc: &CfdBc,
285    cfg: &SolverConfig,
286) -> Result<(f64, usize), EngineeringError> {
287    if cfg.density <= 0.0 {
288        return Err(EngineeringError::ValidationError(
289            "density must be positive".to_string(),
290        ));
291    }
292    if cfg.viscosity < 0.0 {
293        return Err(EngineeringError::ValidationError(
294            "viscosity must be non-negative".to_string(),
295        ));
296    }
297
298    let nx = grid.nx;
299    let ny = grid.ny;
300    let dx = grid.dx;
301    let dy = grid.dy;
302    if (dx - dy).abs() > 1e-10 {
303        return Err(EngineeringError::ValidationError(
304            "LBM requires square cells (dx = dy)".to_string(),
305        ));
306    }
307
308    // LBM works in lattice units: dx = dt = 1. The physical viscosity is
309    // converted to lattice viscosity via the Reynolds number.
310    //
311    // Re = U_phys * L_phys / ν_phys
312    // ν_lattice = U_lattice * N / Re
313    // τ = 3 * ν_lattice + 0.5
314    //
315    // where N = nx (grid size), U_lattice = 0.1 (kept small for incompressibility).
316    // Physical velocity is recovered: u_phys = u_lattice * (U_phys / U_lattice).
317    let nu_phys = cfg.viscosity / cfg.density;
318
319    // Determine characteristic velocity from all inflow boundaries.
320    let mut u_char = 0.0f64;
321    for kind in [bc.left, bc.right, bc.bottom, bc.top] {
322        if let BcKind::Inflow { u, v } = kind {
323            u_char = u_char.max(u.abs()).max(v.abs());
324        }
325    }
326    u_char = u_char.max(1e-10);
327
328    // CFL stability: u·Δt / Δx ≤ 1 (explicit advection / LBM streaming limit).
329    let cfl = u_char * cfg.dt / dx.min(dy);
330    if cfl > 1.0 {
331        return Err(EngineeringError::ValidationError(format!(
332            "CFL condition violated: u·Δt/Δx = {:.4} > 1 (u={}, dt={}, dx={})",
333            cfl, u_char, cfg.dt, dx
334        )));
335    }
336
337    let l_char = dx * nx as f64;
338    let re = u_char * l_char / nu_phys;
339
340    // Lattice velocity derived from physical time step (see lid-driven cavity test).
341    let u_lattice = (u_char * cfg.dt / dx).clamp(1e-4, 0.3);
342    let nu_lattice = u_lattice * nx as f64 / re.max(1.0);
343    let tau = 3.0 * nu_lattice + 0.5;
344
345    if tau < 0.51 {
346        return Err(EngineeringError::ValidationError(format!(
347            "relaxation time τ={} too small (Re too high for this grid); need τ > 0.5",
348            tau
349        )));
350    }
351    if tau > 2.0 {
352        return Err(EngineeringError::ValidationError(format!(
353            "relaxation time τ={} too large (Re too low for this grid); need τ < 2.0",
354            tau
355        )));
356    }
357    let omega_lbm = 1.0 / tau; // relaxation frequency
358    let vel_scale = u_char / u_lattice; // lattice → physical velocity scale
359
360    // ── Extract wall velocities from BCs (convert to lattice units) ──
361    let _u_top = match bc.top {
362        BcKind::Inflow { u, .. } => u / vel_scale,
363        _ => 0.0,
364    };
365    let _u_bot = match bc.bottom {
366        BcKind::Inflow { u, .. } => u / vel_scale,
367        _ => 0.0,
368    };
369    let _v_left = match bc.left {
370        BcKind::Inflow { v, .. } => v / vel_scale,
371        _ => 0.0,
372    };
373    let _v_right = match bc.right {
374        BcKind::Inflow { v, .. } => v / vel_scale,
375        _ => 0.0,
376    };
377
378    // ── D2Q9 lattice directions ──
379    //   i:  0  1  2  3  4  5  6  7  8
380    //   cx: 0  1  0 -1  0  1 -1 -1  1
381    //   cy: 0  0  1  0 -1  1  1 -1 -1
382    //   w:  4/9 1/9 1/9 1/9 1/9 1/36 1/36 1/36 1/36
383    const N_DIR: usize = 9;
384    const CX: [i32; 9] = [0, 1, 0, -1, 0, 1, -1, -1, 1];
385    const CY: [i32; 9] = [0, 0, 1, 0, -1, 1, 1, -1, -1];
386    const W: [f64; 9] = [
387        4.0 / 9.0,
388        1.0 / 9.0,
389        1.0 / 9.0,
390        1.0 / 9.0,
391        1.0 / 9.0,
392        1.0 / 36.0,
393        1.0 / 36.0,
394        1.0 / 36.0,
395        1.0 / 36.0,
396    ];
397    // Opposite directions: 0→0, 1→3, 2→4, 3→1, 4→2, 5→7, 6→8, 7→5, 8→6
398    const OPP: [usize; 9] = [0, 3, 4, 1, 2, 7, 8, 5, 6];
399
400    // Distribution functions: f[direction, x, y] → flat index.
401    let n_cells = nx * ny;
402    let mut f = vec![0.0; N_DIR * n_cells];
403    let mut f_new = vec![0.0; N_DIR * n_cells];
404
405    // Initialize: fluid at rest, uniform density ρ = 1.0 (lattice units).
406    for i in 0..N_DIR {
407        for cell in 0..n_cells {
408            f[i * n_cells + cell] = W[i]; // f_i^eq at u=0, ρ=1
409        }
410    }
411
412    let mut converged_step = cfg.max_steps;
413    let mut prev_max_vel = f64::MAX;
414
415    for step in 0..cfg.max_steps {
416        // ── 1. Compute macroscopic fields (ρ, u, v) from f ──
417        let mut rho = vec![1.0; n_cells];
418        let mut u = vec![0.0; n_cells];
419        let mut v = vec![0.0; n_cells];
420
421        for j in 0..ny {
422            for i in 0..nx {
423                let cell = j * nx + i;
424                let mut rho_c = 0.0;
425                let mut u_c = 0.0;
426                let mut v_c = 0.0;
427                for d in 0..N_DIR {
428                    let f_d = f[d * n_cells + cell];
429                    rho_c += f_d;
430                    u_c += f_d * CX[d] as f64;
431                    v_c += f_d * CY[d] as f64;
432                }
433                rho[cell] = rho_c;
434                if rho_c > 1e-10 {
435                    u[cell] = u_c / rho_c;
436                    v[cell] = v_c / rho_c;
437                }
438            }
439        }
440
441        // ── 2. Collision: f_i' = f_i + ω * (f_i^eq − f_i) ──
442        for j in 0..ny {
443            for i in 0..nx {
444                let cell = j * nx + i;
445                let rho_c = rho[cell];
446                let u_c = u[cell];
447                let v_c = v[cell];
448                let usq = u_c * u_c + v_c * v_c;
449
450                for d in 0..N_DIR {
451                    let cu = CX[d] as f64 * u_c + CY[d] as f64 * v_c;
452                    let f_eq = W[d] * rho_c * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq);
453                    let idx = d * n_cells + cell;
454                    f[idx] += omega_lbm * (f_eq - f[idx]);
455                }
456            }
457        }
458
459        // ── 3. Streaming: f_i(x + c_i, t+1) = f_i'(x, t) ──
460        // For interior cells, stream in each direction.
461        for d in 0..N_DIR {
462            for j in 0..ny {
463                for i in 0..nx {
464                    let cell = j * nx + i;
465                    let ni = i as i32 + CX[d];
466                    let nj = j as i32 + CY[d];
467                    if ni >= 0 && ni < nx as i32 && nj >= 0 && nj < ny as i32 {
468                        let n_cell = nj as usize * nx + ni as usize;
469                        f_new[d * n_cells + n_cell] = f[d * n_cells + cell];
470                    }
471                }
472            }
473        }
474
475        // ── 4. Boundary conditions (bounce-back + Zou-He) ──
476        // Bounce-back for no-slip walls: f_i at wall = f_opposite from interior.
477        // Zou-He for moving walls: prescribe velocity, compute unknown f from ρ.
478
479        // ── 4. Boundary conditions (moving bounce-back) ──
480        // For no-slip walls: f_opp = f_i (standard bounce-back).
481        // For moving walls: f_opp = f_i - 2*w_i*ρ*3*(e_i·u_wall)
482        //   where e_i is the direction pointing TOWARD the wall.
483        // For outflow/pressure outlets: copy from interior (zero gradient).
484
485        // Bottom wall (j=0): directions toward wall = 4,7,8 (downward). Unknowns: 2,5,6.
486        let (u_w, v_w) = match bc.bottom {
487            BcKind::NoSlip => (0.0, 0.0),
488            BcKind::Inflow { u, v } => (u / vel_scale, v / vel_scale),
489            _ => (f64::NAN, f64::NAN), // outflow/pressure: handled separately
490        };
491        if u_w.is_nan() {
492            for i in 1..nx - 1 {
493                let cell = i;
494                let src = nx + i;
495                f_new[2 * n_cells + cell] = f[2 * n_cells + src];
496                f_new[5 * n_cells + cell] = f[5 * n_cells + src];
497                f_new[6 * n_cells + cell] = f[6 * n_cells + src];
498            }
499        } else {
500            for i in 1..nx - 1 {
501                let cell = i;
502                let r = rho[cell];
503                f_new[2 * n_cells + cell] = f[4 * n_cells + cell] + (2.0 / 3.0) * r * v_w;
504                f_new[5 * n_cells + cell] = f[7 * n_cells + cell] + r * (u_w + v_w) / 6.0;
505                f_new[6 * n_cells + cell] = f[8 * n_cells + cell] + r * (-u_w + v_w) / 6.0;
506            }
507        }
508
509        // Top wall (j=ny-1): directions toward wall = 2,5,6 (upward). Unknowns: 4,7,8.
510        // Skip corner cells (i=0 and i=nx-1) — they're handled by left/right walls.
511        let (u_w, v_w) = match bc.top {
512            BcKind::NoSlip => (0.0, 0.0),
513            BcKind::Inflow { u, v } => (u / vel_scale, v / vel_scale),
514            _ => (f64::NAN, f64::NAN),
515        };
516        if u_w.is_nan() {
517            for i in 1..nx - 1 {
518                let cell = (ny - 1) * nx + i;
519                let src = (ny - 2) * nx + i;
520                f_new[4 * n_cells + cell] = f[4 * n_cells + src];
521                f_new[7 * n_cells + cell] = f[7 * n_cells + src];
522                f_new[8 * n_cells + cell] = f[8 * n_cells + src];
523            }
524        } else {
525            for i in 1..nx - 1 {
526                let cell = (ny - 1) * nx + i;
527                let r = rho[cell];
528                f_new[4 * n_cells + cell] = f[2 * n_cells + cell] - (2.0 / 3.0) * r * v_w;
529                f_new[7 * n_cells + cell] = f[5 * n_cells + cell] - r * (u_w + v_w) / 6.0;
530                f_new[8 * n_cells + cell] = f[6 * n_cells + cell] - r * (-u_w + v_w) / 6.0;
531            }
532        }
533
534        // Left wall (i=0): directions toward wall = 3,6,7 (leftward). Unknowns: 1,5,8.
535        let (u_w, v_w) = match bc.left {
536            BcKind::NoSlip => (0.0, 0.0),
537            BcKind::Inflow { u, v } => (u / vel_scale, v / vel_scale),
538            _ => (f64::NAN, f64::NAN),
539        };
540        if u_w.is_nan() {
541            for j in 0..ny {
542                let cell = j * nx;
543                let src = j * nx + 1;
544                f_new[1 * n_cells + cell] = f[1 * n_cells + src];
545                f_new[5 * n_cells + cell] = f[5 * n_cells + src];
546                f_new[8 * n_cells + cell] = f[8 * n_cells + src];
547            }
548        } else {
549            for j in 0..ny {
550                let cell = j * nx;
551                let r = rho[cell];
552                f_new[1 * n_cells + cell] = f[3 * n_cells + cell] + (2.0 / 3.0) * r * u_w;
553                f_new[5 * n_cells + cell] = f[7 * n_cells + cell] + r * (u_w + v_w) / 6.0;
554                f_new[8 * n_cells + cell] = f[6 * n_cells + cell] + r * (u_w - v_w) / 6.0;
555            }
556        }
557
558        // Right wall (i=nx-1): directions toward wall = 1,5,8 (rightward). Unknowns: 3,6,7.
559        let (u_w, v_w) = match bc.right {
560            BcKind::NoSlip => (0.0, 0.0),
561            BcKind::Inflow { u, v } => (u / vel_scale, v / vel_scale),
562            _ => (f64::NAN, f64::NAN),
563        };
564        if u_w.is_nan() {
565            for j in 0..ny {
566                let cell = j * nx + (nx - 1);
567                let src = j * nx + (nx - 2);
568                f_new[3 * n_cells + cell] = f[3 * n_cells + src];
569                f_new[6 * n_cells + cell] = f[6 * n_cells + src];
570                f_new[7 * n_cells + cell] = f[7 * n_cells + src];
571            }
572        } else {
573            for j in 0..ny {
574                let cell = j * nx + (nx - 1);
575                let r = rho[cell];
576                f_new[3 * n_cells + cell] = f[1 * n_cells + cell] - (2.0 / 3.0) * r * u_w;
577                f_new[6 * n_cells + cell] = f[8 * n_cells + cell] - r * (u_w - v_w) / 6.0;
578                f_new[7 * n_cells + cell] = f[5 * n_cells + cell] - r * (u_w + v_w) / 6.0;
579            }
580        }
581
582        // ── Corner cells: regular bounce-back (no moving wall correction) ──
583        // Corners are where two walls meet; use simple bounce-back from both
584        // walls to avoid conflicting moving-wall corrections. `OPP[d]` gives the
585        // opposite lattice direction, so `f_new[d] = f[OPP[d]]` is the standard
586        // half-way bounce-back that reflects the distribution function.
587        // Bottom-left corner (0, 0): reflect directions 1, 2, 5.
588        let c = 0;
589        for &d in &[1usize, 2, 5] {
590            f_new[d * n_cells + c] = f[OPP[d] * n_cells + c];
591        }
592        // Bottom-right corner (nx-1, 0): reflect directions 2, 3, 6.
593        let c = nx - 1;
594        for &d in &[2usize, 3, 6] {
595            f_new[d * n_cells + c] = f[OPP[d] * n_cells + c];
596        }
597        // Top-left corner (0, ny-1): reflect directions 1, 4, 8.
598        let c = (ny - 1) * nx;
599        for &d in &[1usize, 4, 8] {
600            f_new[d * n_cells + c] = f[OPP[d] * n_cells + c];
601        }
602        // Top-right corner (nx-1, ny-1): reflect directions 3, 4, 7.
603        let c = (ny - 1) * nx + (nx - 1);
604        for &d in &[3usize, 4, 7] {
605            f_new[d * n_cells + c] = f[OPP[d] * n_cells + c];
606        }
607
608        // Swap f and f_new.
609        std::mem::swap(&mut f, &mut f_new);
610
611        // ── 5. Convergence check ──
612        let mut max_vel = 0.0f64;
613        for j in 1..ny - 1 {
614            for i in 1..nx - 1 {
615                let cell = j * nx + i;
616                max_vel = max_vel.max(u[cell].abs()).max(v[cell].abs());
617            }
618        }
619
620        if max_vel > 1e6 || max_vel.is_nan() {
621            return Err(EngineeringError::ConvergenceError(format!(
622                "velocity blow-up at step {}: max_vel = {}",
623                step, max_vel
624            )));
625        }
626
627        if step > 100 && (prev_max_vel - max_vel).abs() < cfg.tolerance {
628            converged_step = step;
629            break;
630        }
631        prev_max_vel = max_vel;
632    }
633
634    // ── Extract final macroscopic fields ──
635    let mut rho = vec![1.0; n_cells];
636    let mut u_final = vec![0.0; n_cells];
637    let mut v_final = vec![0.0; n_cells];
638
639    for j in 0..ny {
640        for i in 0..nx {
641            let cell = j * nx + i;
642            let mut rho_c = 0.0;
643            let mut u_c = 0.0;
644            let mut v_c = 0.0;
645            for d in 0..N_DIR {
646                let f_d = f[d * n_cells + cell];
647                rho_c += f_d;
648                u_c += f_d * CX[d] as f64;
649                v_c += f_d * CY[d] as f64;
650            }
651            rho[cell] = rho_c;
652            if rho_c > 1e-10 {
653                u_final[cell] = u_c / rho_c;
654                v_final[cell] = v_c / rho_c;
655            }
656        }
657    }
658
659    // ── Copy to staggered grid (scale lattice → physical) ──
660    // u at vertical faces: average of cell-centre u values.
661    for j in 0..ny {
662        for i in 0..nx + 1 {
663            let u_left = if i > 0 {
664                u_final[j * nx + (i - 1)]
665            } else {
666                0.0
667            };
668            let u_right = if i < nx { u_final[j * nx + i] } else { 0.0 };
669            grid.u[u_idx(nx, i, j)] = 0.5 * (u_left + u_right) * vel_scale;
670        }
671    }
672    // v at horizontal faces: average of cell-centre v values.
673    for j in 0..ny + 1 {
674        for i in 0..nx {
675            let v_bot = if j > 0 {
676                v_final[(j - 1) * nx + i]
677            } else {
678                0.0
679            };
680            let v_top = if j < ny { v_final[j * nx + i] } else { 0.0 };
681            grid.v[v_idx(nx, i, j)] = 0.5 * (v_bot + v_top) * vel_scale;
682        }
683    }
684    // Pressure: p = c_s² * (ρ − ρ₀), where c_s² = 1/3 (lattice units).
685    for j in 0..ny {
686        for i in 0..nx {
687            grid.p[p_idx(nx, i, j)] = (rho[j * nx + i] - 1.0) * cfg.density / 3.0;
688        }
689    }
690
691    // Compute max divergence at interior cell centres only.
692    // Boundary cells have high divergence due to bounce-back BC artifacts,
693    // which is expected and not a solver error.
694    let mut max_div = 0.0f64;
695    for j in 1..ny - 1 {
696        for i in 1..nx - 1 {
697            let du_dx =
698                (u_final[j * nx + (i + 1)] - u_final[j * nx + (i - 1)]) * vel_scale / (2.0 * dx);
699            let dv_dy =
700                (v_final[(j + 1) * nx + i] - v_final[(j - 1) * nx + i]) * vel_scale / (2.0 * dy);
701            max_div = max_div.max((du_dx + dv_dy).abs());
702        }
703    }
704
705    // ── Enforce Dirichlet velocity BCs on the staggered-grid boundary faces ──
706    // The LBM solve above computes cell-centre values and averages them onto the
707    // staggered faces. The boundary face velocities produced by that averaging do
708    // not exactly match the prescribed boundary-condition velocities (e.g. the
709    // lid velocity at the top wall). `apply_bc` overwrites the boundary u/v faces
710    // with the exact Dirichlet values (and applies the Neumann extrapolation for
711    // outflow / pressure outlets), so the returned `CfdSolution` boundary values
712    // are physically consistent with the requested `CfdBc`.
713    apply_bc(grid, bc);
714
715    Ok((max_div, converged_step))
716}
717
718// ─── Public API ──────────────────────────────────────────────────────────────
719
720/// CFD solution fields returned to the caller.
721pub struct CfdSolution {
722    /// u-velocity at vertical faces, shape `(nx+1, ny)`, row-major (j outer, i inner).
723    pub u: Vec<f64>,
724    /// v-velocity at horizontal faces, shape `(nx, ny+1)`.
725    pub v: Vec<f64>,
726    /// pressure at cell centres, shape `(nx, ny)`.
727    pub p: Vec<f64>,
728    /// Number of cells in x.
729    pub nx: usize,
730    /// Number of cells in y.
731    pub ny: usize,
732    /// Domain length in x (m).
733    pub lx: f64,
734    /// Domain length in y (m).
735    pub ly: f64,
736    /// Maximum divergence (continuity residual) at convergence.
737    pub max_divergence: f64,
738    /// Time step at which convergence was achieved.
739    pub converged_step: usize,
740}
741
742/// Run a 2-D incompressible Navier–Stokes simulation.
743///
744/// The domain geometry is derived from the `EngineeringModel`'s `geometry.dimensions`:
745/// `dimensions[0]` = Lx, `dimensions[1]` = Ly. The fluid properties (density,
746/// viscosity) are taken from the model's first material's `MaterialProperties`
747/// (`density` and a viscosity proxy from `thermal_conductivity` / `specific_heat`
748/// when no explicit viscosity field exists — we use `density` directly and
749/// derive viscosity from the Reynolds number if specified in boundary conditions).
750///
751/// For the standard lid-driven cavity (default BCs), viscosity is taken from
752/// the `SolverConfig` which defaults to Re = 100 (μ = 0.01, ρ = 1.0, L = 1, U = 1).
753///
754/// Returns the velocity and pressure fields, or an error if the inputs are
755/// insufficient or the solver fails to converge.
756pub fn run_cfd(
757    model: &EngineeringModel,
758    bc: CfdBc,
759    cfg: SolverConfig,
760    nx: usize,
761    ny: usize,
762) -> Result<CfdSolution, EngineeringError> {
763    // Extract domain dimensions from geometry.
764    let dims = &model.geometry.dimensions;
765    if dims.len() < 2 {
766        return Err(EngineeringError::InsufficientData(
767            "geometry.dimensions must contain at least [Lx, Ly]".to_string(),
768        ));
769    }
770    let lx = dims[0];
771    let ly = dims[1];
772    if lx <= 0.0 || ly <= 0.0 {
773        return Err(EngineeringError::ValidationError(
774            "domain dimensions must be positive".to_string(),
775        ));
776    }
777
778    // Try to extract density from material; fall back to config default.
779    let density = model
780        .materials
781        .values()
782        .next()
783        .map(|m| m.material_properties.density)
784        .filter(|d| *d > 0.0)
785        .unwrap_or(cfg.density);
786
787    let mut cfg = cfg;
788    cfg.density = density;
789
790    if nx < 4 || ny < 4 {
791        return Err(EngineeringError::ValidationError(
792            "mesh must be at least 4×4 cells".to_string(),
793        ));
794    }
795
796    let mut grid = StaggeredGrid::new(nx, ny, lx, ly);
797    let (max_div, converged_step) = solve(&mut grid, &bc, &cfg)?;
798
799    Ok(CfdSolution {
800        u: grid.u,
801        v: grid.v,
802        p: grid.p,
803        nx,
804        ny,
805        lx,
806        ly,
807        max_divergence: max_div,
808        converged_step,
809    })
810}
811
812/// Convert a `CfdSolution` into the library's `AnalysisResults` format.
813pub fn cfd_to_analysis_results(
814    sol: &CfdSolution,
815    model: &EngineeringModel,
816    analysis_type: AnalysisType,
817) -> AnalysisResults {
818    // Flatten velocity magnitude at cell centres into the displacement_field
819    // (reusing the field as a general-purpose scalar output), and pressure
820    // into stress_field. This is the honest mapping — AnalysisResults was
821    // designed for mechanical analysis, but the fields are Vec<f64> and
822    // documented as "field" outputs.
823    let mut vel_mag = Vec::with_capacity(sol.nx * sol.ny);
824    let mut pressure = Vec::with_capacity(sol.nx * sol.ny);
825
826    for j in 0..sol.ny {
827        for i in 0..sol.nx {
828            // Interpolate u and v to cell centre.
829            let u_c = 0.5 * (sol.u[j * (sol.nx + 1) + i] + sol.u[j * (sol.nx + 1) + i + 1]);
830            let v_c = 0.5 * (sol.v[j * sol.nx + i] + sol.v[(j + 1) * sol.nx + i]);
831            vel_mag.push((u_c * u_c + v_c * v_c).sqrt());
832            pressure.push(sol.p[j * sol.nx + i]);
833        }
834    }
835
836    AnalysisResults {
837        results_id: format!("cfd_{}", model.model_id),
838        analysis_type,
839        displacement_field: vel_mag,
840        stress_field: pressure,
841        strain_field: Vec::new(),
842        reaction_forces: Vec::new(),
843        safety_factor: 0.0,
844        temperature_field: Vec::new(),
845        heat_flux_field: Vec::new(),
846    }
847}
848
849// ─── Tests ───────────────────────────────────────────────────────────────────
850
851#[cfg(test)]
852mod tests {
853    use super::super::{
854        EngineeringModel, Geometry, GeometryType, Material, MaterialProperties, ModelType,
855    };
856    use super::*;
857    use std::collections::HashMap;
858
859    fn cfd_model(lx: f64, ly: f64) -> EngineeringModel {
860        let mut materials = HashMap::new();
861        materials.insert(
862            "fluid".to_string(),
863            Material {
864                material_id: "fluid".to_string(),
865                material_name: "water".to_string(),
866                material_properties: MaterialProperties {
867                    youngs_modulus: 0.0,
868                    poissons_ratio: 0.0,
869                    density: 1.0,
870                    thermal_expansion: 0.0,
871                    thermal_conductivity: 0.0,
872                    specific_heat: 0.0,
873                    yield_strength: 0.0,
874                    ultimate_strength: 0.0,
875                },
876            },
877        );
878        EngineeringModel {
879            model_id: "cfd_test".to_string(),
880            model_name: "CFD Test".to_string(),
881            model_type: ModelType::Fluid,
882            geometry: Geometry {
883                geometry_type: GeometryType::Beam,
884                dimensions: vec![lx, ly],
885                features: Vec::new(),
886            },
887            materials,
888            boundary_conditions: Vec::new(),
889            loads: Vec::new(),
890        }
891    }
892
893    #[test]
894    fn lid_driven_cavity_converges() {
895        // Classic lid-driven cavity. Start with Re=10 (viscosity=0.1) for
896        // stability on a 20×20 grid (τ ≈ 1.1, well within stable range).
897        let model = cfd_model(1.0, 1.0);
898        let bc = CfdBc::default(); // no-slip walls + top lid at u=1.
899        let cfg = SolverConfig {
900            density: 1.0,
901            viscosity: 0.1,
902            dt: 0.0025, // Maps to u_LBM = 0.05, tau = 0.8
903            max_steps: 10000,
904            tolerance: 1e-6,
905            poisson_iters: 0,
906        };
907        let result = run_cfd(&model, bc, cfg, 20, 20);
908        assert!(result.is_ok(), "cavity solver failed: {:?}", result.err());
909        let sol = result.unwrap();
910
911        // The cavity should converge to a steady recirculation.
912        // Interior divergence should be small (LBM is divergence-free in bulk).
913        assert!(
914            sol.max_divergence < 1.0,
915            "interior divergence too high: {}",
916            sol.max_divergence
917        );
918
919        // The lid velocity (top wall) should be close to 1.0.
920        // u at the top row of staggered grid (j=ny-1=19).
921        let top_u: Vec<f64> = (1..20).map(|i| sol.u[19 * 21 + i]).collect();
922        let max_u = top_u.iter().cloned().fold(0.0f64, f64::max);
923        assert!(
924            max_u > 0.3,
925            "lid velocity should be significant, got max u = {}",
926            max_u
927        );
928
929        // Centre of the cavity should have a vortex (non-zero velocity).
930        let ci = 10;
931        let cj = 10;
932        let u_c = 0.5 * (sol.u[cj * 21 + ci] + sol.u[cj * 21 + ci + 1]);
933        let v_c = 0.5 * (sol.v[cj * 20 + ci] + sol.v[(cj + 1) * 20 + ci]);
934        let vel_c = (u_c * u_c + v_c * v_c).sqrt();
935        assert!(
936            vel_c > 1e-4,
937            "cavity centre should have non-zero velocity, got {}",
938            vel_c
939        );
940    }
941
942    #[test]
943    fn channel_flow_has_uniform_profile() {
944        // Channel flow: left inflow u=1, right outflow, top/bottom no-slip.
945        let model = cfd_model(2.0, 1.0);
946        let bc = CfdBc {
947            left: BcKind::Inflow { u: 1.0, v: 0.0 },
948            right: BcKind::Outflow,
949            bottom: BcKind::NoSlip,
950            top: BcKind::NoSlip,
951        };
952        let cfg = SolverConfig {
953            density: 1.0,
954            viscosity: 0.01,
955            dt: 0.005,
956            max_steps: 3000,
957            tolerance: 1e-4,
958            poisson_iters: 30,
959        };
960        let sol = run_cfd(&model, bc, cfg, 20, 10).unwrap();
961
962        // At the inflow (left boundary), u should be ~1.0.
963        let inflow_u: Vec<f64> = (0..10).map(|j| sol.u[j * 21 + 0]).collect();
964        let avg_inflow = inflow_u.iter().sum::<f64>() / inflow_u.len() as f64;
965        assert!(
966            (avg_inflow - 1.0).abs() < 0.15,
967            "inflow u should be near 1.0, got avg = {}",
968            avg_inflow
969        );
970
971        // No-slip walls: v at top and bottom should be ~0.
972        let top_v: Vec<f64> = (0..20).map(|i| sol.v[10 * 20 + i]).collect();
973        let max_top_v = top_v.iter().cloned().fold(0.0f64, f64::max);
974        assert!(
975            max_top_v.abs() < 0.1,
976            "top wall v should be ~0, got {}",
977            max_top_v
978        );
979    }
980
981    #[test]
982    fn missing_dimensions_errors() {
983        let mut model = cfd_model(1.0, 1.0);
984        model.geometry.dimensions.clear();
985        let bc = CfdBc::default();
986        let cfg = SolverConfig::default();
987        let result = run_cfd(&model, bc, cfg, 10, 10);
988        assert!(matches!(result, Err(EngineeringError::InsufficientData(_))));
989    }
990
991    #[test]
992    fn negative_viscosity_errors() {
993        let model = cfd_model(1.0, 1.0);
994        let bc = CfdBc::default();
995        let cfg = SolverConfig {
996            viscosity: -1.0,
997            ..Default::default()
998        };
999        let result = run_cfd(&model, bc, cfg, 10, 10);
1000        assert!(matches!(result, Err(EngineeringError::ValidationError(_))));
1001    }
1002
1003    #[test]
1004    fn cfl_violation_errors() {
1005        let model = cfd_model(1.0, 1.0);
1006        let bc = CfdBc::default();
1007        let cfg = SolverConfig {
1008            dt: 10.0, // huge dt → CFL violation
1009            ..Default::default()
1010        };
1011        let result = run_cfd(&model, bc, cfg, 10, 10);
1012        assert!(matches!(result, Err(EngineeringError::ValidationError(_))));
1013    }
1014
1015    #[test]
1016    fn pressure_outlet_maintains_pressure() {
1017        // Pressure outlet on the right: p = 0 (atmospheric).
1018        let model = cfd_model(1.0, 1.0);
1019        let bc = CfdBc {
1020            left: BcKind::Inflow { u: 0.5, v: 0.0 },
1021            right: BcKind::PressureOutlet { p: 0.0 },
1022            bottom: BcKind::NoSlip,
1023            top: BcKind::NoSlip,
1024        };
1025        let cfg = SolverConfig {
1026            density: 1.0,
1027            viscosity: 0.01,
1028            dt: 0.005,
1029            max_steps: 2000,
1030            tolerance: 1e-4,
1031            poisson_iters: 30,
1032        };
1033        let sol = run_cfd(&model, bc, cfg, 16, 16).unwrap();
1034
1035        // Pressure at the right boundary should be near 0.
1036        let right_p: Vec<f64> = (0..16).map(|j| sol.p[j * 16 + 15]).collect();
1037        let avg_right_p = right_p.iter().sum::<f64>() / right_p.len() as f64;
1038        assert!(
1039            avg_right_p.abs() < 5.0,
1040            "pressure outlet should be near 0, got avg = {}",
1041            avg_right_p
1042        );
1043    }
1044
1045    #[test]
1046    fn cfd_to_analysis_results_maps_fields() {
1047        let model = cfd_model(1.0, 1.0);
1048        let bc = CfdBc::default();
1049        let cfg = SolverConfig {
1050            density: 1.0,
1051            viscosity: 0.01,
1052            dt: 0.005,
1053            max_steps: 500,
1054            tolerance: 1e-3,
1055            poisson_iters: 20,
1056        };
1057        let sol = run_cfd(&model, bc, cfg, 10, 10).unwrap();
1058        let results = cfd_to_analysis_results(&sol, &model, AnalysisType::LinearStatic);
1059
1060        assert_eq!(results.results_id, "cfd_cfd_test");
1061        assert_eq!(results.displacement_field.len(), 100); // 10×10 cell-centred values
1062        assert_eq!(results.stress_field.len(), 100);
1063        // Velocity magnitudes should be non-negative.
1064        assert!(results.displacement_field.iter().all(|&v| v >= 0.0));
1065    }
1066}