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:
co-authored by
Claude Fable 5
parent
38645c7c74
commit
0ad31abb6b
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user