Files
rustytorch/crates/specialized/rtx-cfd/tests/embedded3_falsifier.rs
T
Omar SobhandClaude Fable 5.1 c713bbbbb2
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build (ubuntu-latest) (push) Failing after 4s
Documentation / Build User Guide (push) Successful in 4s
CI / Build CPU-Only (Explicit) (push) Failing after 3s
Documentation / Build API Documentation (push) Failing after 4s
CI / Format Check (push) Failing after 14s
CI / Clippy Check (push) Failing after 48s
Performance Benchmarks / Run Benchmarks (push) Successful in 1m52s
embedded3: study knobs — aperture_substeps (space-time sub-sampling), momentum_volume_cell_mean, RTX_E3_MERGE_FRACTION; all default off (refuted as the fresh-row residual's source)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-17 21:38:46 -05:00

575 lines
20 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.
//! embedded3 item 11a: the fresh-cell falsifier of the 2D track
//! (`embedded_fresh_cell_falsifier.rs`, omni-cortex
//! `docs/fresh_cell_gcl_campaign.md`) on the 3D solver, per unit span —
//! the rigid TurekHron flag (0.35 × 0.02 m) extruded across a periodic
//! slab, oscillating transversely in still fluid at the flag's tip speed
//! (1 m/s peak, 80 mm amplitude) on h = 1/152 at dt = 3.24e-4. Per step:
//! the load per unit span (the ghost wall's traction route over the
//! plate's samples; the cut wall's operator route), a far-field pressure
//! probe, the fluid's kinetic energy, the fresh-cell count.
//!
//! Registered gates (`docs/embedded3_campaign.md` item 11):
//! - GhostBinary reproduces the 2D wall's impulse: energy per flipped
//! column within 30 % of the 2D 0.048 J/m per flipped cell, spike RMS
//! exponent in dt ≈ 1 (published 0.8 for the raw volume source);
//! - CutCell: energy per fresh column ≥ 20× lower, max force spike < 5 %
//! of ½ρU²L, exponent ∈ [0.3, 0.3].
//!
//! Default run: dt only, both schemes (minutes on the host);
//! `RTX_E3_FALSIFIER_LADDER=1` runs dt, dt/2, dt/4 and fits the exponent
//! (the gated variant is `#[ignore]`); `RTX_E3_FALSIFIER_NZ` sets the
//! span in cells (default 4); `RTX_E3_FALSIFIER_CSV=<dir>` dumps records.
use rtx_cfd::solvers::incompressible::ConvectionScheme;
use rtx_cfd::solvers::incompressible::embedded3::{
Body, Boundaries, Field, Fluid, Grid, Parameters, Side, Solver, WallScheme,
};
use std::io::Write as _;
const RHO: f64 = 1000.0;
const MU: f64 = 1.0;
const N: usize = 152;
const DT_FSI2: f64 = 3.24e-4;
const HX: f64 = 0.175;
const HY: f64 = 0.01;
const AMP: f64 = 0.08;
const U_PEAK: f64 = 1.0;
const CX: f64 = 0.5;
const CY0: f64 = 0.5;
/// The 2D wall's measured energy per flipped cell (J/m at U = 1, h = 1/152).
const ENERGY_2D: f64 = 0.048;
fn span_cells() -> usize {
std::env::var("RTX_E3_FALSIFIER_NZ")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4)
}
fn center_y(t: f64) -> f64 {
CY0 + AMP * (U_PEAK / AMP * t).sin()
}
fn center_v(t: f64) -> f64 {
U_PEAK * (U_PEAK / AMP * t).cos()
}
fn plate_sdf(x: f64, y: f64, yc: f64) -> f64 {
let qx = (x - CX).abs() - HX;
let qy = (y - yc).abs() - HY;
let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt();
outside + qx.max(qy).min(0.0)
}
const R_CIRCLE: f64 = 0.05;
/// `RTX_E3_FALSIFIER_BODY=circle`: the 2D falsifier's smooth body (a
/// cylinder of radius 0.05 across the span) instead of the plate.
fn circle_body() -> bool {
std::env::var("RTX_E3_FALSIFIER_BODY").is_ok_and(|v| v == "circle")
}
/// `RTX_E3_FALSIFIER_BODY=stadium`: the plate with semicircular ends
/// (radius `HY`): the same length and thickness, a smooth interface for
/// the cut geometry's linear interpolant.
fn stadium_body() -> bool {
std::env::var("RTX_E3_FALSIFIER_BODY").is_ok_and(|v| v == "stadium")
}
fn stadium_sdf(x: f64, y: f64, yc: f64) -> f64 {
let half = HX - HY;
let qx = (x - CX).abs().max(half) - half;
(qx * qx + (y - yc).powi(2)).sqrt() - HY
}
fn plate(moving: bool) -> 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 };
if circle_body() {
return Body::from_sdf(move |x, y, _z, t| {
((x - CX).powi(2) + (y - yc(t)).powi(2)).sqrt() - R_CIRCLE
})
.with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0));
}
if stadium_body() {
return Body::from_sdf(move |x, y, _z, t| stadium_sdf(x, y, yc(t)))
.with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0));
}
Body::from_sdf(move |x, y, _z, t| plate_sdf(x, y, yc(t)))
.with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0))
}
/// The load scale `½ρU²L` of the body (its length across the motion).
fn load_scale() -> f64 {
let l = if circle_body() {
2.0 * R_CIRCLE
} else {
2.0 * HX
};
0.5 * RHO * U_PEAK * U_PEAK * l
}
/// Surface samples of the plate at `t`: `(x, y, z, nx, ny, area)` over the
/// four edges at spacing `ds` and `nz` z levels.
fn samples(
t: f64,
moving: bool,
ds: f64,
nz: usize,
dz: f64,
) -> Vec<(f64, f64, f64, f64, f64, f64)> {
let yc = if moving { center_y(t) } else { CY0 };
let mut out = Vec::new();
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;
for k in 0..n {
let th = (k as f64 + 0.5) * dth;
let (sn, cs) = th.sin_cos();
for kz in 0..nz {
out.push((
CX + R_CIRCLE * cs,
yc + R_CIRCLE * sn,
(kz as f64 + 0.5) * dz,
cs,
sn,
R_CIRCLE * dth * dz,
));
}
}
return out;
}
if stadium_body() {
let half = HX - HY;
let n_flat = ((2.0 * half / ds).ceil() as usize).max(1);
for k in 0..n_flat {
let x = CX - half + (k as f64 + 0.5) / n_flat as f64 * 2.0 * half;
for kz in 0..nz {
let z = (kz as f64 + 0.5) * dz;
let a = 2.0 * half / n_flat as f64 * dz;
out.push((x, yc + HY, z, 0.0, 1.0, a));
out.push((x, yc - HY, z, 0.0, -1.0, a));
}
}
let n_arc = ((std::f64::consts::PI * HY / ds).ceil() as usize).max(4);
for (cx, sign) in [(CX + half, 1.0), (CX - half, -1.0)] {
for k in 0..n_arc {
let th = -std::f64::consts::FRAC_PI_2
+ (k as f64 + 0.5) / n_arc as f64 * std::f64::consts::PI;
let (sn, cs) = th.sin_cos();
let (nx, ny) = (sign * cs, sn);
for kz in 0..nz {
out.push((
cx + HY * nx,
yc + HY * ny,
(kz as f64 + 0.5) * dz,
nx,
ny,
std::f64::consts::PI * HY / n_arc as f64 * dz,
));
}
}
}
return out;
}
let (x0, x1, y0, y1) = (CX - HX, CX + HX, yc - HY, yc + HY);
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;
for kz in 0..nz {
out.push((
ax + s * (bx - ax),
ay + s * (by - ay),
(kz as f64 + 0.5) * dz,
nx,
ny,
len / n as f64 * dz,
));
}
}
};
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,
/// Load per unit span (the scheme's wall route).
fy: f64,
/// Load per unit span by the control-volume route (a box of whole
/// cells around the body, reading no near-wall value).
fy_cv: f64,
fresh: usize,
skipped: usize,
p_far: f64,
/// Kinetic energy per unit span over the fluid cells.
ke: f64,
}
struct Run {
records: Vec<Record>,
/// The largest kinetic-energy change per step at a step with fresh
/// cells (after the impulsive start) over that step's flipped columns
/// (J/m) — the 2D falsifier's 2.604 J/m over 54 cells = 0.048.
energy_per_flip: f64,
seconds: f64,
}
fn run(scheme: WallScheme, moving: bool, dt: f64, t_end: f64) -> Run {
let nz = span_cells();
let h = 1.0 / N as f64;
let lz = nz as f64 * h;
let mut solver = Solver::new(
Fluid {
density: RHO,
viscosity: MU,
reference_velocity: 1.0,
reference_length: 2.0 * HY,
},
Parameters {
corrector_steps: 2,
tolerance: 1e-8,
convection_scheme: ConvectionScheme::Upwind,
wall_scheme: scheme,
boundaries: Boundaries {
z0: Side::Periodic,
z1: Side::Periodic,
..Boundaries::default()
},
max_surface_speed: Some(U_PEAK),
// `RTX_E3_SUBSTEPS`: intermediate geometries for the space-time apertures.
aperture_substeps: std::env::var("RTX_E3_SUBSTEPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0),
// `RTX_E3_CELL_MEAN=1`: the momentum volume from the two cells' fractions.
momentum_volume_cell_mean: std::env::var("RTX_E3_CELL_MEAN").is_ok_and(|v| v == "1"),
..Parameters::default()
},
);
solver.set_boundary_velocity(|_, _, _, _| (0.0, 0.0, 0.0));
if moving {
solver.set_moving_body(plate(true));
} else {
solver.set_body(plate(false));
}
let g = Grid::cubic(N, N, nz, h);
let mut field = Field::new(g);
solver.initialize(&mut field);
let steps = (t_end / dt).round() as usize;
let mut records = Vec::with_capacity(steps);
// The 2D definition: the largest |ΔKE| step's energy over that
// step's flipped columns.
let mut largest_jump = 0.0_f64;
let mut energy_per_flip = 0.0_f64;
let mut ke_prev: Option<f64> = None;
let start = std::time::Instant::now();
let (jp, ip, kp) = (
(0.92 * N as f64) as usize,
(0.5 * N as f64) as usize,
nz / 2,
);
for step in 0..steps {
let result = solver.advance(&mut field, dt);
let t = (step + 1) as f64 * dt;
let mask = solver.mask().expect("mask");
let body = solver.body().expect("body");
let (mut fy, mut skipped) = (0.0, 0usize);
match scheme {
WallScheme::GhostBinary => {
for (x, y, z, nx, ny, area) in samples(t, moving, 0.5 * h, nz, h) {
match mask.traction_at(body, &field, MU, t, [x, y, z], [nx, ny, 0.0]) {
Some(tr) => fy += tr[1] * area,
None => skipped += 1,
}
}
}
WallScheme::CutCell => {
fy = mask.cut_wall_force(body, &field, MU, t).expect("cut wall")[1];
}
}
fy /= lz;
let margin = 8;
let fy_cv = mask.control_volume_force(
&field,
dt,
RHO,
MU,
None,
(margin, N - margin, margin, N - margin, 0, nz),
)[1] / lz;
let p_far = field.p[g.cell(kp, jp, ip)];
let mut ke = 0.0;
for k in 0..nz {
for j in 0..N {
for i in 0..N {
let idx = g.cell(k, j, i);
if mask.is_fluid_cell(idx) {
let uc = 0.5 * (field.u[g.uface(k, j, i)] + field.u[g.uface(k, j, i + 1)]);
let vc = 0.5 * (field.v[g.vface(k, j, i)] + field.v[g.vface(k, j + 1, i)]);
let wc = 0.5 * (field.w[g.wface(k, j, i)] + field.w[g.wface(k + 1, j, i)]);
ke += 0.5 * RHO * (uc * uc + vc * vc + wc * wc) * h * h * h * mask.vol(idx);
}
}
}
}
ke /= lz;
if let Some(prev) = ke_prev {
if step > 30 && result.fresh_cells > 0 && (ke - prev).abs() > largest_jump {
largest_jump = (ke - prev).abs();
// The plate's event is its row (the 2D divided by the row's
// 54 cells); the circle's is the step's fresh columns.
let columns = if circle_body() {
result.fresh_cells as f64 / nz as f64
} else {
(2.0 * HX / h).round()
};
energy_per_flip = largest_jump / columns;
}
}
ke_prev = Some(ke);
records.push(Record {
t,
fy,
fy_cv,
fresh: result.fresh_cells,
skipped,
p_far,
ke,
});
}
Run {
records,
energy_per_flip,
seconds: start.elapsed().as_secs_f64(),
}
}
/// Spike series: the load 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_force: f64,
rms_spike: f64,
max_spike: f64,
rms_spike_cv: f64,
max_spike_cv: f64,
rms_pfar_spike: f64,
max_pfar_spike: f64,
max_ke_jump: f64,
fresh_total: usize,
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 fcv: Vec<f64> = records.iter().map(|r| r.fy_cv).collect();
let spc = spikes(&fcv);
let pf: Vec<f64> = records.iter().map(|r| r.p_far).collect();
let spf = spikes(&pf);
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()
};
Stats {
rms_force: rms(&|k| fy[k]),
rms_spike: rms(&|k| sp[k]),
max_spike: idx.iter().map(|&k| sp[k].abs()).fold(0.0, f64::max),
rms_spike_cv: rms(&|k| spc[k]),
max_spike_cv: idx.iter().map(|&k| spc[k].abs()).fold(0.0, f64::max),
rms_pfar_spike: rms(&|k| spf[k]),
max_pfar_spike: idx.iter().map(|&k| spf[k].abs()).fold(0.0, f64::max),
max_ke_jump: idx
.iter()
.filter(|&&k| k > 0)
.map(|&k| (records[k].ke - records[k - 1].ke).abs())
.fold(0.0, f64::max),
fresh_total: idx.iter().map(|&k| records[k].fresh).sum(),
skipped_max: idx.iter().map(|&k| records[k].skipped).max().unwrap_or(0),
}
}
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,fy,fy_cv,fresh,skipped,p_far,ke").unwrap();
for r in records {
writeln!(
f,
"{:.6},{:.6e},{:.6e},{},{},{:.6e},{:.6e}",
r.t, r.fy, r.fy_cv, r.fresh, r.skipped, r.p_far, r.ke
)
.unwrap();
}
}
struct Verdict {
energy_per_flip: f64,
max_spike: f64,
exponent: Option<f64>,
}
fn falsify(scheme: WallScheme, ladder: bool) -> Verdict {
let csv_dir = std::env::var("RTX_E3_FALSIFIER_CSV").ok();
let period = 2.0 * std::f64::consts::PI * AMP / U_PEAK;
let t_end = 0.3 * period;
let (t_lo, t_hi) = (0.02 * period, 0.28 * period);
let rest = run(scheme, false, DT_FSI2, t_end);
let s0 = stats(&rest.records, t_lo, t_hi);
println!(
" {scheme:?} plate AT REST, dt {DT_FSI2:.2e} ({:.0} s): rms force {:.3e}, rms spike {:.3e}, max spike {:.3e}, fresh {}, skipped max {}",
rest.seconds, s0.rms_force, s0.rms_spike, s0.max_spike, s0.fresh_total, s0.skipped_max
);
if let Some(d) = &csv_dir {
dump(d, &format!("{scheme:?}_rest"), &rest.records);
}
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();
let mut energy = 0.0_f64;
let mut max_spike = 0.0_f64;
for &dt in &dts {
let r = run(scheme, true, dt, t_end);
let s = stats(&r.records, t_lo, t_hi);
println!(
" {scheme:?} plate MOVING, dt {dt:.3e} ({} steps, {:.0} s): rms force {:.3e}, rms spike {:.3e} ({:.1}x rest), max spike {:.3e} N/m ({:.2e} of ½ρU²L), fresh cells {} ({:.2}/step), skipped max {}",
r.records.len(),
r.seconds,
s.rms_force,
s.rms_spike,
s.rms_spike / s0.rms_spike.max(1e-300),
s.max_spike,
s.max_spike / load_scale(),
s.fresh_total,
s.fresh_total as f64 / r.records.len() as f64,
s.skipped_max
);
println!(
" control-volume route: rms spike {:.3e}, max spike {:.3e} N/m ({:.2e} of ½ρU²L)",
s.rms_spike_cv,
s.max_spike_cv,
s.max_spike_cv / load_scale()
);
println!(
" far probe p(0.5, 0.92): rms spike {:.3e}, max spike {:.3e}; max |ΔKE| per step {:.3e} J/m; energy per flipped column {:.3e} J/m ({:.2} of the 2D wall's {ENERGY_2D})",
s.rms_pfar_spike,
s.max_pfar_spike,
s.max_ke_jump,
r.energy_per_flip,
r.energy_per_flip / ENERGY_2D
);
if let Some(d) = &csv_dir {
dump(d, &format!("{scheme:?}_moving_dt{dt:.3e}"), &r.records);
}
assert!(s.rms_force.is_finite() && s.rms_spike.is_finite());
if dt == DT_FSI2 {
energy = r.energy_per_flip;
max_spike = s.max_spike;
}
points.push((dt, s.rms_spike));
}
let exponent = (points.len() >= 2).then(|| {
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();
let e = num / den;
println!(
" {scheme:?}: spike RMS ~ (dt)^{e:.2} across {} time steps",
points.len()
);
e
});
Verdict {
energy_per_flip: energy,
max_spike,
exponent,
}
}
#[test]
fn oscillating_plate_both_walls() {
let ladder = std::env::var("RTX_E3_FALSIFIER_LADDER").is_ok();
println!(
" body: {}",
if circle_body() {
"circle R 0.05"
} else if stadium_body() {
"stadium 0.35 x 0.02 (semicircular ends)"
} else {
"plate 0.35 x 0.02"
}
);
let ghost = falsify(WallScheme::GhostBinary, ladder);
let cut = falsify(WallScheme::CutCell, ladder);
println!(
" energy per flipped column: ghost {:.3e}, cut {:.3e} (ratio {:.1}x); max spike: ghost {:.3e}, cut {:.3e} N/m",
ghost.energy_per_flip,
cut.energy_per_flip,
ghost.energy_per_flip / cut.energy_per_flip.max(1e-300),
ghost.max_spike,
cut.max_spike
);
assert!(
ghost.energy_per_flip > 0.0,
"the binary wall must flip cells"
);
}
/// The registered gates on the dt ladder.
#[test]
#[ignore = "item 11's gated ladder (dt, dt/2, dt/4 on both walls; tens of minutes on the host)"]
fn oscillating_plate_gates() {
let ghost = falsify(WallScheme::GhostBinary, true);
let cut = falsify(WallScheme::CutCell, true);
let ratio = ghost.energy_per_flip / cut.energy_per_flip.max(1e-300);
println!(
" GATES: ghost energy per flipped column {:.3e} ({:.2} of 2D), exponent {:.2}; cut energy {:.3e} ({:.1}x lower), max spike {:.3e} N/m ({:.2e} of ½ρU²L), exponent {:.2}",
ghost.energy_per_flip,
ghost.energy_per_flip / ENERGY_2D,
ghost.exponent.unwrap(),
cut.energy_per_flip,
ratio,
cut.max_spike,
cut.max_spike / load_scale(),
cut.exponent.unwrap()
);
let g2d = ghost.energy_per_flip / ENERGY_2D;
assert!(
(0.7..=1.3).contains(&g2d),
"ghost energy per flip {g2d:.2} of 2D"
);
assert!(ratio >= 20.0, "cut energy only {ratio:.1}x lower");
assert!(
cut.max_spike < 0.05 * load_scale(),
"cut max spike {:.3e}",
cut.max_spike
);
let e = cut.exponent.unwrap();
assert!((-0.3..=0.3).contains(&e), "cut exponent {e:.2}");
}