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 end of the step (velocity units); zero without a body.
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.
@@ -107,6 +110,7 @@ pub struct EmbeddedPisoSolver {
boundary_velocity: Option<VelocityFn>,
body: Option<EmbeddedBody>,
mask: Option<EmbeddedMask>,
moving: bool,
time: f64,
initialized: bool,
}
@@ -122,6 +126,7 @@ impl EmbeddedPisoSolver {
boundary_velocity: None,
body: None,
mask: None,
moving: false,
time: 0.0,
initialized: false,
})
@@ -145,12 +150,30 @@ impl EmbeddedPisoSolver {
self.boundary_velocity = Some(Box::new(f));
}
/// Embed a body. The mask is built on the first step (the body is
/// treated as fixed in shape and position for now — moving bodies
/// arrive with the next rung).
/// Embed a body, treated as fixed in shape and position: the mask is
/// built once, on the first step.
pub fn set_body(&mut self, body: EmbeddedBody) {
self.body = Some(body);
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.
@@ -849,6 +872,64 @@ impl EmbeddedPisoSolver {
// Boundary data for the new interval; the predictor's `u` holds the
// old boundary values until now.
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();
let mut residual_history = Vec::new();
@@ -883,6 +964,7 @@ impl EmbeddedPisoSolver {
},
corrector_steps_performed: total_corrector_steps,
ghost_correction,
fresh_cells,
})
}
}
@@ -525,6 +525,26 @@ impl EmbeddedMask {
u: &mut DMatrix<f64>,
v: &mut DMatrix<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 {
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
// faces and fallbacks, so order does not matter).
let u_vals: Vec<f64> = self.u_ghosts.iter().map(|g| g.reconstruct(u)).collect();
let v_vals: Vec<f64> = self.v_ghosts.iter().map(|g| g.reconstruct(v)).collect();
// Ghost values from the source fluid field (reads only fluid faces
// and fallbacks, so order does not matter).
let u_vals: Vec<f64> = self
.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.
let mut net = 0.0;