//! 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,
..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(())
}