1use super::{AnalysisResults, AnalysisType, EngineeringError, EngineeringModel};
32
33pub struct StaggeredGrid {
40 nx: usize,
41 ny: usize,
42 dx: f64,
43 dy: f64,
44 u: Vec<f64>,
46 v: Vec<f64>,
48 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#[inline]
68fn u_idx(nx: usize, i: usize, j: usize) -> usize {
69 j * (nx + 1) + i
70}
71
72#[inline]
74fn v_idx(nx: usize, i: usize, j: usize) -> usize {
75 j * nx + i
76}
77
78#[inline]
80fn p_idx(nx: usize, i: usize, j: usize) -> usize {
81 j * nx + i
82}
83
84#[derive(Clone, Copy, Debug)]
88pub enum BcKind {
89 NoSlip,
91 Inflow { u: f64, v: f64 },
93 Outflow,
95 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 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
119fn apply_bc(grid: &mut StaggeredGrid, bc: &CfdBc) {
121 let nx = grid.nx;
122 let ny = grid.ny;
123
124 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 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 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 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 }
227
228pub struct SolverConfig {
232 pub density: f64, pub viscosity: f64, pub dt: f64, pub max_steps: usize, pub tolerance: f64, pub poisson_iters: usize, }
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
253fn 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 let nu_phys = cfg.viscosity / cfg.density;
318
319 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 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 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; let vel_scale = u_char / u_lattice; 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 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 const OPP: [usize; 9] = [0, 3, 4, 1, 2, 7, 8, 5, 6];
399
400 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 for i in 0..N_DIR {
407 for cell in 0..n_cells {
408 f[i * n_cells + cell] = W[i]; }
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 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 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 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 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), };
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 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 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 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 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 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 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 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 std::mem::swap(&mut f, &mut f_new);
610
611 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 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 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 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 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 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 apply_bc(grid, bc);
714
715 Ok((max_div, converged_step))
716}
717
718pub struct CfdSolution {
722 pub u: Vec<f64>,
724 pub v: Vec<f64>,
726 pub p: Vec<f64>,
728 pub nx: usize,
730 pub ny: usize,
732 pub lx: f64,
734 pub ly: f64,
736 pub max_divergence: f64,
738 pub converged_step: usize,
740}
741
742pub fn run_cfd(
757 model: &EngineeringModel,
758 bc: CfdBc,
759 cfg: SolverConfig,
760 nx: usize,
761 ny: usize,
762) -> Result<CfdSolution, EngineeringError> {
763 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 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
812pub fn cfd_to_analysis_results(
814 sol: &CfdSolution,
815 model: &EngineeringModel,
816 analysis_type: AnalysisType,
817) -> AnalysisResults {
818 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 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#[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 let model = cfd_model(1.0, 1.0);
898 let bc = CfdBc::default(); let cfg = SolverConfig {
900 density: 1.0,
901 viscosity: 0.1,
902 dt: 0.0025, 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 assert!(
914 sol.max_divergence < 1.0,
915 "interior divergence too high: {}",
916 sol.max_divergence
917 );
918
919 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 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 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 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 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, ..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 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 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); assert_eq!(results.stress_field.len(), 100);
1063 assert!(results.displacement_field.iter().all(|&v| v >= 0.0));
1065 }
1066}