//! A-P2, step S5 (`docs/overset_metal_campaign.md` §5.9): the overset with a //! MOVING patch. //! //! 1. A stationary patch pushed through `set_patch_mesh` every step is the //! static overset path to the bit (the overlap rebuild and the fringe //! re-stamping reproduce the same numbers). //! 2. The phantom circle and its patch translating through the steady //! manufactured field (the `embedded_moving.rs` pattern: the wall carries //! the exact velocity, so the exact solution is unchanged while the //! hole, fringe and acceptors sweep the background) keep the time-max //! L2 on both meshes within 1.5× the static level; reclassification //! counts are printed. //! 3. Snapshot/restore with a pending patch mesh re-runs bit-identically. mod overset_common; use overset_common::{build, errors, p2_patch}; use rtx_cfd::CfdResult; use rtx_cfd::mesh::PatchMesh; use rtx_cfd::mesh::patch_gen::annulus_skewed; use rtx_cfd::solvers::incompressible::OversetField; fn fields_identical(a: &OversetField, b: &OversetField) -> bool { let bits = |v: &[f64], w: &[f64]| v.iter().zip(w).all(|(x, y)| x.to_bits() == y.to_bits()); bits(a.background.u.as_slice(), b.background.u.as_slice()) && bits(a.background.v.as_slice(), b.background.v.as_slice()) && bits(a.background.p.as_slice(), b.background.p.as_slice()) && bits(&a.patch.u, &b.patch.u) && bits(&a.patch.v, &b.patch.v) && bits(&a.patch.p, &b.patch.p) && bits(&a.patch.flux, &b.patch.flux) } fn max_diff(a: &OversetField, b: &OversetField) -> f64 { let d = |v: &[f64], w: &[f64]| { v.iter() .zip(w) .map(|(x, y)| (x - y).abs()) .fold(0.0, f64::max) }; d(a.background.u.as_slice(), b.background.u.as_slice()) .max(d(a.background.v.as_slice(), b.background.v.as_slice())) .max(d(a.background.p.as_slice(), b.background.p.as_slice())) .max(d(&a.patch.u, &b.patch.u)) .max(d(&a.patch.v, &b.patch.v)) .max(d(&a.patch.p, &b.patch.p)) .max(d(&a.patch.flux, &b.patch.flux)) } /// The P2 patch translated to centre `(cx, cy)`. fn translated_patch(n: usize, cx: f64, cy: f64) -> CfdResult { annulus_skewed([cx, cy], 0.2, 0.354, 9 * n / 4, n / 4, 0.3, 3.0) } fn time_step(solver: &rtx_cfd::solvers::incompressible::OversetPisoSolver, n: usize) -> f64 { let h = 1.0 / n as f64; let mut hp = f64::INFINITY; for c in 0..solver.patch().mesh().cell_count() { for (f, _) in solver.patch().mesh().cell_faces(c) { let d = solver.patch().mesh().faces()[f].d; hp = hp.min((d[0] * d[0] + d[1] * d[1]).sqrt()); } } 0.4 * (hp * hp / (4.0 * 0.05)).min(h) } #[tokio::test] async fn stationary_patch_through_the_moving_path_is_bit_identical() -> CfdResult<()> { let n = 32; let (mut plain, mut f_plain) = build(n, p2_patch(n)?, 1e-3)?; let (mut moving, mut f_moving) = build(n, p2_patch(n)?, 1e-3)?; let dt = time_step(&plain, n); for _ in 0..20 { plain.advance(&mut f_plain, dt).await?; let same = moving.patch().mesh().clone(); moving.set_patch_mesh(same)?; let r = moving.advance(&mut f_moving, dt).await?; assert_eq!(r.reclassified_cells, 0); } assert_eq!(plain.time().to_bits(), moving.time().to_bits()); assert!( fields_identical(&f_plain, &f_moving), "moving path with a stationary patch differs from the static path by {:.3e}", max_diff(&f_plain, &f_moving) ); println!(" stationary patch through the moving overset path — bit-identical over 20 steps"); Ok(()) } #[tokio::test] async fn translating_phantom_circle_keeps_the_static_error_level() -> CfdResult<()> { let n: usize = std::env::var("RTX_OVERSET_N") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(32); let h = 1.0 / n as f64; let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?; let dt = time_step(&solver, n); // Static phase to the steady state. let mut steady = f64::INFINITY; for _ in 0..400_000 { let before = (field.background.u.clone(), field.patch.u.clone()); solver.advance(&mut field, dt).await?; let change = (&field.background.u - &before.0).abs().max().max( field .patch .u .iter() .zip(&before.1) .map(|(a, b)| (a - b).abs()) .fold(0.0, f64::max), ); steady = change / dt; if steady < 1e-6 { break; } } assert!(steady < 1e-6, "no static steady state: {steady:.3e}"); let (static_bg, static_patch) = errors(&solver, &field, n); println!(" static level n = {n}: L2 background {static_bg:.4e}, patch {static_patch:.4e}"); // Moving phase: the circle and its patch translate at speed 0.3 to the // left through ~4.5 background cells (per step ≤ 0.01 h). let speed = 0.3; let t0 = solver.time(); let distance = 4.5 * h; let steps = (distance / (speed * dt)).ceil() as usize; let (mut worst_bg, mut worst_patch) = (0.0_f64, 0.0_f64); let (mut reclassified, mut fresh) = (0usize, 0usize); let mut max_rounds = 0usize; for step in 0..steps { let t_new = solver.time() + dt; let cx = 0.6 - speed * (t_new - t0); solver.set_patch_mesh(translated_patch(n, cx, 0.45)?)?; let r = solver.advance(&mut field, dt).await?; reclassified += r.reclassified_cells; fresh += r.fresh_cells; max_rounds = max_rounds.max(r.rounds.iter().copied().max().unwrap_or(0)); let (eb, ep) = errors(&solver, &field, n); worst_bg = worst_bg.max(eb); worst_patch = worst_patch.max(ep); if std::env::var("RTX_OVERSET_TRACE").is_ok() && step % 200 == 0 { println!( " moving step {step}: cx {cx:.4} L2 bg {eb:.4e} patch {ep:.4e} reclassified {} fresh {} rounds {:?} defect bg {:.2e}", r.reclassified_cells, r.fresh_cells, r.rounds, r.background_mass_defect / r.overlap_flux_scale.max(1e-300) ); } } println!( " moving n = {n}: {steps} steps, {distance:.3} travelled; time-max L2 background {worst_bg:.4e} ({:.2}x static), \ patch {worst_patch:.4e} ({:.2}x static); reclassified {reclassified} cells, fresh {fresh}; max Schwarz rounds {max_rounds}", worst_bg / static_bg, worst_patch / static_patch ); assert!( worst_bg < 1.5 * static_bg, "background error rose to {worst_bg:.3e} (static {static_bg:.3e})" ); assert!( worst_patch < 1.5 * static_patch, "patch error rose to {worst_patch:.3e} (static {static_patch:.3e})" ); Ok(()) } #[tokio::test] async fn snapshot_restore_with_a_pending_mesh_is_bit_identical() -> CfdResult<()> { let n = 32; let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?; let dt = time_step(&solver, n); let speed = 0.3; let mut step = 0usize; let cx = |s: usize| 0.6 - speed * s as f64 * dt; for _ in 0..5 { step += 1; solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?; solver.advance(&mut field, dt).await?; } solver.set_patch_mesh(translated_patch(n, cx(step + 1), 0.45)?)?; let saved = solver.snapshot(); let field_saved = field.clone(); let step_saved = step; for _ in 0..10 { step += 1; if step > step_saved + 1 { solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?; } solver.advance(&mut field, dt).await?; } let reference = field.clone(); let t_ref = solver.time(); solver.restore(&saved); field = field_saved; step = step_saved; for _ in 0..10 { step += 1; if step > step_saved + 1 { solver.set_patch_mesh(translated_patch(n, cx(step), 0.45)?)?; } solver.advance(&mut field, dt).await?; } assert_eq!(solver.time().to_bits(), t_ref.to_bits()); assert!( fields_identical(&field, &reference), "re-run on the moving overset differs by {:.3e}", max_diff(&field, &reference) ); println!(" snapshot/restore with a pending patch mesh — bit-identical re-run over 10 steps"); Ok(()) } /// One step with the patch jumped by two background cells must reclassify /// background cells (the overlap follows the pending mesh). #[tokio::test] async fn a_translated_patch_reclassifies_the_background() -> CfdResult<()> { let n = 32; let (mut solver, mut field) = build(n, p2_patch(n)?, 1e-3)?; let dt = time_step(&solver, n); solver.advance(&mut field, dt).await?; let before = solver.overlap().hole_cells(); solver.set_patch_mesh(translated_patch(n, 0.6 - 2.0 / n as f64, 0.45)?)?; let r = solver.advance(&mut field, dt).await?; println!( " 2 h jump: reclassified {}, fresh {}, holes {} -> {}", r.reclassified_cells, r.fresh_cells, before, solver.overlap().hole_cells() ); assert!( r.reclassified_cells > 0, "the overlap did not follow the patch" ); Ok(()) }