rtx-cfd: overset A-P1 GATED — the curvilinear patch moves and deforms under an exact 2-D DGCL
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

StepGeometry (motion.rs): time-averaged face vectors S̄_f = ½(S^n + S^{n+1}) and
swept volumes δV_f = S̄_f·δc_f — exact for linear node motion on any quad, so
Σ sign δV_f = V^{n+1} − V^n is algebra (1.8e-14 measured; the EndOfStep control
2.1e-3). CurvilinearPisoSolver::set_mesh(next) names the end-of-step geometry;
advance swaps it in, rebuilds operators + pressure matrix on it (L_f, LSQ
gradients, no mesh-velocity term in the projection), keeps the old mesh for
V^n and the explicit boundary data; predictor in the conservative ALE form
V^{n+1} û = V^n u^n + dt(−Σ sign (F − δV/dt) u_f + ν D + f V^n), written as
u·(V^n/V^{n+1}) + … so a stationary mesh is bitwise the static path; fluxes on
S̄_f; snapshot carries both meshes; swept_face_rule knob (Trapezoidal default,
EndOfStep = negative control). Stokes limit keeps the mesh flux (was dropped
with convection) and centres it (upwinding it cost an order: 1.06/1.00).

Gates (tests/curvilinear_ale.rs, 11 tests, 83 s): uniform flow on a wiggling
AND bending annulus 4.44e-15 over 400 steps, p exactly 0, 0 pressure
iterations; control deviates 4.9e-5; stationary mesh through the moving path
bit-identical (both diffusion variants); snapshot/restore on the moving mesh
bit-identical; Taylor–Green orders unchanged — upwind 0.995/0.976 vs fixed
0.987/0.975 at 1.05× error, Stokes 1.92/1.97 vs 1.94/1.99 at 2.4×, moving
annulus 2.11/2.02; linear-field falsifier 1.95/1.92 (annulus), 1.91/1.43
(square, sliding wall nodes). P0 ladders re-run identical to every digit.

