Files
rustytorch/crates/specialized/rtx-cfd/tests/embedded_moving.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

578 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.
//! Rung F2 of the TurekHron ladder: a rigid body MOVING through the fixed
//! grid — per-step mask rebuild, fresh cells, and the falsifier-3
//! measurement (fresh-cell pressure noise) of the geometry decision
//! (omni-cortex `docs/turek_hron_geometry_decision.md`).
//!
//! Two claims, in order:
//!
//! 1. **A stationary body run through the moving path is the static path
//! to the bit.** The moving path rebuilds the mask every step and
//! re-imposes ghost values from the previous corrected field; for a
//! body that happens not to move, both are exactly what the static path
//! holds, so nothing may differ.
//!
//! 2. **A circle translating through the steady manufactured field leaves
//! the solution at the static-body error level.** The circle's surface
//! carries the exact field as its velocity (a "phantom" surface), so
//! the steady manufactured solution stays exact while the mask sweeps
//! across the grid: velocity faces flip solid → fluid holding the ghost
//! reconstruction the previous step left, fresh pressure cells are
//! refilled from neighbours, and any fresh-cell pressure transient
//! shows up directly against the KNOWN exact pressure. The measured
//! time-maxima against the static steady-state levels (L2 u 8.489e-3,
//! L2 p 2.22e-2 at n = 32, upwind) are the falsifier-3 numbers: spikes
//! well above the static level would send the method to cut cells.
use rtx_cfd::solvers::incompressible::{
EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FaceKind, FlowField, PoissonSolverKind,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const RHO: f64 = 1.0;
const MU: f64 = 0.05;
fn u_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).cos()
}
fn v_exact(x: f64, y: f64) -> f64 {
-(PI * x).cos() * (PI * y).sin()
}
fn p_exact(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).sin()
}
fn source(x: f64, y: f64) -> (f64, f64) {
let fx = RHO * 0.5 * PI * (2.0 * PI * x).sin()
+ 2.0 * PI * PI * MU * u_exact(x, y)
+ PI * (PI * x).cos() * (PI * y).sin();
let fy = RHO * 0.5 * PI * (2.0 * PI * y).sin()
+ 2.0 * PI * PI * MU * v_exact(x, y)
+ PI * (PI * x).sin() * (PI * y).cos();
(fx, fy)
}
fn boundary_exact(x: f64, y: f64) -> (f64, f64) {
let u = if x <= 0.0 || x >= 1.0 {
0.0
} else {
u_exact(x, y)
};
let v = if y <= 0.0 || y >= 1.0 {
0.0
} else {
v_exact(x, y)
};
(u, v)
}
fn solver(n: usize) -> CfdResult<EmbeddedPisoSolver> {
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(MU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
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_momentum_source(|x, y, _| source(x, y));
solver.set_boundary_velocity(|x, y, _| boundary_exact(x, y));
let _ = n;
Ok(solver)
}
fn exact_field(n: usize) -> CfdResult<FlowField> {
let dx = 1.0 / n as f64;
let mut field = FlowField::new(n, n, dx, dx)?;
for j in 0..n {
for i in 0..=n {
field.u[(j, i)] = u_exact(i as f64 * dx, (j as f64 + 0.5) * dx);
}
}
for j in 0..=n {
for i in 0..n {
field.v[(j, i)] = v_exact((i as f64 + 0.5) * dx, j as f64 * dx);
}
}
for j in 0..n {
for i in 0..n {
field.p[(j, i)] = p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
}
}
for j in 0..n {
field.u[(j, 0)] = boundary_exact(0.0, (j as f64 + 0.5) * dx).0;
field.u[(j, n)] = boundary_exact(1.0, (j as f64 + 0.5) * dx).0;
}
for i in 0..n {
field.v[(0, i)] = boundary_exact((i as f64 + 0.5) * dx, 0.0).1;
field.v[(n, i)] = boundary_exact((i as f64 + 0.5) * dx, 1.0).1;
}
Ok(field)
}
fn time_step(n: usize) -> f64 {
let dx = 1.0 / n as f64;
let nu = MU / RHO;
0.4 * (dx * dx / (4.0 * nu)).min(dx)
}
fn phantom_circle(
cx: impl Fn(f64) -> f64 + Send + Sync + 'static,
cy: impl Fn(f64) -> f64 + Send + Sync + 'static,
r: f64,
) -> EmbeddedBody {
EmbeddedBody::from_sdf(move |x, y, t| ((x - cx(t)).powi(2) + (y - cy(t)).powi(2)).sqrt() - r)
.with_surface_velocity(|x, y, _| (u_exact(x, y), v_exact(x, y)))
}
/// Claim 1: stationary body, static path vs moving path, bit for bit.
#[tokio::test]
async fn a_stationary_body_through_the_moving_path_is_bit_identical() -> CfdResult<()> {
let n = 24;
let dt = time_step(n);
let mut fixed = solver(n)?;
fixed.set_body(phantom_circle(|_| 0.5, |_| 0.45, 0.2));
let mut moving = solver(n)?;
moving.set_moving_body(phantom_circle(|_| 0.5, |_| 0.45, 0.2));
let mut a = exact_field(n)?;
let mut b = exact_field(n)?;
for _ in 0..100 {
let ra = fixed.advance(&mut a, dt).await?;
let rb = moving.advance(&mut b, dt).await?;
assert_eq!(rb.fresh_cells, 0, "a stationary body produced fresh cells");
assert_eq!(ra.ghost_correction, rb.ghost_correction);
}
let mut max_diff: f64 = 0.0;
for (x, y) in a.u.iter().zip(b.u.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.v.iter().zip(b.v.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.p.iter().zip(b.p.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff == 0.0,
"moving path with a stationary body differs from the static path by {max_diff:.3e}"
);
Ok(())
}
/// The subiteration seam: snapshot the solver + clone the field mid-run of
/// a MOVING body, advance further (a discarded coupling candidate), then
/// restore and advance the same steps again — the re-run must be
/// bit-identical to a run that never diverted. This is what lets an FSI
/// coupling re-run one fluid step under updated interface geometry.
#[tokio::test]
async fn snapshot_restore_rerun_is_bit_identical() -> CfdResult<()> {
let n = 24;
let dt = time_step(n);
let mover = || phantom_circle(|t| 0.42 + 0.30 * t, |t| 0.48 + 0.15 * t, 0.2);
// Reference: an uninterrupted run of 30 steps.
let mut reference = solver(n)?;
reference.set_moving_body(mover());
let mut ref_field = exact_field(n)?;
for _ in 0..30 {
reference.advance(&mut ref_field, dt).await?;
}
// Diverted run: 18 steps, snapshot, 12 steps of a discarded candidate,
// restore, the real 12 steps.
let mut solver_d = solver(n)?;
solver_d.set_moving_body(mover());
let mut field = exact_field(n)?;
for _ in 0..18 {
solver_d.advance(&mut field, dt).await?;
}
let saved_state = solver_d.snapshot();
let saved_field = field.clone();
for _ in 0..12 {
solver_d.advance(&mut field, dt).await?; // discarded candidate
}
solver_d.restore(&saved_state);
field = saved_field;
let mut rerun_fresh = 0usize;
for _ in 0..12 {
rerun_fresh += solver_d.advance(&mut field, dt).await?.fresh_cells;
}
// Cells must actually flip in the re-run window, or the restore of the
// mask was never exercised against a mask that changes.
assert!(
rerun_fresh > 0,
"no cells flipped after the restore — the test is vacuous"
);
assert_eq!(
solver_d.time().to_bits(),
reference.time().to_bits(),
"restored time diverges"
);
let mut max_diff: f64 = 0.0;
for (x, y) in field.u.iter().zip(ref_field.u.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in field.v.iter().zip(ref_field.v.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in field.p.iter().zip(ref_field.p.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff == 0.0,
"restored re-run differs from the uninterrupted run by {max_diff:.3e}"
);
Ok(())
}
/// Mask hysteresis is inert for a body that does not move: the reference
/// classification and the exact classification agree at every cell (a
/// fluid cell has `phi > 0 > -band`, a solid cell `phi <= 0 < band`), so
/// a stationary body with any band is the static path to the bit.
#[tokio::test]
async fn mask_hysteresis_is_inert_for_a_stationary_body() -> CfdResult<()> {
let n = 24;
let dt = time_step(n);
let mut fixed = solver(n)?;
fixed.set_body(phantom_circle(|_| 0.5, |_| 0.45, 0.2));
let mut sticky = solver(n)?;
sticky.set_moving_body(phantom_circle(|_| 0.5, |_| 0.45, 0.2));
sticky.set_mask_hysteresis(0.5);
let mut a = exact_field(n)?;
let mut b = exact_field(n)?;
for _ in 0..100 {
fixed.advance(&mut a, dt).await?;
let rb = sticky.advance(&mut b, dt).await?;
assert_eq!(rb.fresh_cells, 0, "a stationary body produced fresh cells");
}
let mut max_diff: f64 = 0.0;
for (x, y) in a.u.iter().zip(b.u.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.v.iter().zip(b.v.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in a.p.iter().zip(b.p.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff == 0.0,
"hysteresis on a stationary body differs from the static path by {max_diff:.3e}"
);
Ok(())
}
/// Mask hysteresis delays each flip by the band: a translating circle at
/// constant velocity flips its first cell later by ~band / (speed * dt)
/// steps, flips no MORE cells than the plain rebuild over the same
/// traverse, and the delayed timeline is deterministic across re-runs.
#[tokio::test]
async fn mask_hysteresis_delays_flips_by_the_band() -> CfdResult<()> {
let n = 24;
let dt = time_step(n);
let steps = 200;
fn mover() -> EmbeddedBody {
phantom_circle(|t| 0.42 + 0.30 * t, |t| 0.48 + 0.15 * t, 0.2)
}
let run = |band: f64| async move {
let mut solver = solver(n)?;
solver.set_moving_body(mover());
solver.set_mask_hysteresis(band);
let mut field = exact_field(n)?;
let mut timeline = Vec::with_capacity(steps);
for _ in 0..steps {
timeline.push(solver.advance(&mut field, dt).await?.fresh_cells);
}
CfdResult::Ok(timeline)
};
let plain = run(0.0).await?;
let sticky = run(0.5).await?;
let rerun = run(0.5).await?;
assert_eq!(
sticky, rerun,
"the sticky flip timeline is not deterministic"
);
let first = |t: &[usize]| t.iter().position(|&f| f > 0);
let first_plain = first(&plain).expect("the plain traverse flips no cells — vacuous");
let first_sticky = first(&sticky).expect("the sticky traverse flips no cells");
let total_plain: usize = plain.iter().sum();
let total_sticky: usize = sticky.iter().sum();
println!(
" first fresh cell: plain step {first_plain}, band 0.5 step {first_sticky}; \
totals over {steps} steps: plain {total_plain}, band 0.5 {total_sticky}"
);
assert!(
first_sticky > first_plain,
"the band did not delay the first flip (plain {first_plain}, sticky {first_sticky})"
);
assert!(
total_sticky <= total_plain,
"hysteresis flipped MORE cells ({total_sticky}) than the plain rebuild ({total_plain})"
);
assert!(
total_sticky * 2 > total_plain,
"hysteresis suppressed most flips ({total_sticky} of {total_plain}) — the band is \
acting as a freeze, not a delay"
);
Ok(())
}
/// Claim 2: the translating phantom circle. Static steady-state baselines
/// at n = 32 (upwind, from `tests/embedded_mms.rs`): L2 u 8.489e-3,
/// L2 p 2.22e-2.
#[tokio::test]
async fn translating_circle_holds_the_manufactured_field() -> CfdResult<()> {
let n = 32;
let dt = time_step(n);
let dx = 1.0 / n as f64;
let steps = 300;
let mut solver = solver(n)?;
solver.set_moving_body(phantom_circle(
|t| 0.42 + 0.30 * t,
|t| 0.48 + 0.15 * t,
0.2,
));
let mut field = exact_field(n)?;
solver.initialize(&mut field)?;
let mut total_fresh = 0usize;
let mut max_l2_u: f64 = 0.0;
let mut max_l2_p: f64 = 0.0;
let mut max_div: f64 = 0.0;
let mut max_ghost_corr: f64 = 0.0;
let mut max_residual: f64 = 0.0;
for _step in 0..steps {
let result = solver.advance(&mut field, dt).await?;
total_fresh += result.fresh_cells;
max_ghost_corr = max_ghost_corr.max(result.ghost_correction.abs());
max_residual = max_residual.max(result.solver_result.final_residual);
let mask = solver.mask().expect("mask");
// L2 velocity error over the current fluid faces.
let mut squared = 0.0;
let mut volume = 0.0;
for j in 0..n {
for i in 1..n {
if mask.u_kind(j, i) == FaceKind::Fluid {
let e = field.u[(j, i)] - u_exact(i as f64 * dx, (j as f64 + 0.5) * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
for j in 1..n {
for i in 0..n {
if mask.v_kind(j, i) == FaceKind::Fluid {
let e = field.v[(j, i)] - v_exact((i as f64 + 0.5) * dx, j as f64 * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
max_l2_u = max_l2_u.max((squared / volume).sqrt());
// Mean-shifted L2 pressure error over the current fluid cells, and
// the divergence.
let mut diff_sum = 0.0;
let mut cells = 0usize;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
diff_sum +=
field.p[(j, i)] - p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
cells += 1;
}
}
}
let shift = diff_sum / cells as f64;
let mut p_sq = 0.0;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
let e = field.p[(j, i)]
- shift
- p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
p_sq += e * e;
// Bulk divergence: only cells whose four faces are all
// fluid unknowns. The end-of-step ghost re-imposition
// legitimately changes the PRESCRIBED fluxes of
// body-adjacent cells after the projection (the next
// projection honours them — the same one-step lag the
// static path has); the projection's own residual below
// is the continuity claim for those.
if mask.u_kind(j, i) == FaceKind::Fluid
&& mask.u_kind(j, i + 1) == FaceKind::Fluid
&& mask.v_kind(j, i) == FaceKind::Fluid
&& mask.v_kind(j + 1, i) == FaceKind::Fluid
{
let div = (field.u[(j, i + 1)] - field.u[(j, i)]) / dx
+ (field.v[(j + 1, i)] - field.v[(j, i)]) / dx;
max_div = max_div.max(div.abs());
}
}
}
}
max_l2_p = max_l2_p.max((p_sq / cells as f64).sqrt());
}
println!(
" {steps} steps, circle centre moved ({:.3}, {:.3}); fresh cells {total_fresh}; \
max L2 u {max_l2_u:.4e} (static steady 8.489e-3, ratio {:.2}); \
max L2 p {max_l2_p:.4e} (static steady 2.22e-2, ratio {:.2}); \
max bulk |div u| {max_div:.2e}; max projection residual {max_residual:.2e}; \
max ghost correction {max_ghost_corr:.2e}",
0.30 * steps as f64 * dt,
0.15 * steps as f64 * dt,
max_l2_u / 8.489e-3,
max_l2_p / 2.22e-2,
);
assert!(
total_fresh > 20,
"the circle should sweep cells fresh; got {total_fresh} — the test is vacuous"
);
assert!(
max_div < 1e-5,
"a bulk fluid cell is not divergence-free under motion: {max_div:.3e}"
);
assert!(
max_residual < 1e-6,
"the projection failed to converge during the sweep: residual {max_residual:.3e}"
);
// Falsifier 3: fresh-cell pressure transients must stay at the level of
// the static discretisation error, not orders above it.
assert!(
max_l2_u < 2.0 * 8.489e-3,
"velocity error under motion {max_l2_u:.3e} vs static steady 8.489e-3"
);
assert!(
max_l2_p < 3.0 * 2.22e-2,
"pressure error under motion {max_l2_p:.3e} vs static steady 2.22e-2 — fresh-cell \
spikes; the geometry decision's falsifier 3 fires and cut cells are next"
);
Ok(())
}
/// The O(h) price of mask hysteresis, measured: the translating phantom
/// circle with a 0.25h band. The effective wall lags the true surface by
/// up to the band, so the error against the manufactured field must rise
/// above the no-hysteresis moving level — boundedly, at the
/// discretisation's own order, not as a blowup. The printed ratios are
/// the measurement; the asserts exclude a runaway.
#[tokio::test]
async fn translating_circle_with_hysteresis_pays_a_bounded_lag() -> CfdResult<()> {
let n = 32;
let dt = time_step(n);
let dx = 1.0 / n as f64;
let steps = 300;
let mut solver = solver(n)?;
solver.set_moving_body(phantom_circle(
|t| 0.42 + 0.30 * t,
|t| 0.48 + 0.15 * t,
0.2,
));
solver.set_mask_hysteresis(0.25);
let mut field = exact_field(n)?;
solver.initialize(&mut field)?;
let mut total_fresh = 0usize;
let mut max_l2_u: f64 = 0.0;
let mut max_l2_p: f64 = 0.0;
for _step in 0..steps {
let result = solver.advance(&mut field, dt).await?;
total_fresh += result.fresh_cells;
let mask = solver.mask().expect("mask");
let mut squared = 0.0;
let mut volume = 0.0;
for j in 0..n {
for i in 1..n {
if mask.u_kind(j, i) == FaceKind::Fluid {
let e = field.u[(j, i)] - u_exact(i as f64 * dx, (j as f64 + 0.5) * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
for j in 1..n {
for i in 0..n {
if mask.v_kind(j, i) == FaceKind::Fluid {
let e = field.v[(j, i)] - v_exact((i as f64 + 0.5) * dx, j as f64 * dx);
squared += e * e * dx * dx;
volume += dx * dx;
}
}
}
max_l2_u = max_l2_u.max((squared / volume).sqrt());
let mut diff_sum = 0.0;
let mut cells = 0usize;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
diff_sum +=
field.p[(j, i)] - p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
cells += 1;
}
}
}
let shift = diff_sum / cells as f64;
let mut p_sq = 0.0;
for j in 0..n {
for i in 0..n {
if mask.is_fluid_cell(j, i) {
let e = field.p[(j, i)]
- shift
- p_exact((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dx);
p_sq += e * e;
}
}
}
max_l2_p = max_l2_p.max((p_sq / cells as f64).sqrt());
}
println!(
" band 0.25h over {steps} steps: fresh cells {total_fresh}; \
max L2 u {max_l2_u:.4e} (static steady 8.489e-3, ratio {:.2}); \
max L2 p {max_l2_p:.4e} (static steady 2.22e-2, ratio {:.2})",
max_l2_u / 8.489e-3,
max_l2_p / 2.22e-2,
);
assert!(
total_fresh > 20,
"the circle should sweep cells fresh; got {total_fresh} — the test is vacuous"
);
// Measured 2026-08-26: ratios 1.17 (u) and 2.12 (p) — within a percent
// of the no-hysteresis moving levels (1.16 / 2.11). The band is held to
// the same bounds as the plain moving test.
assert!(
max_l2_u < 2.0 * 8.489e-3,
"velocity error with a 0.25h band {max_l2_u:.3e} vs static steady 8.489e-3 — \
the lag is not O(h)-bounded"
);
assert!(
max_l2_p < 3.0 * 2.22e-2,
"pressure error with a 0.25h band {max_l2_p:.3e} vs static steady 2.22e-2 — \
the lag is not O(h)-bounded"
);
Ok(())
}