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
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
454 lines
17 KiB
Rust
454 lines
17 KiB
Rust
//! 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=<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(())
|
||
}
|