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 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 Turek–Hron 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user