//! P5 (`docs/overset_metal_campaign.md` §5.12): the FSI2 harness's FLUID //! SIDE on the overset — the background without a body, the cylinder–flag //! O-grid regenerated around the deformed flag every time the interface //! moves (`set_patch_mesh`: the overlap rebuilt, fresh cells refilled, the //! fringe re-stamped, the flux balance on), the patch's wall velocity from //! the interface velocities (nearest wetted segment, linear along it), and //! the load from the patch's wall faces (pressure + full-stress traction //! per face into `WettedSurface::transfer_load`) — no probes, no spike //! clamp, no smoothing. The structure side is the harness's, unchanged. use std::cell::Cell; use std::sync::{Arc, RwLock}; use nalgebra::Vector3; use rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed; use rtx_cfd::mesh::PatchSide; use rtx_cfd::solvers::incompressible::{ AleBoundaries, ConvectionScheme, CurvilinearParameters, CurvilinearPisoSolver, EmbeddedParameters, EmbeddedPisoSolver, FlowField, MgPrecision, NormalDiffusion, OversetField, OversetParameters, OversetPisoSolver, OversetResult, OversetSolverState, PatchConvection, PatchField, PoissonSolverKind, SideBoundary, }; use rtx_cfd::{CfdConfig, CfdResult}; use rtx_fea::mesh::{Mesh, NodeId}; use rtx_fsi::{FluidFace, WettedSurface}; use super::{flag_mesh, inflow_for, BenchmarkCase, Interface, FLAG_X1, H, L, NU_F, RHO_F}; const CYL_CENTRE: [f64; 2] = [0.2, 0.2]; const CYL_R: f64 = 0.05; const FLAG_T: f64 = 0.01; /// The junction fillet: a fixed 5 mm at every resolution (§5.11). const FILLET: f64 = 0.5 * 0.41 / 41.0; const PATCH_ROWS: usize = 12; const PATCH_STRETCH: f64 = 4.0; /// The deforming wall as the patch sees it: the wetted polygon (the /// `Interface` walk, anchors included) and the velocity at each vertex. #[derive(Debug, Clone, Default)] pub struct WallMotion { pub polygon: Vec<[f64; 2]>, pub velocity: Vec<[f64; 2]>, /// The net normal velocity removed from every segment (the imposed /// velocity's volume flux over the wall length) when the wall is /// made volume-preserving; zero otherwise. pub q: f64, } impl WallMotion { /// The imposed velocity's net volume flux INTO the fluid over the /// polygon (counter-clockwise around the body: outward normal /// `(dy, −dx)/L`), and the polygon's length. pub fn net_flux(&self) -> (f64, f64) { let (mut flux, mut len) = (0.0, 0.0); for i in 0..self.polygon.len().saturating_sub(1) { let (a, b) = (self.polygon[i], self.polygon[i + 1]); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let (va, vb) = (self.velocity[i], self.velocity[i + 1]); let (vx, vy) = (0.5 * (va[0] + vb[0]), 0.5 * (va[1] + vb[1])); flux += vx * dy - vy * dx; len += (dx * dx + dy * dy).sqrt(); } (flux, len) } /// The polygon's enclosed area (shoelace; the walk is closed across /// the cylinder by its anchors, so this is the flag's area up to a /// constant). pub fn area(&self) -> f64 { let n = self.polygon.len(); let mut a = 0.0; for i in 0..n { let (p, q) = (self.polygon[i], self.polygon[(i + 1) % n]); a += p[0] * q[1] - q[0] * p[1]; } 0.5 * a } } impl WallMotion { /// Velocity at `(x, y)`: linear along the nearest polygon segment. pub fn velocity_at(&self, x: f64, y: f64) -> (f64, f64) { let n = self.polygon.len(); if n < 2 { return (0.0, 0.0); } let (mut best_d, mut best) = (f64::INFINITY, (0.0, 0.0)); for i in 0..n - 1 { let (a, b) = (self.polygon[i], self.polygon[i + 1]); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let l2 = dx * dx + dy * dy; let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; let (px, py) = (a[0] + t * dx, a[1] + t * dy); let d = (px - x).powi(2) + (py - y).powi(2); if d < best_d { best_d = d; let (va, vb) = (self.velocity[i], self.velocity[i + 1]); let l = l2.sqrt().max(1e-300); best = ( va[0] + t * (vb[0] - va[0]) - self.q * dy / l, va[1] + t * (vb[1] - va[1]) + self.q * dx / l, ); } } best } } /// The overset fluid of the coupled march. pub struct OversetFluid { pub case: BenchmarkCase, pub mesh: Mesh, pub interface: Interface, pub a_node: NodeId, pub ny: usize, pub nx: usize, pub h: f64, pub mu: f64, pub dt_fluid: f64, pub solver: OversetPisoSolver, pub field: OversetField, pub shared: Arc>, pub sweeps: usize, /// Patch regenerations and their wall time. pub regen_count: Cell, pub regen_seconds: Cell, /// Background cells reclassified, summed over every fluid step. pub reclassified_total: Cell, pub fresh_total: Cell, pub rounds_total: Cell, pub correctors_total: Cell, /// The interface of the last `set_geometry`. pub last_d: Vec, /// The last fluid step's mass defects (patch acceptor ring, background /// fringe) and the patch's max divergence. pub last_defects: Cell<(f64, f64, f64)>, } impl OversetFluid { /// Build the composite at rest around the undeformed flag. pub fn build_case( case: BenchmarkCase, ny: usize, flag_nx: usize, sweeps: usize, max_rounds: usize, ) -> CfdResult { let h = H / ny as f64; let nx = (L / h).round() as usize; let mu = RHO_F * NU_F; let u_mean = case.u_mean; let u_peak = 1.5 * 1.5 * u_mean; let mesh = flag_mesh(flag_nx, 2); let interface = Interface::build(&mesh); let a_node = mesh .nodes .iter() .find(|(_, n)| { (n.position().x - 0.6).abs() < 1e-9 && (n.position().y - 0.2).abs() < 1e-9 }) .map(|(&id, _)| id) .expect("point A"); let zero_d = vec![0.0; 2 * interface.wetted.len()]; let config = CfdConfig::new() .with_density(RHO_F) .with_viscosity(mu) .with_reference_velocity(u_mean) .with_reference_length(0.1); let mut background = EmbeddedPisoSolver::new( config.clone(), EmbeddedParameters { corrector_steps: 2, tolerance: 1e-7, boundaries: AleBoundaries { left: SideBoundary::Velocity, right: SideBoundary::PressureOutlet, bottom: SideBoundary::Velocity, top: SideBoundary::Velocity, }, poisson_solver: PoissonSolverKind::Multigrid, poisson_precision: MgPrecision::F64, convection_scheme: ConvectionScheme::TvdVanAlbada, }, )?; background.set_boundary_velocity(move |x, y, t| { if x <= 0.0 { (inflow_for(u_mean, y, t), 0.0) } else { (0.0, 0.0) } }); let (patch_mesh, _) = cylinder_flag_patch_deformed( CYL_CENTRE, CYL_R, FLAG_T, &interface.edges(&zero_d), FLAG_X1, h, FILLET, 6.0 * h, PATCH_ROWS, PATCH_STRETCH, sweeps, )?; // The patch's along-body explicit limit (its wall row is // line-implicit) beside the background's combined criterion. let mut hs = f64::INFINITY; for c in 0..patch_mesh.cell_count() { for (f, _) in patch_mesh.cell_faces(c) { if patch_mesh.is_sface(f) { let d = patch_mesh.faces()[f].d; hs = hs.min((d[0] * d[0] + d[1] * d[1]).sqrt()); } } } let dt_bg = 0.25 / (2.0 * u_peak / h + 4.0 * NU_F / (h * h)); let dt_patch = 0.4 * (hs * hs / (4.0 * NU_F)).min(hs / u_peak); let dt_fluid = dt_bg.min(dt_patch); let shared = Arc::new(RwLock::new(WallMotion { polygon: interface .polygon(&zero_d) .iter() .map(|&(x, y)| [x, y]) .collect(), velocity: vec![[0.0, 0.0]; interface.walk.len()], q: 0.0, })); let wall = shared.clone(); let mut patch = CurvilinearPisoSolver::new( config, CurvilinearParameters { tolerance: 1e-5, convection: PatchConvection::TvdVanAlbada, normal_diffusion: NormalDiffusion::LineImplicit, ..CurvilinearParameters::default() }, patch_mesh, )?; // Discriminators (P5-1 matrix): `RTX_FSI2O_NO_WALL_VEL` keeps the // wall at rest in the fluid while the geometry moves. let no_wall_vel = std::env::var("RTX_FSI2O_NO_WALL_VEL").is_ok(); patch.set_side_velocity(PatchSide::Inner, move |x, y, _| { if no_wall_vel { (0.0, 0.0) } else { wall.read().unwrap().velocity_at(x, y) } }); let mut patch_field = PatchField::new(patch.mesh()); patch.initialize(&mut patch_field, |_, _| (0.0, 0.0)); let params = OversetParameters { stall_rounds: 2, max_rounds, ..OversetParameters::default() }; let mut solver = OversetPisoSolver::new(background, patch, (nx, ny, h, h), params)?; let mut field = OversetField { background: FlowField::new(nx, ny, h, h)?, patch: patch_field, }; solver.initialize(&mut field)?; Ok(Self { case, mesh, interface, a_node, ny, nx, h, mu, dt_fluid, solver, field, shared, sweeps, regen_count: Cell::new(0), regen_seconds: Cell::new(0.0), reclassified_total: Cell::new(0), fresh_total: Cell::new(0), rounds_total: Cell::new(0), correctors_total: Cell::new(0), last_d: zero_d, last_defects: Cell::new((0.0, 0.0, 0.0)), }) } /// Save the fluid state (`RTX_FSI2O_SAVE=dir`, tag `fsi2o_ny{ny}`): /// the background as `FlowField::save`, the patch vectors raw, the /// time. pub fn save(&self, dir: &str) -> CfdResult<()> { let dir = std::path::Path::new(dir); std::fs::create_dir_all(dir).expect("save dir"); let tag = format!("fsi2o_ny{}", self.ny); self.field .background .save(&dir.join(format!("bg_{tag}.bin")))?; for (name, vals) in [ ("u", &self.field.patch.u), ("v", &self.field.patch.v), ("p", &self.field.patch.p), ("flux", &self.field.patch.flux), ] { let bytes: Vec = vals.iter().flat_map(|x| x.to_le_bytes()).collect(); std::fs::write(dir.join(format!("patch_{tag}_{name}.bin")), bytes).expect("save patch"); } std::fs::write( dir.join(format!("time_{tag}.txt")), format!("{:.17e}", self.solver.time()), ) .expect("save time"); println!( " saved the fluid state {tag} at t = {:.4} to {}", self.solver.time(), dir.display() ); Ok(()) } /// Load a state saved by [`Self::save`] (same ny, undeformed patch); /// returns its time. pub fn load(&mut self, dir: &str) -> CfdResult { let dir = std::path::Path::new(dir); let tag = format!("fsi2o_ny{}", self.ny); self.field.background = FlowField::load(&dir.join(format!("bg_{tag}.bin")))?; let read = |name: &str| -> Vec { let bytes = std::fs::read(dir.join(format!("patch_{tag}_{name}.bin"))) .unwrap_or_else(|e| panic!("load patch {name}: {e}")); bytes .chunks_exact(8) .map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes"))) .collect() }; self.field.patch.u = read("u"); self.field.patch.v = read("v"); self.field.patch.p = read("p"); self.field.patch.flux = read("flux"); let t: f64 = std::fs::read_to_string(dir.join(format!("time_{tag}.txt"))) .expect("time") .trim() .parse() .expect("time value"); self.solver.set_time(t); println!( " loaded the fluid state {tag} at t = {t:.4} from {}", dir.display() ); Ok(t) } /// The patch around the interface `d`. pub fn patch_for(&self, d: &[f64]) -> CfdResult { let start = std::time::Instant::now(); let (mesh, _) = cylinder_flag_patch_deformed( CYL_CENTRE, CYL_R, FLAG_T, &self.interface.edges(d), FLAG_X1, self.h, FILLET, 6.0 * self.h, PATCH_ROWS, PATCH_STRETCH, self.sweeps, )?; self.regen_count.set(self.regen_count.get() + 1); self.regen_seconds .set(self.regen_seconds.get() + start.elapsed().as_secs_f64()); Ok(mesh) } /// The wall for the next fluid step: geometry `d`, velocity `ddot`. pub fn set_geometry(&mut self, d: &[f64], ddot: &[f64]) -> CfdResult<()> { { let mut w = self.shared.write().unwrap(); w.polygon = self .interface .polygon(d) .iter() .map(|&(x, y)| [x, y]) .collect(); w.velocity = self .interface .walk_velocities(ddot) .iter() .map(|&(u, v)| [u, v]) .collect(); // `RTX_FSI2O_VOLUME_PRESERVE`: the fluid sees a volume-preserving // wall — the imposed velocity's net flux (the flag's thickness // breathing under the pressure step, a ~300 Hz mode of the // ν = 0.4 solid) is removed uniformly along the wall. w.q = 0.0; if std::env::var("RTX_FSI2O_VOLUME_PRESERVE").is_ok() { let (flux, len) = w.net_flux(); w.q = flux / len.max(1e-300); } } // `RTX_FSI2O_FREEZE_PATCH`: the undeformed patch throughout (the // wall velocity still moves) — is the regeneration the driver? if std::env::var("RTX_FSI2O_FREEZE_PATCH").is_ok() { self.last_d = d.to_vec(); return Ok(()); } let mesh = match self.patch_for(d) { Ok(m) => m, Err(e) => { self.dump_edges(d, &format!("generator refused ({e:?})")); return Err(e); } }; self.last_d = d.to_vec(); self.solver.set_patch_mesh(mesh) } /// The last geometry, for the offline reproduction /// (`patch_cylinder_flag_deformed.rs`, `RTX_CF_EDGES_FILE`): one edge /// per block, `x y` per line, when `RTX_FSI2O_DUMP_DIR` is set. fn dump_edges(&self, d: &[f64], why: &str) { let Ok(dir) = std::env::var("RTX_FSI2O_DUMP_DIR") else { return; }; let e = self.interface.edges(d); let mut out = String::new(); for (name, pts) in [("bottom", &e.bottom), ("tip", &e.tip), ("top", &e.top)] { out.push_str(&format!("# {name} {}\n", pts.len())); for p in pts { out.push_str(&format!("{:.17e} {:.17e}\n", p[0], p[1])); } } let path = std::path::Path::new(&dir).join("p5_death_edges.txt"); std::fs::write(&path, out).expect("dump edges"); println!(" {why}; edges dumped to {}", path.display()); } /// One fluid step at the current wall. pub fn step(&mut self) -> CfdResult { let r = match futures::executor::block_on( self.solver.advance(&mut self.field, self.dt_fluid), ) { Ok(r) => r, Err(e) => { // The overlap is rebuilt inside `advance`: a refused patch // surfaces here, with the last geometry. let d = self.last_d.clone(); self.dump_edges(&d, &format!("advance refused ({e:?})")); return Err(e); } }; self.last_defects.set(( r.patch_mass_defect, r.background_mass_defect, r.patch_max_divergence, )); self.reclassified_total .set(self.reclassified_total.get() + r.reclassified_cells); self.fresh_total.set(self.fresh_total.get() + r.fresh_cells); self.rounds_total .set(self.rounds_total.get() + r.rounds.iter().sum::()); self.correctors_total .set(self.correctors_total.get() + r.rounds.len()); Ok(r) } /// `subcycle` fluid substeps from the current state with the interface /// interpolated from `d_n` to `d_candidate` (the harness's /// `advance_subcycled`, digit for digit in the kinematics). pub fn advance_subcycled( &mut self, d_n: &[f64], d_candidate: &[f64], subcycle: usize, v_n: Option<&[f64]>, ) -> CfdResult<()> { let dt = self.dt_fluid * subcycle as f64; let mean_velocity: Vec = d_candidate .iter() .zip(d_n) .map(|(new, old)| (new - old) / dt) .collect(); for m in 1..=subcycle { let fraction = m as f64 / subcycle as f64; let (d_sub, ddot_sub): (Vec, Vec) = match v_n { None => ( d_n.iter() .zip(d_candidate) .map(|(old, new)| old + fraction * (new - old)) .collect(), mean_velocity.clone(), ), Some(v_start) => { let mut d_sub = Vec::with_capacity(d_n.len()); let mut ddot_sub = Vec::with_capacity(d_n.len()); for k in 0..d_n.len() { let v_end = 2.0 * mean_velocity[k] - v_start[k]; let accel = (v_end - v_start[k]) / dt; let tau = fraction * dt; d_sub.push(d_n[k] + v_start[k] * tau + 0.5 * accel * tau * tau); ddot_sub.push(v_start[k] + accel * tau); } (d_sub, ddot_sub) } }; self.set_geometry(&d_sub, &ddot_sub)?; self.step()?; } Ok(()) } /// Drag and lift on cylinder + flag from the patch's wall stress. pub fn measure_force(&self) -> (f64, f64) { let f = self .solver .patch() .surface_force(&self.field.patch, PatchSide::Inner, self.solver.time()) .total(); (f[0], f[1]) } /// The wall faces' tractions transferred to the flag's wetted nodes /// at geometry `d`: `(nodal forces, conservation defect, faces used)`. /// Faces on the cylinder proper are skipped; the fillets' load goes /// to the nearest (clamped) root nodes. pub fn sample_load(&self, d: &[f64]) -> (Vec<(NodeId, Vector3)>, f64, usize) { let mut faces = Vec::new(); let mut tractions: Vec> = Vec::new(); for (centre, normal, len, traction) in self.solver.patch().wall_tractions( &self.field.patch, PatchSide::Inner, self.solver.time(), ) { let on_cylinder = ((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)).sqrt() < CYL_R + 1e-9; if on_cylinder { continue; } faces.push(FluidFace { centroid: Vector3::new(centre[0], centre[1], 0.0), normal: Vector3::new(normal[0], normal[1], 0.0), area: len, }); tractions.push(Vector3::new(traction[0], traction[1], 0.0)); } let nodes_now = self.interface.deformed_nodes(d); let surface = WettedSurface::build(&faces, &nodes_now).expect("transfer build"); let nodal = surface.transfer_load(&faces, &tractions).unwrap(); let total_sampled: Vector3 = faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum(); let total_nodal: Vector3 = nodal.iter().sum(); let conservation = (total_nodal - total_sampled).norm() / total_sampled.norm().max(1e-30); ( self.interface .wetted .iter() .zip(nodal) .map(|(&id, f)| (id, f)) .collect(), conservation, faces.len(), ) } /// The wall pressure's roughness along the flag: over the flag's /// wall faces in order, `Σ|t_n(f+1) − t_n(f)| / Σ|t_n(f)|` with `t_n` /// the normal traction, and the count of sign changes of the /// face-to-face difference — a checkerboard reads ≫ 1 with a sign /// change at every face. pub fn wall_roughness(&self) -> (f64, usize, f64, f64) { let mut tn: Vec = Vec::new(); for (centre, normal, _, traction) in self.solver .patch() .wall_tractions(&self.field.patch, PatchSide::Inner, self.solver.time()) { let on_cylinder = ((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)) .sqrt() < CYL_R + 1e-9; if !on_cylinder { tn.push(traction[0] * normal[0] + traction[1] * normal[1]); } } let total: f64 = tn.iter().map(|v| v.abs()).sum(); let mut jumps = 0.0; let mut flips = 0usize; let mut prev_diff = 0.0; for w in tn.windows(2) { let d = w[1] - w[0]; jumps += d.abs(); if prev_diff * d < 0.0 { flips += 1; } prev_diff = d; } let max = tn.iter().fold(0.0_f64, |m, v| m.max(v.abs())); (jumps / total.max(1e-300), flips, max, total / tn.len().max(1) as f64) } /// The patch's pressure level (mean over its cells) and the wall's /// net volume flux from the imposed velocity (`Σ u_wall · S_out` over /// the Inner faces, positive = fluid leaving the patch through the /// wall), against the inner ring's enclosed-area rate would be the /// mesh's own; both must match for a consistent moving wall. pub fn level_and_wall_flux(&self) -> (f64, f64, f64, f64, f64) { let mesh = self.solver.patch().mesh(); let p = &self.field.patch.p; let level = p.iter().sum::() / p.len().max(1) as f64; let wall = self.shared.read().unwrap(); let (poly_flux, _) = wall.net_flux(); let poly_area = wall.area(); let (mut flux, mut area) = (0.0, 0.0); for (f, face) in mesh.faces().iter().enumerate() { if mesh.side(f) != Some(PatchSide::Inner) { continue; } // Inner side: S points from the body into the fluid (the // neighbour is the cell), so −S is out of the fluid. let sign = if face.neigh.is_some() { 1.0 } else { -1.0 }; let s_in = [sign * face.s[0], sign * face.s[1]]; let (u, v) = wall.velocity_at(face.centre[0], face.centre[1]); // Volume flux INTO the fluid = u_wall · S_into_fluid. flux += u * s_in[0] + v * s_in[1]; area += (s_in[0] * s_in[0] + s_in[1] * s_in[1]).sqrt(); } (level, flux, area, poly_flux, poly_area) } pub fn snapshot(&self) -> (OversetSolverState, OversetField) { (self.solver.snapshot(), self.field.clone()) } pub fn restore(&mut self, saved: &(OversetSolverState, OversetField)) { self.solver.restore(&saved.0); self.field = saved.1.clone(); } pub fn time(&self) -> f64 { self.solver.time() } }