rtx-cfd: F2 — the moving embedded body, and falsifier 3 measured

EmbeddedPisoSolver::set_moving_body: the mask is rebuilt at the
end-of-step geometry every step, and the new mask's ghost values are
reconstructed FROM THE PREVIOUS CORRECTED FIELD (EmbeddedMask::
impose_from — the boundary-history principle extended to a moving wall),
so a stationary body run through the moving path is bit-identical to the
static path, which is the first test. A velocity face that flips
solid -> fluid enters the new interval holding exactly the ghost
reconstruction the previous step left on it — a consistent near-wall
value, not garbage; a fresh pressure cell is refilled from its fluid
neighbours before the predictor's gradient can read the value it kept
while inside the body. The body must move under a cell per step (the
convective dt limit already enforces this for bodies slower than the
local peak velocity). EmbeddedResult reports fresh_cells.

tests/embedded_moving.rs:
- a stationary body through the moving path: 0.0 difference over 100
  steps (and zero fresh cells, identical ghost corrections);
- a circle (r = 0.2) translating through the steady manufactured field
  with the exact field as its surface velocity — the solution must hold
  still while the mask sweeps 84 cells fresh over 300 steps at n = 32:
  max L2 velocity error 9.85e-3 = 1.16x the static steady level
  (8.489e-3), max L2 pressure error 4.67e-2 = 2.11x the static level
  (2.22e-2), bulk |div u| 1.6e-7, projection residual 5.9e-9 every step.

That pressure ratio is the geometry decision's falsifier 3 (omni-cortex
docs/turek_hron_geometry_decision.md): fresh-cell transients sit at ~2x
the static discretisation error, not orders above it — the falsifier
does not fire and no cut cells are needed. Measurement note, recorded in
the test: the divergence of body-adjacent cells read after the
end-of-step ghost re-imposition is a one-step lag by design (the next
projection honours the re-imposed prescribed fluxes — the same lag the
static path has); the continuity claims are the projection residual and
the bulk divergence over all-fluid-faced cells.

Deferred: an oscillating-cylinder benchmark against published force
histories (Duetsch et al. 1998) when the FSI rungs need it.

