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
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:
co-authored by
Claude Fable 5.1
parent
23eb996a9f
commit
c9492d3e1e
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user