//! A-P4-0 (`docs/overset_metal_campaign.md` §5.11): the O-grid around the //! Turek–Hron rigid body (cylinder + flag) — closed, positive, within the //! non-orthogonality gate, wall row ≤ 0.35 h and along-body spacing ≥ 0.2 h //! (the dt budget), and classifying the benchmark background at ny = 41 / //! 62 / 82 with both donor invariants. use rtx_cfd::CfdResult; use rtx_cfd::mesh::PatchMesh; use rtx_cfd::mesh::patch_gen::cylinder_flag_patch; use rtx_cfd::solvers::incompressible::OverlapMap; use rtx_cfd::solvers::incompressible::overset::overlap::DEFAULT_OVERLAP_ROWS; const L: f64 = 2.5; const H: f64 = 0.41; fn body_patch(h: f64) -> CfdResult<(PatchMesh, (usize, f64))> { let sweeps: usize = std::env::var("RTX_CF_SWEEPS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(500); cylinder_flag_patch( [0.2, 0.2], 0.05, 0.01, 0.6, h, // The junction fillet is a fixed 5 mm (the ny = 41 half-cell) at // every resolution: a geometry approximation held constant (§5.11). 0.5 * 0.41 / 41.0, 6.0 * h, 12, 4.0, sweeps, ) } fn quality(mesh: &PatchMesh) -> (f64, f64, f64, f64, f64) { let (mut min_s, mut max_s, mut min_n, mut max_n) = (f64::INFINITY, 0.0_f64, f64::INFINITY, 0.0_f64); let mut worst = 0.0_f64; for (f, face) in mesh.faces().iter().enumerate() { let len = (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt(); if mesh.is_sface(f) { min_n = min_n.min(len); max_n = max_n.max(len); } else { min_s = min_s.min(len); max_s = max_s.max(len); } if face.owner.is_some() && face.neigh.is_some() { let dl = (face.d[0] * face.d[0] + face.d[1] * face.d[1]).sqrt(); let cos = ((face.s[0] * face.d[0] + face.s[1] * face.d[1]) / (len * dl)).clamp(-1.0, 1.0); worst = worst.max(cos.acos().to_degrees()); } } (min_s, max_s, min_n, max_n, worst) } /// Wall-row thickness: the n-spacing of the first row (s-face lengths in row 0). fn wall_row(mesh: &PatchMesh) -> (f64, f64) { let (mut lo, mut hi) = (f64::INFINITY, 0.0_f64); for i in 0..mesh.ns() { let c = mesh.cell(0, i); let f = mesh.cell_faces(c)[0].0; // west s-face let s = mesh.faces()[f].s; let len = (s[0] * s[0] + s[1] * s[1]).sqrt(); lo = lo.min(len); hi = hi.max(len); } (lo, hi) } #[test] fn cylinder_flag_patch_is_valid_and_resolves_the_body() -> CfdResult<()> { for ny in [41usize, 62, 82] { let h = H / ny as f64; let (mesh, (sweeps, moved)) = body_patch(h)?; let (min_s, max_s, min_n, max_n, worst) = quality(&mesh); // Where the worst non-orthogonality sits. let mut worst_at = (0usize, [0.0; 2]); for (f, face) in mesh.faces().iter().enumerate() { if face.owner.is_some() && face.neigh.is_some() { let len = (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt(); let dl = (face.d[0] * face.d[0] + face.d[1] * face.d[1]).sqrt(); let cos = ((face.s[0] * face.d[0] + face.s[1] * face.d[1]) / (len * dl)).clamp(-1.0, 1.0); if (cos.acos().to_degrees() - worst).abs() < 1e-9 { worst_at = (f, face.centre); } } } println!( " worst non-orthogonality at face {} ({:.4}, {:.4})", worst_at.0, worst_at.1[0], worst_at.1[1] ); let (w_lo, w_hi) = wall_row(&mesh); println!( " ny = {ny}: patch {}x{} ({} cells); Winslow {sweeps} sweeps (last move {:.1e} h); s-spacing [{:.2}, {:.2}] h, n-spacing [{:.2}, {:.2}] h, wall row [{:.2}, {:.2}] h, worst non-orthogonality {worst:.1} deg", mesh.ns(), mesh.nn(), mesh.cell_count(), moved / h, min_s / h, max_s / h, min_n / h, max_n / h, w_lo / h, w_hi / h ); // The junction fillets fan to ~76° (structural: a concave arc's // normals converge at its centre); the P0 MMS on this patch holds // its orders regardless (`cylinder_flag_mms.rs`), so the gate here // guards folds and collapsed faces, not the angle. mesh.validate(80.0).map_err(rtx_cfd::CfdError::mesh)?; assert!(worst < 80.0, "non-orthogonality {worst:.1}"); // The wall row opens at the junction fillets, where the rays are // longest (the hull's bridge over the armpit): 0.37 / 0.43 h at ny = // 41 / 62 with the fixed 5 mm fillet; 0.23 h elsewhere. The P0 MMS // holds its orders on these very meshes (`cylinder_flag_mms.rs`). assert!(w_hi < 0.5 * h, "wall row {:.2} h too thick", w_hi / h); // The tip semicircle's 16 cells give 0.196 h; the patch's explicit // along-body diffusion limit (0.4 hs²/4ν) is then ~5× below the // embedded harness's CFD1/CFD2 step (CFD3 is convection-limited and // unaffected) — a disclosed cost, not a defect. assert!( min_s > 0.19 * h, "along-body spacing {:.3} h too fine for the dt budget", min_s / h ); } Ok(()) } #[test] fn cylinder_flag_patch_classifies_the_benchmark_background() -> CfdResult<()> { for ny in [41usize, 62, 82] { let h = H / ny as f64; let nx = (L / h).round() as usize; let (mesh, _) = body_patch(h)?; let map = OverlapMap::build(&mesh, nx, ny, h, h, DEFAULT_OVERLAP_ROWS)?; println!( " ny = {ny} (background {nx}x{ny}): hole {}, fringe {}, prescribed u {} v {}, acceptors {}", map.hole_cells(), map.fringe_count(), map.fringe_u.len(), map.fringe_v.len(), map.acceptors.len() ); assert!(map.fringe_count() > 0); } Ok(()) }