rtx-cfd 321 -> 323 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-20 13:07:28 -07:00
co-authored by Claude Fable 5
parent 38645c7c74
commit 0ad31abb6b
3 changed files with 424 additions and 7 deletions
@@ -97,6 +97,9 @@ pub struct EmbeddedResult {
/// The per-face compatibility correction applied to the ghost faces at /// The per-face compatibility correction applied to the ghost faces at
/// the end of the step (velocity units); zero without a body. /// the end of the step (velocity units); zero without a body.
pub ghost_correction: f64, pub ghost_correction: f64,
/// Pressure cells that flipped solid → fluid in this step's mask
/// rebuild (always zero for a static body).
pub fresh_cells: usize,
} }
/// The embedded-boundary PISO solver. See the module docs. /// The embedded-boundary PISO solver. See the module docs.
@@ -107,6 +110,7 @@ pub struct EmbeddedPisoSolver {
boundary_velocity: Option<VelocityFn>, boundary_velocity: Option<VelocityFn>,
body: Option<EmbeddedBody>, body: Option<EmbeddedBody>,
mask: Option<EmbeddedMask>, mask: Option<EmbeddedMask>,
moving: bool,
time: f64, time: f64,
initialized: bool, initialized: bool,
} }
@@ -122,6 +126,7 @@ impl EmbeddedPisoSolver {
boundary_velocity: None, boundary_velocity: None,
body: None, body: None,
mask: None, mask: None,
moving: false,
time: 0.0, time: 0.0,
initialized: false, initialized: false,
}) })
@@ -145,12 +150,30 @@ impl EmbeddedPisoSolver {
self.boundary_velocity = Some(Box::new(f)); self.boundary_velocity = Some(Box::new(f));
} }
/// Embed a body. The mask is built on the first step (the body is /// Embed a body, treated as fixed in shape and position: the mask is
/// treated as fixed in shape and position for now — moving bodies /// built once, on the first step.
/// arrive with the next rung).
pub fn set_body(&mut self, body: EmbeddedBody) { pub fn set_body(&mut self, body: EmbeddedBody) {
self.body = Some(body); self.body = Some(body);
self.mask = None; self.mask = None;
self.moving = false;
}
/// Embed a body whose signed distance and surface velocity depend on
/// time. The mask is rebuilt at the end-of-step time every step; the
/// new mask's ghost values are reconstructed from the previous
/// corrected field, so a *stationary* body run through this path is
/// bit-identical to [`Self::set_body`]'s. A velocity face that flips
/// solid → fluid (a *fresh* face) enters the new interval holding
/// exactly the ghost reconstruction the previous step left on it —
/// a consistent near-wall value, not garbage — and a fresh pressure
/// cell is refilled from its fluid neighbours before the predictor's
/// gradient can read its stale value. The body must move less than a
/// cell per step (the convective time-step limit already enforces
/// this for a body slower than the local peak velocity).
pub fn set_moving_body(&mut self, body: EmbeddedBody) {
self.body = Some(body);
self.mask = None;
self.moving = true;
} }
/// The body, if any. /// The body, if any.
@@ -849,6 +872,64 @@ impl EmbeddedPisoSolver {
// Boundary data for the new interval; the predictor's `u` holds the // Boundary data for the new interval; the predictor's `u` holds the
// old boundary values until now. // old boundary values until now.
self.apply_boundary_normals(field, t_new); self.apply_boundary_normals(field, t_new);
// A moving body: rebuild the mask at the end-of-step geometry,
// refill the pressure of cells that just became fluid (their stored
// p is stale by their time inside the body — the next predictor
// would read its gradient), and impose the new mask's ghost values
// from the previous corrected field.
let mut fresh_cells = 0usize;
if self.moving {
if let Some(body) = &self.body {
let (nx, ny, dx, dy) = field.grid_info();
let new_mask = EmbeddedMask::build(body, nx, ny, dx, dy, t_new)?;
if let Some(old_mask) = &self.mask {
for j in 0..ny {
for i in 0..nx {
if new_mask.is_fluid_cell(j, i) && !old_mask.is_fluid_cell(j, i) {
fresh_cells += 1;
let mut sum = 0.0;
let mut count = 0usize;
let mut visit = |jj: usize, ii: usize| {
if new_mask.is_fluid_cell(jj, ii)
&& old_mask.is_fluid_cell(jj, ii)
{
sum += field.p[(jj, ii)];
count += 1;
}
};
if i + 1 < nx {
visit(j, i + 1);
}
if i > 0 {
visit(j, i - 1);
}
if j + 1 < ny {
visit(j + 1, i);
}
if j > 0 {
visit(j - 1, i);
}
if count > 0 {
field.p[(j, i)] = sum / count as f64;
}
}
}
}
}
let u_history = field.u_old.clone();
let v_history = field.v_old.clone();
new_mask.impose_from(
body,
&u_history,
&v_history,
&mut field.u,
&mut field.v,
t_new,
);
self.mask = Some(new_mask);
}
}
field.copy_to_starred(); field.copy_to_starred();
let mut residual_history = Vec::new(); let mut residual_history = Vec::new();
@@ -883,6 +964,7 @@ impl EmbeddedPisoSolver {
}, },
corrector_steps_performed: total_corrector_steps, corrector_steps_performed: total_corrector_steps,
ghost_correction, ghost_correction,
fresh_cells,
}) })
} }
} }
@@ -525,6 +525,26 @@ impl EmbeddedMask {
u: &mut DMatrix<f64>, u: &mut DMatrix<f64>,
v: &mut DMatrix<f64>, v: &mut DMatrix<f64>,
t: f64, t: f64,
) -> f64 {
let (u_source, v_source) = (u.clone(), v.clone());
self.impose_from(body, &u_source, &v_source, u, v, t)
}
/// [`Self::impose`] with the fluid values read from a *different* field
/// than the one written: the moving-body step reconstructs the new
/// mask's ghost values from the previous step's corrected field (the
/// boundary-history principle — ghost data, like domain-boundary data,
/// is carried by what the previous step left, not by the uncorrected
/// predictor state). With `source == target` values this is `impose`.
#[allow(clippy::too_many_arguments)]
pub fn impose_from(
&self,
body: &EmbeddedBody,
u_source: &DMatrix<f64>,
v_source: &DMatrix<f64>,
u: &mut DMatrix<f64>,
v: &mut DMatrix<f64>,
t: f64,
) -> f64 { ) -> f64 {
let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy); let (nx, ny, dx, dy) = (self.nx, self.ny, self.dx, self.dy);
@@ -549,10 +569,18 @@ impl EmbeddedMask {
} }
} }
// Ghost values from the fluid field as it stands (reads only fluid // Ghost values from the source fluid field (reads only fluid faces
// faces and fallbacks, so order does not matter). // and fallbacks, so order does not matter).
let u_vals: Vec<f64> = self.u_ghosts.iter().map(|g| g.reconstruct(u)).collect(); let u_vals: Vec<f64> = self
let v_vals: Vec<f64> = self.v_ghosts.iter().map(|g| g.reconstruct(v)).collect(); .u_ghosts
.iter()
.map(|g| g.reconstruct(u_source))
.collect();
let v_vals: Vec<f64> = self
.v_ghosts
.iter()
.map(|g| g.reconstruct(v_source))
.collect();
// Net outward (from fluid) flux through flux-carrying ghost faces. // Net outward (from fluid) flux through flux-carrying ghost faces.
let mut net = 0.0; let mut net = 0.0;
@@ -0,0 +1,307 @@
//! 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,
..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(())
}
/// 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(())
}