Files
rustytorch/crates/specialized/rtx-cfd/tests/embedded_fresh_cell_falsifier.rs
T
Omar SobhandClaude Fable 5.1 c63d79c300
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
rtx-cfd/rtx-fsi: overset A-P0 GATED + M1 precision probe — curvilinear collocated PISO: relative-reduction pressure stop (the absolute stop floored |du/dt| at 2e-4 on 64²), line-implicit-n sign fix, adjustPhi; gates: Cartesian reduction 1.37–1.40x the staggered error at orders 0.83/0.90; skewed stretched periodic annulus Stokes orders 2.30/2.06 (explicit and line-implicit), upwind 1.08/0.80; Poiseuille exact to 1e-9 on Cartesian and affine-sheared periodic channels (both diffusion variants), varying-skew channel order 2.02 (v 1.9), cell mass 1e-14; divergence ≤ 1e-11 relative every step; snapshot/restore bit-identical. M1: poisson.rs multigrid hierarchy generic over MgScalar (f32/f64), f64 CG keeps its own fine level; MgPrecision on MultigridParameters/EmbeddedParameters/PisoParameters, set_poisson_precision, harness RTX_FSI2_POISSON_F32 (march + noise probe, printed marker); f64 arm bit-identical in vivo (FSI2 default line-for-line with 08-31), f32 arm holds the noise floor and stall pins and the FSI2 band; poisson_equivalence f32 arm
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-09-04 12:40:43 -07:00

454 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 TurekHron 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=<dir>` 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<Vec<Record>> {
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<f64> {
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<f64> = 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<f64> = records.iter().map(|r| r.fy).collect();
let sp = spikes(&fy);
let idx: Vec<usize> = (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::<f64>() / 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<usize> = idx.clone();
ranked.sort_by(|a, b| sp[*b].abs().partial_cmp(&sp[*a].abs()).unwrap());
let top: Vec<usize> = 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<f64> = 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<f64> = 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<f64> = points.iter().map(|p| p.0.ln()).collect();
let ys: Vec<f64> = points.iter().map(|p| p.1.ln()).collect();
let mx = xs.iter().sum::<f64>() / xs.len() as f64;
let my = ys.iter().sum::<f64>() / 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(())
}