Rule from the diagnosis: start a moving run ON the t = 0 mesh and sweep less
than a cell per step — a first step that jumped 2–4 cells imprinted an
O(displacement) error no refinement removed (dt- and motion-independent).

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-04 17:00:04 -07:00
co-authored by Claude Fable 5.1
parent c63d79c300
commit d46fb0b7a7
6 changed files with 1060 additions and 54 deletions
@@ -19,16 +19,29 @@
//! re-project the STORED fluxes. `L_f` is the orthogonal difference plus a
//! node-based tangential correction (a 9-point stencil), assembled and
//! applied by the same code.
//!
//! Phase P1: the patch MOVES. [`CurvilinearPisoSolver::set_mesh`] names the
//! geometry at the end of the coming step; the step then runs in the
//! conservative ALE form `(V^{n+1} û V^n u^n)/dt = …` with the
//! convecting flux relative to the mesh, `F_f δV_f/dt`, on the
//! time-averaged face vectors `S̄_f` (`motion.rs`: the 2-D discrete GCL is
//! exact algebra under that rule). `L_f`, the cell gradients and the
//! pressure matrix live on the end-of-step geometry; the projection is the
//! static one (no mesh-velocity term). A stationary mesh through this path
//! is bit-identical to the static path.
mod motion;
mod operators;
mod predictor;
mod projection;
pub use motion::StepGeometry;
pub use operators::Operators;
use crate::mesh::{PatchMesh, PatchSide};
use crate::solvers::incompressible::ale::SweptFaceRule;
use crate::solvers::incompressible::sparse_bicgstab::CsrMatrix;
use crate::{CfdConfig, CfdResult};
use crate::{CfdConfig, CfdError, CfdResult};
/// What one side of the patch is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -110,6 +123,10 @@ pub struct CurvilinearParameters {
pub boundaries: PatchBoundaries,
/// BiCGSTAB iteration cap.
pub max_poisson_iterations: usize,
/// Face-area rule on a moving mesh: `Trapezoidal` (the DGCL-exact
/// choice, the default) or `EndOfStep` (the negative control, GCL-
/// violating). Irrelevant when the mesh does not move.
pub swept_face_rule: SweptFaceRule,
}
impl Default for CurvilinearParameters {
@@ -121,6 +138,7 @@ impl Default for CurvilinearParameters {
normal_diffusion: NormalDiffusion::Explicit,
boundaries: PatchBoundaries::default(),
max_poisson_iterations: 5000,
swept_face_rule: SweptFaceRule::Trapezoidal,
}
}
}
@@ -166,10 +184,15 @@ pub struct CurvilinearResult {
pub boundary_flux_adjustment: f64,
}
/// Restorable solver state (the coupling re-runs a step).
/// Restorable solver state (the coupling re-runs a step): the time, the
/// current mesh and any mesh already named for the next step. Restoring
/// rebuilds the operators and drops the cached matrix, so a re-run from
/// the snapshot is bit-identical to the first run.
#[derive(Debug, Clone)]
pub struct CurvilinearSolverState {
time: f64,
mesh: PatchMesh,
pending: Option<PatchMesh>,
}
type VelocityFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
@@ -179,6 +202,8 @@ pub struct CurvilinearPisoSolver {
config: CfdConfig,
params: CurvilinearParameters,
mesh: PatchMesh,
/// The mesh the next `advance` ends on (`set_mesh`), if it moves.
pending: Option<PatchMesh>,
ops: Operators,
boundary_velocity: Option<VelocityFn>,
momentum_source: Option<VelocityFn>,
@@ -198,6 +223,7 @@ impl CurvilinearPisoSolver {
config,
params,
mesh,
pending: None,
ops,
boundary_velocity: None,
momentum_source: None,
@@ -220,10 +246,37 @@ impl CurvilinearPisoSolver {
{
self.momentum_source = Some(Box::new(f));
}
/// The mesh.
/// The mesh the field currently lives on (the end of the last step).
pub fn mesh(&self) -> &PatchMesh {
&self.mesh
}
/// The mesh named for the end of the next step, if any.
pub fn next_mesh(&self) -> Option<&PatchMesh> {
self.pending.as_ref()
}
/// Name the geometry the NEXT `advance` ends on. Same topology as the
/// current mesh (`ns`, `nn`, periodicity); the node motion between the
/// two is taken as linear in time. Calling it again before `advance`
/// replaces the earlier choice (the coupling loop re-tries a step);
/// not calling it leaves the mesh where it is.
pub fn set_mesh(&mut self, next: PatchMesh) -> CfdResult<()> {
if next.ns() != self.mesh.ns()
|| next.nn() != self.mesh.nn()
|| next.periodic().is_some() != self.mesh.periodic().is_some()
{
return Err(CfdError::mesh(format!(
"set_mesh: topology changed ({}x{}, periodic {}) -> ({}x{}, periodic {})",
self.mesh.ns(),
self.mesh.nn(),
self.mesh.periodic().is_some(),
next.ns(),
next.nn(),
next.periodic().is_some()
)));
}
self.pending = Some(next);
Ok(())
}
/// The operators.
pub fn operators(&self) -> &Operators {
&self.ops
@@ -244,13 +297,21 @@ impl CurvilinearPisoSolver {
pub fn set_time(&mut self, t: f64) {
self.time = t;
}
/// Capture the state.
/// Capture the state (time and both meshes).
pub fn snapshot(&self) -> CurvilinearSolverState {
CurvilinearSolverState { time: self.time }
CurvilinearSolverState {
time: self.time,
mesh: self.mesh.clone(),
pending: self.pending.clone(),
}
}
/// Restore a captured state.
/// Restore a captured state: operators rebuilt, matrix cache dropped.
pub fn restore(&mut self, state: &CurvilinearSolverState) {
self.time = state.time;
self.mesh = state.mesh.clone();
self.pending = state.pending.clone();
self.ops = Operators::new(&self.mesh, &self.params.boundaries);
self.matrix = None;
}
pub(crate) fn boundary_velocity(&self, x: f64, y: f64, t: f64) -> (f64, f64) {
@@ -278,8 +339,15 @@ impl CurvilinearPisoSolver {
field.v[c] = v;
}
let zero = vec![0.0; mesh.cell_count()];
field.flux =
self.predicted_fluxes(&field.u.clone(), &field.v.clone(), &zero, 0.0, self.time);
let geo = StepGeometry::stationary(mesh);
field.flux = self.predicted_fluxes(
&field.u.clone(),
&field.v.clone(),
&zero,
0.0,
self.time,
&geo,
);
}
/// Advance one step of `dt`.
@@ -291,10 +359,28 @@ impl CurvilinearPisoSolver {
let t_old = self.time;
let t_new = t_old + dt;
let rho = self.config.density;
// The mesh moves: `self.mesh` becomes the end-of-step geometry
// (operators and matrix follow it); the start-of-step geometry is
// kept for this step's volumes and swept faces only.
let old = match self.pending.take() {
Some(next) => {
let old = std::mem::replace(&mut self.mesh, next);
self.ops = Operators::new(&self.mesh, &self.params.boundaries);
self.matrix = None;
Some(old)
}
None => None,
};
let geo = match &old {
Some(o) => StepGeometry::new(o, &self.mesh, self.params.swept_face_rule),
None => StepGeometry::stationary(&self.mesh),
};
let old_mesh = old.as_ref().unwrap_or(&self.mesh);
let mesh = &self.mesh;
let (uh, vh) = self.predict(field, dt, t_old);
let mut flux = self.predicted_fluxes(&uh, &vh, &field.p, dt, t_new);
let (uh, vh) = self.predict(field, dt, t_old, old_mesh, &geo);
let mut flux = self.predicted_fluxes(&uh, &vh, &field.p, dt, t_new, &geo);
let adjustment = self.adjust_boundary_flux(&mut flux);
for c in 0..mesh.cell_count() {
let g = self.pressure_gradient(&field.p, c);