//! The fresh-cell falsifier (omni-cortex `docs/fresh_cell_gcl_campaign.md` //! phase 0): Seo & Mittal's oscillating-body test on this embedded solver, //! with the flag's own dimensions and the FSI2 grid and time step. //! //! A rigid plate 0.35 × 0.02 m (the Turek–Hron flag) oscillates //! transversely in still fluid with the flag's tip speed (1 m/s peak, //! 80 mm amplitude) on h = 1/152 ≈ 6.6 mm (FSI2's ny = 62 spacing) at //! dt = 3.24e-4 (FSI2 s = 1's coupled step). Per fluid step the surface //! force is sampled exactly as the coupling samples it (`traction_at` //! over the plate's surface at 0.5 h spacing) and the fresh-cell count //! is recorded. The falsifier's registered predictions: //! //! (a) force spikes (force minus its 21-step running median) coincide //! with fresh-cell creation events; //! (b) their RMS scales like (Δt)^−0.8 ± 0.2 across dt, dt/2, dt/4 — //! the published exponent of the spurious volume source //! (ΔV/Δt)|1 − CFL_b| that a binary-mask projection injects; //! (c) the plate at rest shows neither (the sampler's own floor). //! //! Default run: dt only (seconds); `RTX_FRESHCELL_LADDER=1` runs the //! three time steps and prints the fitted exponent; //! `RTX_FRESHCELL_CSV=` dumps per-step records. use rtx_cfd::solvers::incompressible::{ EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FlowField, PoissonSolverKind, }; use rtx_cfd::{CfdConfig, CfdResult}; use std::io::Write as _; const RHO: f64 = 1000.0; const MU: f64 = 1.0; // nu = 1e-3, the FSI2 fluid const N: usize = 152; // h = 6.58 mm ≈ FSI2's 0.41 / 62 const DT_FSI2: f64 = 3.24e-4; const HX: f64 = 0.175; // plate half-length (0.35 m) const HY: f64 = 0.01; // plate half-thickness (0.02 m) const AMP: f64 = 0.08; // tip amplitude /// Peak speed (m/s); `RTX_FRESHCELL_U` overrides (the amplitude stays). fn peak_speed() -> f64 { std::env::var("RTX_FRESHCELL_U") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1.0) } const CX: f64 = 0.5; const CY0: f64 = 0.5; fn center_y(t: f64) -> f64 { CY0 + AMP * (peak_speed() / AMP * t).sin() } fn center_v(t: f64) -> f64 { peak_speed() * (peak_speed() / AMP * t).cos() } const R_CIRCLE: f64 = 0.05; fn circle_body() -> bool { std::env::var("RTX_FRESHCELL_BODY").is_ok_and(|v| v == "circle") } fn plate(moving: bool) -> EmbeddedBody { if circle_body() { let yc = move |t: f64| if moving { center_y(t) } else { CY0 }; let vc = move |t: f64| if moving { center_v(t) } else { 0.0 }; return EmbeddedBody::from_sdf(move |x, y, t| { ((x - CX).powi(2) + (y - yc(t)).powi(2)).sqrt() - R_CIRCLE }) .with_surface_velocity(move |_, _, t| (0.0, vc(t))); } let yc = move |t: f64| if moving { center_y(t) } else { CY0 }; let vc = move |t: f64| if moving { center_v(t) } else { 0.0 }; EmbeddedBody::from_sdf(move |x, y, t| { let qx = (x - CX).abs() - HX; let qy = (y - yc(t)).abs() - HY; let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); outside + qx.max(qy).min(0.0) }) .with_surface_velocity(move |_, _, t| (0.0, vc(t))) } /// Surface samples of the plate at time `t` (outward normals), spacing `ds`. fn samples(t: f64, moving: bool, ds: f64) -> Vec<(f64, f64, f64, f64, f64)> { let yc = if moving { center_y(t) } else { CY0 }; if circle_body() { let n = ((2.0 * std::f64::consts::PI * R_CIRCLE / ds).ceil() as usize).max(8); let dth = 2.0 * std::f64::consts::PI / n as f64; return (0..n) .map(|k| { let th = (k as f64 + 0.5) * dth; let (s, c) = th.sin_cos(); (CX + R_CIRCLE * c, yc + R_CIRCLE * s, c, s, R_CIRCLE * dth) }) .collect(); } let (x0, x1, y0, y1) = (CX - HX, CX + HX, yc - HY, yc + HY); let mut out = Vec::new(); let mut edge = |ax: f64, ay: f64, bx: f64, by: f64, nx: f64, ny: f64| { let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt(); let n = ((len / ds).ceil() as usize).max(1); for k in 0..n { let s = (k as f64 + 0.5) / n as f64; out.push(( ax + s * (bx - ax), ay + s * (by - ay), nx, ny, len / n as f64, )); } }; edge(x0, y0, x1, y0, 0.0, -1.0); edge(x1, y0, x1, y1, 1.0, 0.0); edge(x1, y1, x0, y1, 0.0, 1.0); edge(x0, y1, x0, y0, -1.0, 0.0); out } struct Record { t: f64, fx: f64, fy: f64, fresh: usize, skipped: usize, /// Pressure at a far-field probe (0.5, 0.92) — the fluid's own /// account of the impulse, independent of the traction sampler. p_far: f64, /// Kinetic energy over the fluid cells. ke: f64, } async fn run(moving: bool, dt: f64, t_end: f64) -> CfdResult> { let config = CfdConfig::new() .with_density(RHO) .with_viscosity(MU) .with_reference_velocity(1.0) .with_reference_length(2.0 * HY); let mut solver = EmbeddedPisoSolver::new( config, EmbeddedParameters { corrector_steps: 2, tolerance: 1e-8, poisson_solver: PoissonSolverKind::Multigrid, poisson_precision: rtx_cfd::solvers::incompressible::MgPrecision::F64, poisson_smoother: rtx_cfd::solvers::incompressible::MgSmoother::Lexicographic, ..EmbeddedParameters::default() }, )?; solver.set_boundary_velocity(|_, _, _| (0.0, 0.0)); if std::env::var("RTX_EMBEDDED_EXTEND").is_ok() { solver.set_field_extension(true); } if let Ok(v) = std::env::var("RTX_EMBEDDED_SWEPT") { solver.set_swept_volume_source(v.parse().expect("RTX_EMBEDDED_SWEPT")); } if moving { solver.set_moving_body(plate(true)); } else { solver.set_body(plate(false)); } let h = 1.0 / N as f64; let mut field = FlowField::new(N, N, h, h)?; solver.initialize(&mut field)?; let steps = (t_end / dt).round() as usize; let mut records = Vec::with_capacity(steps); let trace_ke = std::env::var("RTX_FRESHCELL_KE").is_ok(); let mut best: Option<(usize, f64, f64, f64, f64, usize, usize)> = None; for step in 0..steps { let (u_prev, v_prev, mask_prev) = if trace_ke { ( Some(field.u.clone()), Some(field.v.clone()), solver.mask().cloned(), ) } else { (None, None, None) }; let result = solver.advance(&mut field, dt).await?; if let (Some(up), Some(vp), Some(mp)) = (&u_prev, &v_prev, &mask_prev) { // Kinetic-energy change on this step split by face class: // interior (fluid in both masks), fresh (non-fluid -> fluid), // dead (fluid -> non-fluid). Faces non-fluid in both are the // solid interior and are skipped. use rtx_cfd::solvers::incompressible::FaceKind; let mn = solver.mask().expect("mask"); let (mut ke_int, mut ke_fresh, mut ke_dead) = (0.0, 0.0, 0.0); let (mut n_fresh, mut n_dead) = (0usize, 0usize); let cell = 0.5 * RHO * h * h; for j in 0..N { for i in 0..=N { let was = mp.u_kind(j, i) == FaceKind::Fluid; let now = mn.u_kind(j, i) == FaceKind::Fluid; let d = field.u[(j, i)] - up[(j, i)]; let e = cell * d * d; match (was, now) { (true, true) => ke_int += e, (false, true) => { ke_fresh += e; n_fresh += 1; } (true, false) => { ke_dead += e; n_dead += 1; } _ => {} } } } for j in 0..=N { for i in 0..N { let was = mp.v_kind(j, i) == FaceKind::Fluid; let now = mn.v_kind(j, i) == FaceKind::Fluid; let d = field.v[(j, i)] - vp[(j, i)]; let e = cell * d * d; match (was, now) { (true, true) => ke_int += e, (false, true) => { ke_fresh += e; n_fresh += 1; } (true, false) => { ke_dead += e; n_dead += 1; } _ => {} } } } // Skip the impulsive start (the plate begins at peak speed in // fluid at rest); the scored window starts at 0.02 T. if step > 30 && result.fresh_cells > 0 && best.is_none_or(|b| ke_int + ke_fresh + ke_dead > b.1) { best = Some(( step, ke_int + ke_fresh + ke_dead, ke_int, ke_fresh, ke_dead, n_fresh, n_dead, )); } } let t = (step + 1) as f64 * dt; let mask = solver.mask().expect("mask"); let body = solver.body().expect("body"); let (mut fx, mut fy, mut skipped) = (0.0, 0.0, 0usize); for (x, y, nx, ny, ds) in samples(t, moving, 0.5 * h) { match mask.traction_at(body, &field.u, &field.v, &field.p, MU, t, x, y, nx, ny) { Some((tx, ty)) => { fx += tx * ds; fy += ty * ds; } None => skipped += 1, } } let jp = (0.92 * N as f64) as usize; let ip = (0.5 * N as f64) as usize; let p_far = field.p[(jp, ip)]; let mut ke = 0.0; for j in 0..N { for i in 0..N { if mask.is_fluid_cell(j, i) { let uc = 0.5 * (field.u[(j, i)] + field.u[(j, i + 1)]); let vc = 0.5 * (field.v[(j, i)] + field.v[(j + 1, i)]); ke += 0.5 * RHO * (uc * uc + vc * vc) * h * h; } } } records.push(Record { t, fx, fy, fresh: result.fresh_cells, skipped, p_far, ke, }); } if let Some((step, tot, ki, kf, kd, nf, nd)) = best { println!( " KE-by-face at the largest flip step {step}: total ½ρh²Σ(Δu)² {tot:.3e} J/m — interior \ {ki:.3e}, fresh faces {kf:.3e} ({nf} faces), dead faces {kd:.3e} ({nd} faces)" ); } Ok(records) } /// Spike series: force minus its 21-step running median. fn spikes(f: &[f64]) -> Vec { let w = 10usize; (0..f.len()) .map(|k| { let lo = k.saturating_sub(w); let hi = (k + w + 1).min(f.len()); let mut win: Vec = f[lo..hi].to_vec(); win.sort_by(|a, b| a.partial_cmp(b).unwrap()); f[k] - win[win.len() / 2] }) .collect() } struct Stats { rms_pfar_spike: f64, max_pfar_spike: f64, max_ke_jump: f64, rms_spike: f64, max_spike: f64, rms_force: f64, fresh_total: usize, top_spikes_with_fresh: f64, skipped_max: usize, } fn stats(records: &[Record], t_lo: f64, t_hi: f64) -> Stats { let fy: Vec = records.iter().map(|r| r.fy).collect(); let sp = spikes(&fy); let idx: Vec = (0..records.len()) .filter(|&k| records[k].t >= t_lo && records[k].t <= t_hi) .collect(); let rms = |v: &dyn Fn(usize) -> f64| { (idx.iter().map(|&k| v(k) * v(k)).sum::() / idx.len().max(1) as f64).sqrt() }; let rms_spike = rms(&|k| sp[k]); let rms_force = rms(&|k| fy[k]); let max_spike = idx.iter().map(|&k| sp[k].abs()).fold(0.0, f64::max); let fresh_total: usize = idx.iter().map(|&k| records[k].fresh).sum(); // Of the 20 largest |spike| steps, how many have a fresh cell within ±1 step. let mut ranked: Vec = idx.clone(); ranked.sort_by(|a, b| sp[*b].abs().partial_cmp(&sp[*a].abs()).unwrap()); let top: Vec = ranked.into_iter().take(20).collect(); let with_fresh = top .iter() .filter(|&&k| { (k.saturating_sub(1)..=(k + 1).min(records.len() - 1)).any(|j| records[j].fresh > 0) }) .count(); let top_spikes_with_fresh = with_fresh as f64 / top.len().max(1) as f64; let skipped_max = idx.iter().map(|&k| records[k].skipped).max().unwrap_or(0); let pf: Vec = records.iter().map(|r| r.p_far).collect(); let spf = spikes(&pf); let rms_pfar_spike = rms(&|k| spf[k]); let max_pfar_spike = idx.iter().map(|&k| spf[k].abs()).fold(0.0, f64::max); let max_ke_jump = idx .iter() .filter(|&&k| k > 0) .map(|&k| (records[k].ke - records[k - 1].ke).abs()) .fold(0.0, f64::max); Stats { rms_pfar_spike, max_pfar_spike, max_ke_jump, rms_spike, max_spike, rms_force, fresh_total, top_spikes_with_fresh, skipped_max, } } fn dump(dir: &str, name: &str, records: &[Record]) { let path = std::path::Path::new(dir).join(format!("{name}.csv")); let mut f = std::fs::File::create(path).expect("csv"); writeln!(f, "t,fx,fy,fresh,skipped,p_far,ke").unwrap(); for r in records { writeln!( f, "{:.6},{:.6e},{:.6e},{},{},{:.6e},{:.6e}", r.t, r.fx, r.fy, r.fresh, r.skipped, r.p_far, r.ke ) .unwrap(); } } #[tokio::test] async fn oscillating_plate_force_spikes_track_fresh_cells() -> CfdResult<()> { let ladder = std::env::var("RTX_FRESHCELL_LADDER").is_ok(); let csv_dir = std::env::var("RTX_FRESHCELL_CSV").ok(); let period = 2.0 * std::f64::consts::PI * AMP / peak_speed(); // Through the first max-velocity crossing (t = 0, the plate starts at // peak speed) and up to the turning point at T/4, plus a little. let t_end = 0.3 * period; let (t_lo, t_hi) = (0.02 * period, 0.28 * period); let rest = run(false, DT_FSI2, t_end).await?; let s0 = stats(&rest, t_lo, t_hi); println!( " plate AT REST, dt {DT_FSI2:.2e}: rms force {:.3e}, rms spike {:.3e}, max spike {:.3e}, \ fresh cells {}, skipped max {}", s0.rms_force, s0.rms_spike, s0.max_spike, s0.fresh_total, s0.skipped_max ); if let Some(d) = &csv_dir { dump(d, "rest", &rest); } let dts: Vec = if ladder { vec![DT_FSI2, DT_FSI2 / 2.0, DT_FSI2 / 4.0] } else { vec![DT_FSI2] }; let mut points = Vec::new(); for &dt in &dts { let start = std::time::Instant::now(); let rec = run(true, dt, t_end).await?; let s = stats(&rec, t_lo, t_hi); println!( " plate MOVING, dt {dt:.3e} ({} steps, {:.0} s): rms force {:.3e}, rms spike {:.3e} \ ({:.1}x rest), max spike {:.3e}, fresh cells {} ({:.2}/step), top-20 spikes with a \ fresh cell within ±1 step: {:.0}%, skipped max {}", rec.len(), start.elapsed().as_secs_f64(), s.rms_force, s.rms_spike, s.rms_spike / s0.rms_spike.max(1e-300), s.max_spike, s.fresh_total, s.fresh_total as f64 / rec.len() as f64, 100.0 * s.top_spikes_with_fresh, s.skipped_max ); println!( " far probe p(0.5,0.92): rms spike {:.3e}, max spike {:.3e}; max |ΔKE| per step {:.3e} J/m", s.rms_pfar_spike, s.max_pfar_spike, s.max_ke_jump ); if let Some(d) = &csv_dir { dump(d, &format!("moving_dt{dt:.3e}"), &rec); } assert!(s.rms_force.is_finite() && s.rms_spike.is_finite()); assert!( s.fresh_total > 0, "a plate sweeping at 1 m/s must create fresh cells" ); points.push((dt, s.rms_spike)); } if points.len() >= 2 { // OLS slope of ln(rms) vs ln(dt). let xs: Vec = points.iter().map(|p| p.0.ln()).collect(); let ys: Vec = points.iter().map(|p| p.1.ln()).collect(); let mx = xs.iter().sum::() / xs.len() as f64; let my = ys.iter().sum::() / ys.len() as f64; let num: f64 = xs.iter().zip(&ys).map(|(x, y)| (x - mx) * (y - my)).sum(); let den: f64 = xs.iter().map(|x| (x - mx).powi(2)).sum(); println!( " spike RMS ~ (dt)^{:.2} across {} time steps (published: -0.8 for the raw \ volume source, -0.5 after the cut-cell Poisson)", num / den, points.len() ); } Ok(()) }