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
@@ -0,0 +1,756 @@
//! P1 gates (`docs/overset_metal_campaign.md` §2.2 P1, `next_session.md`
//! 2026-09-04): the curvilinear patch moves and deforms under an exact
//! discrete geometric conservation law.
//!
//! 1. The 2-D identity `Σ_f sign_f S̄_f·δc_f = V^{n+1} V^n` holds to
//! rounding on general quadrilaterals under the trapezoidal face rule
//! and fails visibly under end-of-step areas.
//! 2. Uniform flow on an arbitrarily wiggling AND bending annulus (whole-
//! patch translation, rotation, quadratic bending, short-wave interior
//! wiggle) is preserved to ≤ 1e-12 over 400 steps at the `ale_dgcl.rs`
//! standard (pressure stop 1e-13); the end-of-step negative control
//! must FAIL visibly.
//! 3. A stationary mesh pushed through the moving path is bit-identical to
//! the static path (both diffusion variants).
//! 4. Snapshot/restore on a moving mesh re-runs bit-identically.
//! 5. TaylorGreen order is unchanged under a general (non-tensor) interior
//! mesh motion (`ale_taylor_green.rs` pattern).
use rtx_cfd::mesh::PatchMesh;
use rtx_cfd::mesh::patch_gen::{annulus_skewed, cartesian};
use rtx_cfd::solvers::incompressible::{
CurvilinearParameters, CurvilinearPisoSolver, NormalDiffusion, PatchConvection, PatchField,
StepGeometry, SweptFaceRule,
};
use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI;
const U_UNIFORM: f64 = 0.7;
const V_UNIFORM: f64 = -0.4;
/// The annulus every moving-mesh test starts from (the P0 skewed,
/// stretched, periodic O-grid).
fn base_annulus(ns: usize, nn: usize) -> CfdResult<PatchMesh> {
annulus_skewed([0.0, 0.0], 0.5, 1.5, ns, nn, 0.3, 3.0)
}
/// Arbitrary smooth motion of the whole annulus: quadratic bending, a
/// short-wavelength interior wiggle, a rigid rotation and a translation —
/// nothing is fixed, no symmetry survives, and the displacement gradient
/// stays below one so no cell folds. A function of the reference position
/// only, so the periodic seam (column `ns` a bitwise copy of column 0)
/// stays exact.
fn deform(x: f64, y: f64, t: f64) -> [f64; 2] {
let x1 = x + 0.08 * (1.7 * t).sin() * y * y;
let y1 = y + 0.06 * (2.3 * t + 1.0).sin() * x * x;
let x2 = x1 + 0.03 * (4.0 * y1 + 2.9 * t).sin();
let y2 = y1 + 0.03 * (4.0 * x1 + 4.3 * t + 1.0).sin();
let th = 0.3 * (1.1 * t).sin();
let (c, s) = (th.cos(), th.sin());
[
c * x2 - s * y2 + 0.2 * (0.9 * t).sin(),
s * x2 + c * y2 + 0.15 * ((1.3 * t).cos() - 1.0),
]
}
/// `base` with every node moved by `motion(x_ref, y_ref, t)`.
fn moved(
base: &PatchMesh,
t: f64,
motion: &dyn Fn(f64, f64, f64) -> [f64; 2],
) -> CfdResult<PatchMesh> {
let (ns, nn) = (base.ns(), base.nn());
let count = (ns + 1) * (nn + 1);
let mut x = Vec::with_capacity(count);
let mut y = Vec::with_capacity(count);
for n in 0..count {
let r = base.node_xy(n);
let m = motion(r[0], r[1], t);
x.push(m[0]);
y.push(m[1]);
}
PatchMesh::from_nodes(ns, nn, x, y, base.periodic())
}
fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b)
.map(|(x, y)| (x - y).abs())
.fold(0.0, f64::max)
}
fn fields_identical(a: &PatchField, b: &PatchField) -> bool {
let bits = |v: &[f64], w: &[f64]| v.iter().zip(w).all(|(x, y)| x.to_bits() == y.to_bits());
bits(&a.u, &b.u) && bits(&a.v, &b.v) && bits(&a.p, &b.p) && bits(&a.flux, &b.flux)
}
// ---------------------------------------------------------------- gate 1
#[test]
fn swept_volumes_close_the_gcl_identity_on_general_quads() -> CfdResult<()> {
let base = base_annulus(32, 8)?;
let mut worst_trap = 0.0_f64;
let mut worst_end = 0.0_f64;
for step in 0..40 {
let (t0, t1) = (0.05 * step as f64, 0.05 * (step + 1) as f64);
let old = moved(&base, t0, &deform)?;
let new = moved(&base, t1, &deform)?;
let trap = StepGeometry::new(&old, &new, SweptFaceRule::Trapezoidal);
let end = StepGeometry::new(&old, &new, SweptFaceRule::EndOfStep);
worst_trap = worst_trap.max(trap.gcl_defect(&old, &new));
worst_end = worst_end.max(end.gcl_defect(&old, &new));
}
println!(
" GCL defect (relative to cell volume): trapezoidal {worst_trap:.3e}, end-of-step {worst_end:.3e}"
);
assert!(
worst_trap < 1e-13,
"trapezoidal swept volumes do not close the volume increment: {worst_trap:.3e}"
);
assert!(
worst_end > 1e-4,
"the end-of-step control only reaches {worst_end:.3e}: motion too tame"
);
Ok(())
}
// ------------------------------------------------------------- gates 2, 3
async fn uniform_flow_deviation(
rule: SweptFaceRule,
steps: usize,
dt: f64,
) -> CfdResult<(f64, f64, usize)> {
let config = CfdConfig::new()
.with_density(1.0)
.with_viscosity(0.05)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
// Pressure stop at the rounding floor (the ale_dgcl.rs lesson): with an
// engineering tolerance a partial p' accumulates and re-perturbs u.
let params = CurvilinearParameters {
corrector_steps: 2,
tolerance: 1e-13,
swept_face_rule: rule,
..CurvilinearParameters::default()
};
let base = base_annulus(32, 8)?;
let mut solver = CurvilinearPisoSolver::new(config, params, moved(&base, 0.0, &deform)?)?;
solver.set_boundary_velocity(|_x, _y, _t| (U_UNIFORM, V_UNIFORM));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |_, _| (U_UNIFORM, V_UNIFORM));
let mut worst = 0.0_f64;
let mut iterations = 0;
for step in 0..steps {
let t_new = (step + 1) as f64 * dt;
solver.set_mesh(moved(&base, t_new, &deform)?)?;
let r = solver.advance(&mut field, dt).await?;
iterations += r.poisson_iterations;
for &u in &field.u {
worst = worst.max((u - U_UNIFORM).abs());
}
for &v in &field.v {
worst = worst.max((v - V_UNIFORM).abs());
}
}
let max_p = field.p.iter().fold(0.0_f64, |m, &p| m.max(p.abs()));
Ok((worst, max_p, iterations))
}
#[tokio::test]
async fn uniform_flow_stays_exactly_uniform_on_a_wiggling_and_bending_annulus() -> CfdResult<()> {
let (worst, max_p, iters) =
uniform_flow_deviation(SweptFaceRule::Trapezoidal, 400, 1e-3).await?;
println!(
" DGCL on the annulus: max |u - U| over 400 steps = {worst:.3e}, final max |p| = {max_p:.3e}, \
pressure iterations {iters}"
);
let scale = U_UNIFORM.abs().max(V_UNIFORM.abs());
assert!(
worst <= 1e-12 * scale,
"DGCL violated: uniform flow deviated by {worst:.3e} on the moving annulus"
);
assert!(max_p < 1e-11, "pressure moved off constant: {max_p:.3e}");
Ok(())
}
#[tokio::test]
async fn end_of_step_face_areas_violate_the_gcl_visibly() -> CfdResult<()> {
let (worst, max_p, _) = uniform_flow_deviation(SweptFaceRule::EndOfStep, 400, 1e-3).await?;
println!(
" GCL-violating control: max |u - U| over 400 steps = {worst:.3e}, max |p| = {max_p:.3e}"
);
let scale = U_UNIFORM.abs().max(V_UNIFORM.abs());
assert!(
worst > 1e-6 * scale,
"the GCL-violating rule deviated only {worst:.3e}: the DGCL test has lost its teeth"
);
Ok(())
}
// ------------------------------------------------------------- gates 4, 5
const RHO: f64 = 1.0;
const MU: f64 = 0.05;
fn u_mms(x: f64, y: f64) -> f64 {
(PI * x).sin() * (PI * y).cos()
}
fn v_mms(x: f64, y: f64) -> f64 {
-(PI * x).cos() * (PI * y).sin()
}
fn source_mms(x: f64, y: f64) -> (f64, f64) {
let conv = RHO * 0.5 * PI;
(
conv * (2.0 * PI * x).sin()
+ 2.0 * PI * PI * MU * u_mms(x, y)
+ PI * (PI * x).cos() * (PI * y).sin(),
conv * (2.0 * PI * y).sin()
+ 2.0 * PI * PI * MU * v_mms(x, y)
+ PI * (PI * x).sin() * (PI * y).cos(),
)
}
fn mms_solver(
mesh: PatchMesh,
diffusion: NormalDiffusion,
) -> CfdResult<(CurvilinearPisoSolver, PatchField)> {
let config = CfdConfig::new().with_density(RHO).with_viscosity(MU);
let params = CurvilinearParameters {
normal_diffusion: diffusion,
..CurvilinearParameters::default()
};
let mut solver = CurvilinearPisoSolver::new(config, params, mesh)?;
solver.set_boundary_velocity(|x, y, t| (u_mms(x, y) * (1.0 + 0.1 * t), v_mms(x, y)));
solver.set_momentum_source(|x, y, _| source_mms(x, y));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |x, y| (u_mms(x, y), v_mms(x, y)));
Ok((solver, field))
}
#[tokio::test]
async fn stationary_mesh_through_the_moving_path_is_bit_identical() -> CfdResult<()> {
for diffusion in [NormalDiffusion::Explicit, NormalDiffusion::LineImplicit] {
let mesh = base_annulus(24, 6)?;
let (mut plain, mut f_plain) = mms_solver(mesh.clone(), diffusion)?;
let (mut moving, mut f_moving) = mms_solver(mesh.clone(), diffusion)?;
let dt = 1e-3;
for _ in 0..20 {
plain.advance(&mut f_plain, dt).await?;
moving.set_mesh(mesh.clone())?;
moving.advance(&mut f_moving, dt).await?;
}
assert_eq!(plain.time().to_bits(), moving.time().to_bits());
assert!(
fields_identical(&f_plain, &f_moving),
"{diffusion:?}: the moving path with a stationary mesh differs from the static path by \
u {:.3e} v {:.3e} p {:.3e} flux {:.3e}",
max_abs_diff(&f_plain.u, &f_moving.u),
max_abs_diff(&f_plain.v, &f_moving.v),
max_abs_diff(&f_plain.p, &f_moving.p),
max_abs_diff(&f_plain.flux, &f_moving.flux),
);
println!(
" {diffusion:?}: stationary mesh through the moving path — bit-identical over 20 steps"
);
}
Ok(())
}
#[tokio::test]
async fn snapshot_restore_rerun_on_a_moving_mesh_is_bit_identical() -> CfdResult<()> {
let base = base_annulus(24, 6)?;
let (mut solver, mut field) =
mms_solver(moved(&base, 0.0, &deform)?, NormalDiffusion::LineImplicit)?;
let dt = 1e-3;
let mut step = 0usize;
for _ in 0..5 {
step += 1;
solver.set_mesh(moved(&base, step as f64 * dt, &deform)?)?;
solver.advance(&mut field, dt).await?;
}
// Name the next mesh BEFORE the snapshot so the pending mesh is part of
// what restore must bring back.
solver.set_mesh(moved(&base, (step + 1) as f64 * dt, &deform)?)?;
let saved = solver.snapshot();
let field_saved = field.clone();
let step_saved = step;
for _ in 0..10 {
step += 1;
if step > step_saved + 1 {
solver.set_mesh(moved(&base, step as f64 * dt, &deform)?)?;
}
solver.advance(&mut field, dt).await?;
}
let reference = (field.clone(), solver.time());
solver.restore(&saved);
field = field_saved;
step = step_saved;
for _ in 0..10 {
step += 1;
if step > step_saved + 1 {
solver.set_mesh(moved(&base, step as f64 * dt, &deform)?)?;
}
solver.advance(&mut field, dt).await?;
}
assert_eq!(solver.time().to_bits(), reference.1.to_bits());
assert!(
fields_identical(&field, &reference.0),
"re-run on the moving mesh differs: u {:.3e} v {:.3e} p {:.3e} flux {:.3e}",
max_abs_diff(&field.u, &reference.0.u),
max_abs_diff(&field.v, &reference.0.v),
max_abs_diff(&field.p, &reference.0.p),
max_abs_diff(&field.flux, &reference.0.flux),
);
println!(" snapshot/restore on the moving mesh — bit-identical re-run over 10 steps");
Ok(())
}
#[test]
fn set_mesh_refuses_a_topology_change() -> CfdResult<()> {
let config = CfdConfig::new().with_density(RHO).with_viscosity(MU);
let mut solver = CurvilinearPisoSolver::new(
config,
CurvilinearParameters::default(),
base_annulus(24, 6)?,
)?;
assert!(solver.set_mesh(base_annulus(24, 8)?).is_err());
assert!(solver.set_mesh(cartesian(24, 6, 1.0, 1.0, false)?).is_err());
assert!(solver.set_mesh(base_annulus(24, 6)?).is_ok());
Ok(())
}
// ------------------------------------------------- linear-field falsifier
/// A linear divergence-free field `u = (U + G y, V + G x)` is an exact steady
/// Stokes solution with flat pressure on any moving mesh. `L_f` is exact on
/// linear fields and the pressure is zero, so the only discrete error is the
/// upwind mesh-flux term, `O(h ∂w ∂u)` per unit time — it must FALL with
/// refinement. An `O(displacement · G)` error that does not fall means the
/// field is being carried with the mesh.
#[tokio::test]
async fn linear_field_on_the_moving_annulus_converges() -> CfdResult<()> {
const G: f64 = 0.3;
let lin = |x: f64, y: f64| (U_UNIFORM + G * y, V_UNIFORM + G * x);
let dt = 1e-3;
let steps = 400;
let mut errs = Vec::new();
for ns in [32usize, 64, 128] {
let config = CfdConfig::new().with_density(1.0).with_viscosity(0.05);
let params = CurvilinearParameters {
tolerance: 1e-10,
convection: PatchConvection::None,
..CurvilinearParameters::default()
};
let base = base_annulus(ns, ns / 4)?;
let mut solver = CurvilinearPisoSolver::new(config, params, moved(&base, 0.0, &deform)?)?;
solver.set_boundary_velocity(move |x, y, _t| lin(x, y));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, lin);
let mut worst = 0.0_f64;
for step in 0..steps {
let t_new = (step + 1) as f64 * dt;
solver.set_mesh(moved(&base, t_new, &deform)?)?;
solver.advance(&mut field, dt).await?;
let mesh = solver.mesh();
for c in 0..mesh.cell_count() {
let xy = mesh.centre(c);
let (ue, ve) = lin(xy[0], xy[1]);
worst = worst.max((field.u[c] - ue).abs().max((field.v[c] - ve).abs()));
}
}
let max_p = field.p.iter().fold(0.0_f64, |m, &p| m.max(p.abs()));
println!(
" linear field, ns = {ns}: max |u - u_lin| over {steps} steps = {worst:.3e}, max |p| = {max_p:.3e}"
);
errs.push(worst);
}
let o: Vec<f64> = errs.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
println!(" linear-field orders {o:?}");
assert!(
o.iter().all(|&x| x > 0.7),
"the moving-mesh error on a linear field does not fall with refinement: {errs:?}"
);
Ok(())
}
/// Diagnostic runner: march `exact(x, y, t)` (its own Dirichlet data) in
/// the Stokes limit on `base` moved by `motion` (or fixed), returning the
/// L2 velocity error at `t_end` on the final geometry and max |p|.
async fn stokes_march(
base: &PatchMesh,
motion: Option<&dyn Fn(f64, f64, f64) -> [f64; 2]>,
exact: fn(f64, f64, f64) -> (f64, f64),
nu: f64,
dt: f64,
steps: usize,
) -> CfdResult<(f64, f64)> {
let config = CfdConfig::new().with_density(1.0).with_viscosity(nu);
let params = CurvilinearParameters {
tolerance: 1e-8,
convection: PatchConvection::None,
..CurvilinearParameters::default()
};
let start = match motion {
Some(m) => moved(base, 0.0, m)?,
None => base.clone(),
};
let mut solver = CurvilinearPisoSolver::new(config, params, start)?;
solver.set_boundary_velocity(exact);
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |x, y| exact(x, y, 0.0));
for step in 0..steps {
if let Some(m) = motion {
solver.set_mesh(moved(base, (step + 1) as f64 * dt, m)?)?;
}
solver.advance(&mut field, dt).await?;
}
let t_end = steps as f64 * dt;
let mesh = solver.mesh();
let (mut sq, mut vol) = (0.0, 0.0);
for c in 0..mesh.cell_count() {
let xy = mesh.centre(c);
let (ue, ve) = exact(xy[0], xy[1], t_end);
let (eu, ev) = (field.u[c] - ue, field.v[c] - ve);
sq += (eu * eu + ev * ev) * mesh.area(c);
vol += mesh.area(c);
}
let max_p = field.p.iter().fold(0.0_f64, |m, &p| m.max(p.abs()));
Ok(((sq / vol).sqrt(), max_p))
}
/// The linear field on the moving UNIT SQUARE (boundary nodes sliding along
/// fixed walls, non-periodic sides, four corners): the error must fall with
/// refinement (measured 2.99e-4 / 1.39e-4 / 6.80e-5, first order).
#[tokio::test]
async fn linear_field_on_the_moving_square_converges() -> CfdResult<()> {
fn lin(x: f64, y: f64, _t: f64) -> (f64, f64) {
(U_UNIFORM + 0.3 * y, V_UNIFORM + 0.3 * x)
}
let mut errs = Vec::new();
for n in [16usize, 32, 64] {
let base = cartesian(n, n, 1.0, 1.0, false)?;
let (fixed, _) = stokes_march(&base, None, lin, 0.02, 1e-3, 250).await?;
let (moving, max_p) = stokes_march(&base, Some(&tg_motion), lin, 0.02, 1e-3, 250).await?;
println!(
" linear on the square, n = {n}: fixed L2 {fixed:.3e}, moving L2 {moving:.3e}, max |p| {max_p:.3e}"
);
errs.push(moving);
}
let o: Vec<f64> = errs.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
println!(" orders {o:?}");
assert!(
o.iter().all(|&x| x > 0.7),
"linear field on the moving square: orders {o:?}"
);
Ok(())
}
/// TaylorGreen (Stokes limit) on the moving ANNULUS — the boundary moves
/// with the mesh and the patch is periodic: the second order of the P0
/// annulus gate must survive the motion.
#[tokio::test]
async fn taylor_green_stokes_on_the_moving_annulus_is_second_order() -> CfdResult<()> {
fn tg(x: f64, y: f64, t: f64) -> (f64, f64) {
(tg_u(x, y, t), tg_v(x, y, t))
}
let mut errs = Vec::new();
for ns in [32usize, 64, 128] {
let base = base_annulus(ns, ns / 4)?;
let mut h = f64::INFINITY;
for c in 0..base.cell_count() {
for (f, _) in base.cell_faces(c) {
let d = base.faces()[f].d;
h = h.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
let dt = 0.4 * h * h / (4.0 * NU);
let steps = (T_END / dt).ceil() as usize;
let dt = T_END / steps as f64;
let (fixed, _) = stokes_march(&base, None, tg, NU, dt, steps).await?;
let (moving, max_p) = stokes_march(&base, Some(&deform), tg, NU, dt, steps).await?;
println!(
" TG Stokes on the annulus, ns = {ns}: fixed L2 {fixed:.3e}, moving L2 {moving:.3e}, max |p| {max_p:.3e}, {steps} steps"
);
errs.push(moving);
}
let o: Vec<f64> = errs.windows(2).map(|w| (w[0] / w[1]).log2()).collect();
println!(" orders {o:?}");
assert!(
o.iter().all(|&x| x >= 1.8),
"TG Stokes on the moving annulus: orders {o:?}"
);
Ok(())
}
// ---------------------------------------------------------------- gate 6
const NU: f64 = 0.02;
const T_END: f64 = 0.25;
fn tg_amp(t: f64) -> f64 {
(-2.0 * NU * PI * PI * t).exp()
}
fn tg_u(x: f64, y: f64, t: f64) -> f64 {
tg_amp(t) * (PI * x).sin() * (PI * y).cos()
}
fn tg_v(x: f64, y: f64, t: f64) -> f64 {
-tg_amp(t) * (PI * x).cos() * (PI * y).sin()
}
fn tg_p(x: f64, y: f64, t: f64) -> f64 {
let a = tg_amp(t);
-RHO * a * a / 4.0 * ((2.0 * PI * x).cos() + (2.0 * PI * y).cos())
}
/// Interior motion of the unit square: the `ale_dgcl.rs` tensor wiggle plus
/// a non-tensor term that skews the cells; boundary-fixed (every term
/// carries `sin(πx)` or `sin(πy)`); displacement gradient below one.
fn tg_motion(x: f64, y: f64, t: f64) -> [f64; 2] {
let (sx, sy) = ((PI * x).sin(), (PI * y).sin());
let t = t * env_f64("RTX_CURV_TG_RATE", 1.0);
let a = env_f64("RTX_CURV_TG_AMP", 1.0);
let b = env_f64("RTX_CURV_TG_SKEW", 1.0);
[
x + a * 0.06 * sx * (2.9 * t + 3.0 * x).sin()
+ b * 0.03 * sx * sy * (4.3 * t + 2.0 * y).sin(),
y + a * 0.06 * sy * (4.3 * t + 2.0 * y).sin()
+ b * 0.03 * sx * sy * (2.9 * t + 3.0 * x + 1.0).sin(),
]
}
/// Print-only diagnostic knobs (`RTX_CURV_*`), never gates.
fn env_f64(k: &str, d: f64) -> f64 {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
}
/// March TaylorGreen on the patch to `T_END`, returning the L2 velocity
/// error on the final geometry and the kinetic-energy ratio.
async fn tg_measure(n: usize, moving: bool, convection: PatchConvection) -> CfdResult<(f64, f64)> {
let h = 1.0 / n as f64;
let dt = env_f64("RTX_CURV_DTFRAC", 0.4) * h * h / (4.0 * NU);
let steps = (T_END / dt).ceil() as usize;
let dt = T_END / steps as f64;
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(RHO * NU)
.with_reference_velocity(1.0)
.with_reference_length(1.0);
let params = CurvilinearParameters {
tolerance: 1e-6,
convection,
normal_diffusion: if std::env::var("RTX_CURV_LINE").is_ok() {
NormalDiffusion::LineImplicit
} else {
NormalDiffusion::Explicit
},
..CurvilinearParameters::default()
};
let base = cartesian(n, n, 1.0, 1.0, false)?;
// Diagnostic: freeze the "moving" run on the deformed mesh at this
// time and never move it (separates static skew from skewing motion).
let freeze = std::env::var("RTX_CURV_TG_FREEZE")
.ok()
.and_then(|v| v.parse::<f64>().ok());
// The moving run STARTS on the deformed mesh at t = 0: the motion is not
// the identity there (its phase terms), and starting on the Cartesian
// mesh made the first step sweep 24 cells at once — a mesh CFL far
// above one for the explicit upwinded mesh flux, which imprinted an
// O(displacement) error that no refinement removed (measured: L2
// 1.14e-2 / 1.17e-2 / 1.51e-2 at n = 16/32/64 in the Stokes limit
// against a second-order fixed ladder).
let start = match (moving, freeze) {
(true, Some(tf)) => moved(&base, tf, &tg_motion)?,
(true, None) => moved(&base, 0.0, &tg_motion)?,
(false, _) => base.clone(),
};
let moving = moving && freeze.is_none();
let mut solver = CurvilinearPisoSolver::new(config, params, start)?;
solver.set_boundary_velocity(|x, y, t| (tg_u(x, y, t), tg_v(x, y, t)));
let mut field = PatchField::new(solver.mesh());
solver.initialize(&mut field, |x, y| (tg_u(x, y, 0.0), tg_v(x, y, 0.0)));
for c in 0..base.cell_count() {
let xy = solver.mesh().centre(c);
field.p[c] = tg_p(xy[0], xy[1], 0.0);
}
let energy = |field: &PatchField, mesh: &PatchMesh| -> f64 {
(0..mesh.cell_count())
.map(|c| 0.5 * RHO * (field.u[c] * field.u[c] + field.v[c] * field.v[c]) * mesh.area(c))
.sum()
};
let e0 = energy(&field, solver.mesh());
for step in 0..steps {
if moving {
let t_new = (step + 1) as f64 * dt;
solver.set_mesh(moved(&base, t_new, &tg_motion)?)?;
}
let r = solver.advance(&mut field, dt).await?;
assert!(
r.poisson_converged,
"n = {n} moving = {moving} step {step}: {r:?}"
);
}
let mesh = solver.mesh();
let (mut sq, mut vol) = (0.0, 0.0);
let (mut worst, mut worst_xy) = (0.0_f64, [0.0; 2]);
for c in 0..mesh.cell_count() {
let xy = mesh.centre(c);
let eu = field.u[c] - tg_u(xy[0], xy[1], T_END);
let ev = field.v[c] - tg_v(xy[0], xy[1], T_END);
sq += (eu * eu + ev * ev) * mesh.area(c);
vol += mesh.area(c);
let e = (eu * eu + ev * ev).sqrt();
if e > worst {
worst = e;
worst_xy = xy;
}
}
if std::env::var("RTX_CURV_TRACE").is_ok() {
// Against the exact field at the REFERENCE (undeformed) centres: if
// this is much smaller, the field is being carried with the mesh.
let (mut sq_ref, mut sq_lag) = (0.0, 0.0);
for c in 0..mesh.cell_count() {
let r = base.centre(c);
let eu = field.u[c] - tg_u(r[0], r[1], T_END);
let ev = field.v[c] - tg_v(r[0], r[1], T_END);
sq_ref += (eu * eu + ev * ev) * mesh.area(c);
let xy = mesh.centre(c);
let du = tg_u(xy[0], xy[1], T_END) - tg_u(r[0], r[1], T_END);
let dv = tg_v(xy[0], xy[1], T_END) - tg_v(r[0], r[1], T_END);
sq_lag += (du * du + dv * dv) * mesh.area(c);
}
println!(
" n = {n} moving = {moving}: max pointwise error {worst:.3e} at ({:.3}, {:.3}); \
L2 vs exact at reference centres {:.3e}; full-Lagrangian L2 would be {:.3e}",
worst_xy[0],
worst_xy[1],
(sq_ref / vol).sqrt(),
(sq_lag / vol).sqrt()
);
}
Ok(((sq / vol).sqrt(), energy(&field, mesh) / e0))
}
/// Diagnostic (print-only): the worst mesh quality the TaylorGreen motion
/// produces at resolution `n` over `[0, T_END]` — largest interior
/// non-orthogonality angle, smallest/largest cell area relative to `h²`,
/// and the largest face aspect ratio.
#[test]
fn taylor_green_motion_mesh_quality() -> CfdResult<()> {
let n: usize = env_f64("RTX_CURV_N", 64.0) as usize;
let base = cartesian(n, n, 1.0, 1.0, false)?;
let h2 = 1.0 / (n * n) as f64;
let (mut worst_angle, mut min_area, mut max_area, mut max_aspect) =
(0.0_f64, f64::INFINITY, 0.0_f64, 0.0_f64);
for k in 0..=50 {
let t = T_END * k as f64 / 50.0;
let m = moved(&base, t, &tg_motion)?;
for f in m.faces() {
if f.owner.is_some() && f.neigh.is_some() {
let len = (f.s[0] * f.s[0] + f.s[1] * f.s[1]).sqrt();
let dl = (f.d[0] * f.d[0] + f.d[1] * f.d[1]).sqrt();
let cos = ((f.s[0] * f.d[0] + f.s[1] * f.d[1]) / (len * dl)).clamp(-1.0, 1.0);
worst_angle = worst_angle.max(cos.acos().to_degrees());
}
}
for c in 0..m.cell_count() {
min_area = min_area.min(m.area(c) / h2);
max_area = max_area.max(m.area(c) / h2);
let faces = m.cell_faces(c);
let len = |f: usize| {
let s = m.faces()[f].s;
(s[0] * s[0] + s[1] * s[1]).sqrt()
};
let (a, b) = (len(faces[0].0), len(faces[2].0));
max_aspect = max_aspect.max((a / b).max(b / a));
}
}
println!(
" TG motion at n = {n}: max non-orthogonality {worst_angle:.1} deg, cell area / h^2 in [{min_area:.3}, {max_area:.3}], max aspect {max_aspect:.2}"
);
let mut worst_gcl = 0.0_f64;
for k in 0..50 {
let (t0, t1) = (T_END * k as f64 / 50.0, T_END * (k + 1) as f64 / 50.0);
let (old, new) = (moved(&base, t0, &tg_motion)?, moved(&base, t1, &tg_motion)?);
worst_gcl = worst_gcl
.max(StepGeometry::new(&old, &new, SweptFaceRule::Trapezoidal).gcl_defect(&old, &new));
}
println!(" TG motion at n = {n}: GCL defect {worst_gcl:.3e}");
Ok(())
}
/// Gate: the observed order under mesh motion equals the fixed-mesh order,
/// in both convection modes — upwind (≈ 1, the physical scheme) and the
/// Stokes limit (≥ 1.8, the P0 second-order gate) — and the moving-mesh
/// error stays commensurate with the fixed one at equal resolution
/// (measured 1.05× upwind, 2.4× Stokes: the centred mesh flux costs a
/// constant, not an order).
#[tokio::test]
async fn taylor_green_order_is_unchanged_under_mesh_motion() -> CfdResult<()> {
let exact_ratio = (-4.0 * NU * PI * PI * T_END).exp();
let ns = [16usize, 32, 64, 128];
let only = std::env::var("RTX_CURV_N")
.ok()
.and_then(|v| v.parse::<usize>().ok());
for convection in [PatchConvection::Upwind, PatchConvection::None] {
let (band, error_factor): (std::ops::Range<f64>, f64) = match convection {
PatchConvection::Upwind => (0.7..1.6, 2.0),
PatchConvection::None => (1.8..2.4, 3.0),
};
let mut fixed = Vec::new();
let mut moving = Vec::new();
for &n in &ns {
if only.is_some_and(|o| o != n) || (only.is_none() && n == 128) {
continue;
}
let (ef, rf) = tg_measure(n, false, convection).await?;
let (em, rm) = tg_measure(n, true, convection).await?;
println!(
" {convection:?} n = {n}: fixed L2 {ef:.4e} E(T)/E(0) {rf:.5} | moving L2 {em:.4e} \
E(T)/E(0) {rm:.5} (exact {exact_ratio:.5}, ratio moving/fixed {:.3})",
em / ef
);
assert!(
em < error_factor * ef,
"{convection:?} n = {n}: mesh motion inflated the L2 error {ef:.3e} -> {em:.3e}"
);
// The energy decay is the scheme's business, not the motion's:
// upwind misses the closed form by 8.6% at n = 16 on the FIXED
// mesh (the staggered test's 5% gate was set at n = 32), so the
// claim is that motion leaves the decay where the fixed mesh
// puts it (measured: 0.3% upwind, 0.03% Stokes).
assert!(
(rm - rf).abs() < 0.01 * rf,
"{convection:?} n = {n}: mesh motion changed the energy decay {rf:.5} -> {rm:.5}"
);
fixed.push(ef);
moving.push(em);
}
let order =
|e: &[f64]| -> Vec<f64> { e.windows(2).map(|w| (w[0] / w[1]).log2()).collect() };
let (of, om) = (order(&fixed), order(&moving));
println!(" {convection:?} orders: fixed {of:?}, moving {om:?}");
for (a, b) in of.iter().zip(&om) {
assert!(
band.contains(a),
"{convection:?} fixed order {a:.3} outside {band:?}"
);
assert!(
band.contains(b),
"{convection:?} moving order {b:.3} outside {band:?}"
);
assert!(
(a - b).abs() < 0.3,
"{convection:?}: mesh motion changed the observed order: fixed {a:.3}, moving {b:.3}"
);
}
}
Ok(())
}