test(rtx-cfd): fresh-cell falsifier extended (circle body, far-field probe, kinetic energy, speed knob) + print-only divergence trace; two candidate fixes REFUTED on it (swept-volume source 40x worse at either sign; fresh-face field extension no effect), both kept default-off with their verdicts
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

Phase 1 of omni-cortex docs/fresh_cell_gcl_campaign.md. The mechanism
of the moving-body force spikes is measured from four directions:
per-flip force amplitude ∝ 1/dt, kinetic energy injected per flipped
cell 0.048 J/m independent of dt and body shape (plate row vs circle),
felt at a far-field pressure probe, and ∝ U^2 (2.60 / 0.64 / 0.15 J/m at
U = 1 / 0.5 / 0.25). A binary mask's wall position jumps by one cell at
every flip and the fluid answers with a fixed impulse. Neither the
swept-volume source (the wall faces already carry the swept volume —
the source double-counts it) nor the fresh-face velocity is where it
lives. Next: the virtual cut cell in the projection (apertures + the
wall-relative divergence), registered in the campaign doc.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-09-03 11:40:28 -07:00
co-authored by Claude Fable 5.1
parent 23eb996a9f
commit c9492d3e1e
3 changed files with 320 additions and 7 deletions
@@ -93,6 +93,7 @@ pub struct EmbeddedSolverState {
mask: Option<EmbeddedMask>,
time: f64,
initialized: bool,
alpha: Option<Vec<f64>>,
}
/// Result of one embedded PISO step.
@@ -115,6 +116,31 @@ pub struct EmbeddedPisoSolver {
config: CfdConfig,
parameters: EmbeddedParameters,
momentum_source: Option<SourceFn>,
/// Reporting-only: the cells that turned fluid on the current step,
/// kept for the `RTX_EMBEDDED_TRACE_SP` divergence trace (empty
/// unless the env var is set).
fresh_trace: Vec<(usize, usize)>,
/// REFUTED on the falsifier (2026-09-03): 40× larger spikes at either
/// sign — the wall faces already carry the swept volume; kept as the
/// record of that measurement, never to be enabled.
/// Swept-volume source strength (0 = off, bit-identical; ±1 = on,
/// sign as registered by the falsifier): the fluid area fraction of
/// every interface cell, α = clamp(½ + φ/h, 0, 1) from the body's
/// signed distance at the cell centre, enters the continuity
/// constraint as a source ρ (α^{n+1} α^n) dx dy / dt, so a cell's
/// fluid volume enters continuously as the wall sweeps instead of as
/// a whole-cell jump at the mask flip — the fixed impulse per flip
/// the fresh-cell falsifier measured (omni-cortex
/// `docs/fresh_cell_gcl_campaign.md`).
swept_volume: f64,
/// Field extension for fresh faces (knob, default off = bit-identical;
/// measured NO EFFECT on the falsifier 2026-09-03 — kept as the record):
/// see `EmbeddedMask::extend_fresh_faces`.
field_extension: bool,
/// The previous step's fluid area fractions (moving path, knob on).
alpha_old: Option<Vec<f64>>,
/// This step's fractions, computed at the mask rebuild.
alpha_new: Option<Vec<f64>>,
boundary_velocity: Option<VelocityFn>,
body: Option<EmbeddedBody>,
mask: Option<EmbeddedMask>,
@@ -133,6 +159,11 @@ impl EmbeddedPisoSolver {
config,
parameters,
momentum_source: None,
fresh_trace: Vec::new(),
swept_volume: 0.0,
field_extension: false,
alpha_old: None,
alpha_new: None,
boundary_velocity: None,
body: None,
mask: None,
@@ -201,6 +232,16 @@ impl EmbeddedPisoSolver {
self.moving = true;
}
/// Field extension for faces that turn fluid (moving-body path).
pub fn set_field_extension(&mut self, on: bool) {
self.field_extension = on;
}
/// Swept-volume source strength for the moving-body path (0 = off).
pub fn set_swept_volume_source(&mut self, strength: f64) {
self.swept_volume = strength;
}
/// The body, if any.
pub fn body(&self) -> Option<&EmbeddedBody> {
self.body.as_ref()
@@ -228,6 +269,7 @@ impl EmbeddedPisoSolver {
mask: self.mask.clone(),
time: self.time,
initialized: self.initialized,
alpha: self.alpha_old.clone(),
}
}
@@ -237,6 +279,7 @@ impl EmbeddedPisoSolver {
self.mask = state.mask.clone();
self.time = state.time;
self.initialized = state.initialized;
self.alpha_old = state.alpha.clone();
}
/// Reset the accumulated time.
@@ -722,6 +765,108 @@ impl EmbeddedPisoSolver {
source_scale += divergence_flux.abs();
}
}
// Swept-volume source (knob; see `swept_volume`): the corrected
// field must satisfy Σ u·n A = dV_f/dt in every interface cell.
if let (true, Some(a_new), Some(a_old)) =
(self.swept_volume != 0.0, &self.alpha_new, &self.alpha_old)
{
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
let k = j * nx + i;
let da = a_new[k] - a_old[k];
if da != 0.0 {
field.sp[(j, i)] -= self.swept_volume * rho * da * dx * dy / dt;
}
}
}
}
// Reporting-only divergence trace (RTX_EMBEDDED_TRACE_SP): where the
// projection's source sits relative to the step's fresh cells, in
// units of one whole cell volume per step (rho dx dy / dt).
if warm_start && !self.fresh_trace.is_empty() {
let unit = rho * dx * dy / dt;
let is_fresh =
|j: usize, i: usize| self.fresh_trace.iter().any(|&(a, b)| a == j && b == i);
let is_nbr = |j: usize, i: usize| {
self.fresh_trace.iter().any(|&(a, b)| {
(a == j && (b + 1 == i || i + 1 == b)) || (b == i && (a + 1 == j || j + 1 == a))
})
};
let (mut mf, mut mn, mut mo) = (0.0f64, 0.0f64, 0.0f64);
let (mut arg, mut argv) = ((0usize, 0usize), 0.0f64);
let mut sum_fresh = 0.0f64;
for j in 0..ny {
for i in 0..nx {
if !self.cell_is_fluid(j, i) {
continue;
}
let v = field.sp[(j, i)] / unit;
if is_fresh(j, i) {
mf = mf.max(v.abs());
sum_fresh += v;
} else if is_nbr(j, i) {
mn = mn.max(v.abs());
} else {
mo = mo.max(v.abs());
}
if v.abs() > argv.abs() {
argv = v;
arg = (j, i);
}
}
}
let class = if is_fresh(arg.0, arg.1) {
"FRESH"
} else if is_nbr(arg.0, arg.1) {
"NEIGHBOUR"
} else {
"other"
};
// The argmax cell's 3x3 neighbourhood: F = fluid, S = solid,
// * = fresh this step (row above first).
let mut hood = String::new();
for dj in [1i64, 0, -1] {
for di in [-1i64, 0, 1] {
let (jj, ii) = (arg.0 as i64 + dj, arg.1 as i64 + di);
let c = if jj < 0 || ii < 0 || jj >= ny as i64 || ii >= nx as i64 {
'#'
} else if is_fresh(jj as usize, ii as usize) {
'*'
} else if self.cell_is_fluid(jj as usize, ii as usize) {
'F'
} else {
'S'
};
hood.push(c);
}
hood.push('/');
}
let fj = self.fresh_trace.iter().map(|c| c.0);
let fi = self.fresh_trace.iter().map(|c| c.1);
println!(
" SP-TRACE fresh rows {:?}..{:?} cols {:?}..{:?}; argmax hood {hood}",
fj.clone().min(),
fj.max(),
fi.clone().min(),
fi.max()
);
println!(
" SP-TRACE t = {:.6}: {} fresh cells; max |sp| {:+.3} cell-volumes/step at ({}, {}) [{class}]; \
max over fresh {:.3}, neighbours {:.3}, others {:.3}; sum over fresh {:+.3}",
self.time + dt,
self.fresh_trace.len(),
argv,
arg.0,
arg.1,
mf,
mn,
mo,
sum_fresh
);
}
let ae_interior = dt * dy / dx;
let an_interior = dt * dx / dy;
@@ -950,6 +1095,8 @@ impl EmbeddedPisoSolver {
// would read its gradient), and impose the new mask's ghost values
// from the previous corrected field.
let mut fresh_cells = 0usize;
let trace_sp = std::env::var("RTX_EMBEDDED_TRACE_SP").is_ok();
self.fresh_trace.clear();
if self.moving {
if let Some(body) = &self.body {
let (nx, ny, dx, dy) = field.grid_info();
@@ -968,6 +1115,9 @@ impl EmbeddedPisoSolver {
for i in 0..nx {
if new_mask.is_fluid_cell(j, i) && !old_mask.is_fluid_cell(j, i) {
fresh_cells += 1;
if trace_sp {
self.fresh_trace.push((j, i));
}
let mut sum = 0.0;
let mut count = 0usize;
let mut visit = |jj: usize, ii: usize| {
@@ -997,6 +1147,20 @@ impl EmbeddedPisoSolver {
}
}
}
if self.swept_volume != 0.0 {
let h = dx.min(dy);
let mut alpha = vec![1.0f64; nx * ny];
for j in 0..ny {
for i in 0..nx {
let phi = body.phi((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy, t_new);
alpha[j * nx + i] = (0.5 + phi / h).clamp(0.0, 1.0);
}
}
if self.alpha_old.is_none() {
self.alpha_old = Some(alpha.clone());
}
self.alpha_new = Some(alpha);
}
let u_history = field.u_old.clone();
let v_history = field.v_old.clone();
new_mask.impose_from(
@@ -1007,6 +1171,21 @@ impl EmbeddedPisoSolver {
&mut field.v,
t_new,
);
if self.field_extension {
if let Some(old_mask) = &self.mask {
old_mask.extend_fresh_faces(
&new_mask,
body,
t_new,
&u_history,
&v_history,
&mut field.u,
&mut field.v,
&mut field.u_old,
&mut field.v_old,
);
}
}
self.mask = Some(new_mask);
}
}
@@ -1034,6 +1213,9 @@ impl EmbeddedPisoSolver {
};
self.time = t_new;
if let Some(a) = self.alpha_new.take() {
self.alpha_old = Some(a);
}
Ok(EmbeddedResult {
solver_result: SolverResult {
converged: final_residual < self.parameters.tolerance,
@@ -696,6 +696,47 @@ impl EmbeddedMask {
self.impose_from(body, &u_source, &v_source, u, v, t)
}
/// Field extension for the faces that were ghosts in this (old) mask
/// and are fluid in `new_mask`: overwrite their velocity AND history
/// with the fluid-side reconstruction at their new distance from the
/// wall ([`Ghost::extend`]), instead of the inherited ghost value (a
/// plane fit extrapolated through the wall, or — where the fit has too
/// few fluid nodes, at corners — the mirror formula with the
/// deviation's sign flipped). Returns the number of faces extended.
/// Knob-gated by the solver; off, nothing here runs.
#[allow(clippy::too_many_arguments)]
pub fn extend_fresh_faces(
&self,
new_mask: &EmbeddedMask,
body: &EmbeddedBody,
t: f64,
u_source: &DMatrix<f64>,
v_source: &DMatrix<f64>,
u: &mut DMatrix<f64>,
v: &mut DMatrix<f64>,
u_old: &mut DMatrix<f64>,
v_old: &mut DMatrix<f64>,
) -> usize {
let mut extended = 0usize;
for g in &self.u_ghosts {
if new_mask.u_kind(g.j, g.i) == FaceKind::Fluid {
let val = g.extend(u_source, body.phi(g.x, g.y, t));
u[(g.j, g.i)] = val;
u_old[(g.j, g.i)] = val;
extended += 1;
}
}
for g in &self.v_ghosts {
if new_mask.v_kind(g.j, g.i) == FaceKind::Fluid {
let val = g.extend(v_source, body.phi(g.x, g.y, t));
v[(g.j, g.i)] = val;
v_old[(g.j, g.i)] = val;
extended += 1;
}
}
extended
}
/// [`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
@@ -1057,6 +1098,20 @@ impl Ghost {
/// collinear), fall back to the linear profile along the normal with the
/// non-fluid nodes replaced by the surface velocity at their own
/// projections.
/// The fluid-side value at signed distance `s_new >= 0` from the wall
/// along this ghost's normal: the wall value plus the probe's
/// deviation scaled by `s_new / s_probe` — never the mirror. This is
/// the field extension for a face that has just turned fluid
/// (Yang & Balaras 2006; Lee, Kim, Choi & Yang 2011's temporal
/// velocity discontinuity is what it removes).
fn extend(&self, values: &DMatrix<f64>, s_new: f64) -> f64 {
let mut probe = 0.0;
for n in &self.nodes {
probe += n.weight * n.fallback.unwrap_or_else(|| values[(n.j, n.i)]);
}
self.u_surface + (probe - self.u_surface) * (s_new.max(0.0) / self.s_probe)
}
fn reconstruct(&self, values: &DMatrix<f64>) -> f64 {
let mut pts: Vec<(f64, f64, f64)> = self
.nodes