//! P5 (`docs/overset_metal_campaign.md` §5.12): Turek–Hron FSI2 with the //! fluid on the OVERSET — the body-fitted patch following the flag. The //! structure, coupling and acceptance are the FSI2 harness's; the fluid //! side is `fsi2_harness::overset`. Defaults are the P5-1 gate run //! (ny = 41, s = 1, a short march); `RTX_FSI2O_*` knobs as the harness's. //! Reference (Turek–Hron FSI2): uy(A) 1.23 ± 80.6 mm at 2.0 Hz, ux(A) //! −14.58 ± 12.44 mm, drag 208.83 ± 73.75, lift 0.88 ± 234.2. mod fsi2_harness; use fsi2_harness::overset_march::{run_march_overset, OversetMarchConfig}; use fsi2_harness::{case_from_env, FSI2}; const REF_UY_AMP: f64 = 80.6e-3; const REF_UY_FREQ: f64 = 2.0; const REF_UX_MEAN: f64 = -14.58e-3; const REF_UX_AMP: f64 = 12.44e-3; const REF_DRAG_MEAN: f64 = 208.83; const REF_LIFT_AMP: f64 = 234.2; /// `RTX_FSI2O_AUDIT=dir`: the solver-metric chain on every saved instant /// under `dir` (`inst_*`, from `RTX_FSI2O_SAVE_EVERY`), no march. #[test] fn fsi2_overset_audit_of_saved_instants() { let Ok(dir) = std::env::var("RTX_FSI2O_AUDIT") else { return; }; let ny = std::env::var("RTX_FSI2O_NY") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(41); let flag_nx = std::env::var("RTX_FSI2O_FLAG_NX") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(35); let case = case_from_env("FSI2O", FSI2); let mut dirs: Vec<_> = std::fs::read_dir(&dir) .expect("audit dir") .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("inst_")) }) .collect(); dirs.sort(); for d in dirs { let (fluid, dvec, t) = fsi2_harness::overset::OversetFluid::from_instant(case, ny, flag_nx, &d) .expect("instant"); let tip: f64 = dvec.iter().map(|v| v * v).sum::().sqrt(); println!( " AUDIT {} t = {t:.4} |d| {tip:.3e}: {}", d.file_name().unwrap().to_string_lossy(), fluid.chain_line() ); } } #[test] fn fsi2_on_the_overset() { let config = OversetMarchConfig::from_env( "FSI2O", OversetMarchConfig { ny: 41, flag_nx: 35, t_release: 6.0, t_end: 7.0, subcycle: 1, tol_floor: 2e-4, rtol: 1e-2, stall_accept: 5.0, max_subiterations: 12, coupler: "aitken".into(), reuse: 2, initial_relaxation: 0.5, c1_interface: false, predictor: "structure".into(), sweeps: 100, max_rounds: 3, csv_path: None, trace_steps: 0, }, ); let case = case_from_env("FSI2O", FSI2); let r = run_march_overset(case, &config); let m = &r.result; let w = m.window(3.0); println!( " FSI2 OVERSET (ny = {}, flag {}x2 Quad8, dt = {:.2e}, s = {}, sweeps {}, rounds cap {}, {}): coupled {} steps in {:.0} s wall; {:.1} subit/step (max {}); {} stalled, {} retries (worst residual {:.2e}); conservation {:.2e}; {} wall faces; Newton rescues {:?}; rounds mean {:.2}; reclassified/step {:.2} (fresh {:.2}); patch regenerations {} in {:.0} s; fluid {:.0} s, structure {:.0} s; death {:?}\n measured over [{:.1}, {:.1}] s: uy(A) = {:.3} ± {:.3} mm (ref 1.23 ± {:.1}), ux(A) = {:.3} ± {:.3} mm (ref {:.2} ± {:.2}), f = {:?} Hz (ref {REF_UY_FREQ}); drag {:.2} ± {:.2} (ref {REF_DRAG_MEAN} ± 73.75), lift {:.2} ± {:.2} (ref 0.88 ± {REF_LIFT_AMP}); onset amp {:.3e} → {:.3e} m; rigid drag {:.2}", config.ny, config.flag_nx, m.dt, config.subcycle, config.sweeps, config.max_rounds, config.coupler, m.coupled_steps, m.elapsed, m.mean_subiterations, m.max_subiterations, m.stalled_steps, m.retried_steps, m.worst_stall, m.worst_conservation, r.faces_used, m.newton_rescues, r.rounds_mean, r.reclassified_mean, r.fresh_mean, r.regen_count, r.regen_seconds, r.fluid_seconds, r.structure_seconds, r.death, w.t_start, config.t_end, w.uy_mid * 1e3, w.uy_amp * 1e3, REF_UY_AMP * 1e3, w.ux_mid * 1e3, w.ux_amp * 1e3, REF_UX_MEAN * 1e3, REF_UX_AMP * 1e3, w.frequency, w.drag_mid, w.drag_amp, w.lift_mid, w.lift_amp, w.amp_early, w.amp_late, m.rigid_drag ); assert!(m.final_state_finite, "the flag's state is not finite"); assert!(r.death.is_none(), "the coupling died: {:?}", r.death); } /// `RTX_FSI2O_PROBE_INSTANT=dir`: the P5-2 warm replica died at t = 10.99 /// s (tip −35 mm) with "acceptor p donor cell not active". From the /// instant, extrapolate the interface along its velocity and find where /// the overlap first refuses — with the warm chain from the instant's /// mesh and with cold builds — reporting the acceptor, its donor cell's /// class, and the patch's thickness there. #[test] fn fsi2_overset_probe_death_from_instant() { use rtx_cfd::mesh::patch_gen::{ cylinder_flag_patch_deformed, cylinder_flag_patch_deformed_from, }; use rtx_cfd::solvers::incompressible::{CellClass, OverlapMap}; let Ok(dir) = std::env::var("RTX_FSI2O_PROBE_INSTANT") else { return; }; let dir = std::path::Path::new(&dir); let case = case_from_env("FSI2O", FSI2); let (fluid, d, t) = fsi2_harness::overset::OversetFluid::from_instant(case, 41, 35, dir).expect("instant"); let dd: Vec = { let bytes = std::fs::read(dir.join("patch_dd.bin")).expect("dd"); bytes .chunks_exact(8) .map(|c| f64::from_le_bytes(c.try_into().unwrap())) .collect() }; let h = fluid.h; let nx = fluid.nx; let tip_y = d[2 * fluid.interface.tip[fluid.interface.tip.len() / 2] + 1]; println!( " instant t = {t:.4}: tip uy {tip_y:+.4} m, hole {} fringe {} acceptors {}", fluid.solver.overlap().hole_cells(), fluid.solver.overlap().fringe_count(), fluid.solver.overlap().acceptors.len() ); let mut warm_prev = fluid.solver.patch().mesh().clone(); for k in 0..=12 { let tau = 0.005 * k as f64; let dk: Vec = d.iter().zip(&dd).map(|(a, v)| a + v * tau).collect(); let edges = fluid.interface.edges(&dk); let fillet = 0.5 * 0.41 / 41.0; let cold = cylinder_flag_patch_deformed( [0.2, 0.2], 0.05, 0.01, &edges, 0.6, h, fillet, 6.0 * h, 12, 4.0, 100, ) .expect("cold"); let warm = cylinder_flag_patch_deformed_from( Some(&warm_prev), [0.2, 0.2], 0.05, 0.01, &edges, 0.6, h, fillet, 6.0 * h, 12, 4.0, 20, ) .expect("warm"); let tip_now = dk[2 * fluid.interface.tip[fluid.interface.tip.len() / 2] + 1]; for (name, mesh) in [("cold", &cold.0), ("warm", &warm.0)] { let valid = mesh.validate(80.0).err(); let r = OverlapMap::build(mesh, nx, 41, h, h, 4); match r { Ok(map) => println!( " τ = {tau:.3} tip {tip_now:+.4}: {name} valid {:?}, overlap ok (hole {} fringe {})", valid.is_none(), map.hole_cells(), map.fringe_count() ), Err(e) => { let msg = format!("{e:?}"); // The failing acceptor's geometry, and the overlap depth // that would build. let mut extra = String::new(); if let Some(a0) = msg.find("acceptor ") { let k: usize = msg[a0 + 9..] .split(' ') .next() .unwrap() .parse() .unwrap(); let c = mesh.cell(mesh.nn() - 1, k); let ac = mesh.centre(c); let inner = mesh.node_xy(mesh.node(0, k)); let outer = mesh.node_xy(mesh.node(mesh.nn(), k)); let r8 = mesh.node_xy(mesh.node(8, k)); let thick = ((outer[0] - inner[0]).powi(2) + (outer[1] - inner[1]).powi(2)).sqrt(); let hole_depth = ((r8[0] - inner[0]).powi(2) + (r8[1] - inner[1]).powi(2)).sqrt(); let mut rows_ok = Vec::new(); for rows in [5usize, 6, 7, 8] { if OverlapMap::build(mesh, nx, 41, h, h, rows).is_ok() { rows_ok.push(rows); } } extra = format!( " — acceptor {k} centre ({:.3}, {:.3}); its ray: wall node ({:.3}, {:.3}), outer ({:.3}, {:.3}), thickness {:.2} h, hole depth {:.2} h, band {:.2} h; overlap_rows that build: {rows_ok:?}", ac[0], ac[1], inner[0], inner[1], outer[0], outer[1], thick / h, hole_depth / h, (thick - hole_depth) / h ); } if let Some(i0) = msg.find("cell (") { let coords: Vec = msg[i0 + 6..] .split(')') .next() .unwrap() .split(',') .map(|s| s.trim().parse().unwrap()) .collect(); let (j, i) = (coords[0], coords[1]); let (x, y) = ((i as f64 + 0.5) * h, (j as f64 + 0.5) * h); // The nearest inner-ring node and the patch thickness along its ray. let mut best = (f64::INFINITY, 0usize); for s in 0..=mesh.ns() { let p = mesh.node_xy(mesh.node(0, s)); let dd2 = (p[0] - x).powi(2) + (p[1] - y).powi(2); if dd2 < best.0 { best = (dd2, s); } } let s = best.1; let inner = mesh.node_xy(mesh.node(0, s)); let outer = mesh.node_xy(mesh.node(mesh.nn(), s)); let r8 = mesh.node_xy(mesh.node(8, s)); let thick = ((outer[0] - inner[0]).powi(2) + (outer[1] - inner[1]).powi(2)).sqrt(); let hole_depth = ((r8[0] - inner[0]).powi(2) + (r8[1] - inner[1]).powi(2)).sqrt(); extra += &format!( " — cell ({j}, {i}) at ({x:.3}, {y:.3}); nearest wall node s = {s} at ({:.3}, {:.3}); ray thickness {:.3} h, hole depth (row 8) {:.3} h, overlap band {:.3} h; outer node ({:.3}, {:.3})", inner[0], inner[1], thick / h, hole_depth / h, (thick - hole_depth) / h, outer[0], outer[1] ); let _ = CellClass::Hole; } println!( " τ = {tau:.3} tip {tip_now:+.4}: {name} valid {:?}, overlap REFUSED: {msg}{extra}", valid.is_none() ); } } } warm_prev = warm.0; } }