Compare commits

..
9 Commits
Author SHA1 Message Date
Omar SobhandClaude Fable 5 4534d90684 rtx-fea + rtx-cfd: the single-step seams FSI2 stands on
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
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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
rtx-fea: NonlinearDynamicAnalysis refactored onto a NonlinearDynamicStepper
- set_nodal_forces on both (the interface load of a coupling subiteration,
  replaceable between steps and between subiterations of one step);
- step(&DynamicState) is a pure function of the start-of-step state and
  the current forces - commits nothing, so a partitioned coupling re-runs
  one Newmark step to the interface fixed point (the piston semantics);
- run() marches through the same stepper: one code path, pinned from both
  ends (linear limit, CSM3, and a new manual-drive == run() assertion);
- new test: a nodal step load oscillates about the *static* nonlinear
  analysis's deflection (cross-code-path, mean within 3%, amplitude 6%),
  with re-run determinism and force-swap sensitivity asserted mid-march
  (a one-step response to a force change is ~ beta dt^2 - the first
  assertion draft demanded 10% and was corrected against the physics).

rtx-cfd: the subiteration seam and the moving no-slip closure
- EmbeddedPisoSolver::snapshot()/restore() (mask + time + init flag; the
  mask is now Clone): re-running a fluid step within a subiteration is
  bit-identical to never having diverted - proven on a moving body with
  cells flipping in the re-run window;
- polygon_interface_velocity: nearest-edge linear interpolation of
  per-vertex velocities, exact for the linear-along-edge boundary data a
  finite-element interface hands over - the no-slip closure that replaces
  FSI1's zero-velocity polygon.

Suites: rtx-fea 567, rtx-cfd 325, rtx-fsi piston+transfer - all green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
2026-08-20 20:00:49 -07:00
Omar SobhandClaude Fable 5 c0f5a86f03 rtx-cfd: the embedded-body pieces FSI1 stands on (belongs with b82f307)
EmbeddedMask::traction_at (the per-sample traction factored out of
surface_force, so a coupling loop can load a structure at its own
quadrature points), EmbeddedBody::polygon and the public
polygon_signed_distance (a deformable interface as a vertex list, usable
behind a lock through EmbeddedBody::from_sdf). Left unstaged by mistake
in b82f307 — that commit's FSI1 test needs these to compile.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 17:22:44 -07:00
Omar SobhandClaude Fable 5 b82f307cae rtx-fsi + rtx-cfd + rtx-fea: FSI1 — the coupled cylinder and flag
The summit rung C1: all the verified pieces joined into the first coupled
Turek–Hron computation (rtx-fsi/tests/turek_hron_fsi1.rs). The embedded
fluid computes tractions on the DEFORMED flag surface
(EmbeddedMask::traction_at, factored from surface_force); the flag's
wetted boundary is a polygon whose vertex list sits behind a lock, so the
moving-body mask rebuild picks up every shape update
(EmbeddedBody::polygon + pub polygon_signed_distance); WettedSurface —
rebuilt on the deformed interface every subiteration — carries the loads
to the flag's boundary nodes (NonlinearStaticAnalysis::set_nodal_forces);
Subiterated::aitken drives the exchange, each pass marching the fluid to
flag-load stagnation so the coupling map is a function of geometry, not
of the fluid's transient.

Result (ny = 62, 6 Aitken passes, 420 s): coupled drag 15.360 (+7.5%,
the rigid CFD1 band at this grid), lift 0.7977 (+4.4%), ux(A) 2.647e-5
vs 2.270e-5 (+16.6%; +6.1% at ny = 82), uy(A) 3.90e-4 vs 8.21e-4 at
h = 6.6 mm and 1.124e-3 (+37%) at h = 5 mm — the resolutions BRACKET the
reference through the flag's 3 -> 4-cell thickness transition, like the
rigid-flag lift; conservation 7.4e-12 every pass. Bands asserted are the
measured ones; RTX_FSI1_NY runs studies.

Two real rtx-fsi defects found by this rung (15th and 16th of the
campaign), both regression-tested (tests/transfer_curved_edge.rs):

1. solve_weights built its constraint Gram from RAW coordinates: the
   condition number grows as (position/spacing)^2 — ~1e4 for a flag edge
   at x ~ 0.26 with 5 mm spacing — and the 4x4 SVD pseudo-inverse lost
   enough accuracy that the (correctly strict) partition-of-unity /
   reproduction verification rejected healthy neighbourhoods: the
   operator's behaviour depended on WHERE the interface sat. Now centred
   on the face and scaled by the neighbourhood radius — identical
   constraints, O(1) conditioning, translation-invariant.

2. A NEARLY collinear neighbourhood (the nearest nodes of a face on a
   smoothly deformed edge: y is almost linear in x, off by the curvature
   sagitta) cannot satisfy exact centroid reproduction with bounded
   weights — the offending singular value is too large to truncate and
   too small to invert. The recruitment now widens (8 -> 16 -> 32 -> all)
   until the verified constraints hold; for a thin structure that pulls
   in the opposite face, exactly the transverse spread the system needs.

Findings measured before believed: the transfer is faithful (a strictly
local two-node split of the same tractions moved the tip by 2%); the
uy error is the sampled lift PROFILE on a 3-cell flag (a uniform
distribution of the same net lift bends 4x more), confirmed by the
resolution study; TVD limiter chatter (+-0.5% steady load — limited
schemes stall short of machine steady state) defeats steady fixed-point
coupling, so steady coupled cases run upwind while the time-marched
FSI2/FSI3 keep TVD; and the mask never chattered at FSI1's sub-cell
amplitude (fluid-cell count constant through every pass).

rtx-fsi 29 -> 31 green (lib 27, piston 2, curved-edge 1, FSI1 1).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 17:22:27 -07:00
Omar SobhandClaude Fable 5 38ca5ef080 rtx-fea: S2 — nonlinear Newmark, and Turek–Hron CSM3 to 0.1–1.3%
analysis::nonlinear_dynamic::NonlinearDynamicAnalysis: Newmark-beta with
a full Newton solve on the internal force inside every step — residual
F_ext − f_int(u) − M a(u), consistent Jacobian K_T(u) + M/(beta dt^2),
the same total-Lagrangian St. Venant–Kirchhoff / small-strain seam the
nonlinear static analysis uses, consistent mass assembled once (element
mass is configuration-independent in a total-Lagrangian setting),
undamped, homogeneous Dirichlet only (prescribed interface motion
arrives with the FSI rung). The existing NewmarkStepper stays what it
is: the right tool for constant matrices.

Verified (tests/nonlinear_newmark_csm3.rs):
- linear limit (strains ~1e-9): the TL nonlinear stepper reproduces the
  verified linear NewmarkStepper on the same dense M, K, F to a max
  deviation of 1.7e-16 — 1.3e-9 of the oscillation amplitude — over 120
  steps;
- Turek–Hron CSM3 (35x2 Quad8, dt = 0.005, gravity switched on at rest,
  6 s): ux(A) = −14.291 ± 14.291 mm vs the reference −14.305 ± 14.305
  (0.1%); uy(A) = −63.644 ± 64.295 mm vs −63.607 ± 65.160 (0.06% mean,
  1.3% amplitude); frequency 1.1020 Hz vs 1.0995 (0.23%); two Newton
  iterations per step throughout; the undamped amplitude persists
  (half-window drift 0.2%).

rtx-fea 564 -> 566 green (full suite).

With this the S-side of the Turek–Hron ladder is complete: statics
(CSM1/CSM2) and dynamics (CSM3) both land on the benchmark. Next rung is
the coupling: FSI1 with the embedded fluid, the TL flag, and rtx-fsi's
transfer rebuilt on the deformed interface each subiteration.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 14:02:15 -07:00
Omar SobhandClaude Fable 5 0ad31abb6b 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]>
2026-08-20 13:07:28 -07:00
Omar SobhandClaude Fable 5 38645c7c74 fix(autograd): AbsBackward produced NaN for exactly-zero inputs
sign was computed as x/|x|, which is 0/0 = NaN at x = 0; one zero element
in an |pred - target| loss poisoned every upstream gradient (hit
deterministically by dg-gnn HetGAT training). Compute x/(|x| + tiny)
instead so sign(0) = 0 (the subgradient convention). Regression test
included.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 11:53:30 -07:00
Omar SobhandClaude Fable 5 67c47898fa perf(backend-cpu): parallelize blocked gemm over row blocks with rayon
Each task owns a disjoint BLOCK_SIZE-row slice of the result; the inner
blocked kernel is unchanged. Needed for dg-gnn HetGAT training throughput
(node-level [M,64]x[64,64] matmuls dominated single-threaded step time).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 11:37:43 -07:00
Omar SobhandClaude Fable 5 35b2b2cdf4 rtx-cfd: TVD convection in the embedded predictor — the wake sheds — and Turek–Hron CFD2/CFD3
First-order upwind's numerical viscosity |u| h / 2 is ~10x the physical
viscosity on the Turek–Hron grids: the effective Reynolds number lands
near 20 and CFD3 (Re 200) produced NO vortex shedding at all — one lift
zero-crossing in three seconds at h = 10 mm. The physics, not a bug.

EmbeddedParameters gains `convection_scheme` (default Upwind, bit-
identical — the no-body degeneracy test still reads 0.0): the TVD branch
adds SIMPLE's limited face corrections (van Albada / van Leer,
`face_correction` now pub(crate)) directly in the explicit predictor —
no deferred iteration needed in an explicit step. Domain-side faces and
faces whose far-upwind node is outside fall back to upwind exactly as in
SIMPLE; near the body the stencil reads ghost values, which encode the
wall. Verified: the embedded-circle MMS error drops 10–16x below upwind
(8.16e-4 vs 8.49e-3 at n = 32) at observed order 1.56 (SIMPLE's TVD
measured 1.59–1.84).

tests/turek_hron_cfd23.rs — CFD2 (Re 100, steady) and CFD3 (Re 200,
periodic), both with the benchmark's inflow ramp, both measured as time
statistics over a window (never a snapshot), surface route primary and
the control volume printed as the diagnostic (its central-difference
evaluation truncation grows with the convective flux: the routes agree
to 0.6% at Re 20 and differ 15–25% at Re 100–200 on these grids).

Measured across h = 10 / 6.6 / 5 mm:
- CFD3 shedding frequency 4.2746 / 4.3400 / 4.3939 Hz vs the reference
  4.3956 — converging −2.8% -> −1.3% -> −0.04%;
- CFD3 lift mean −184 / +160 / −2.6 vs −11.9 — lands on the reference;
  lift amplitude ±438 / ±556 / ±557 vs ±437.8 — +27% at the finer grids,
  unconverged (the flag is 2/3/4 cells thick);
- CFD2 control-volume drag 152.4 / 143.3 / 139.4 vs 136.700 — +2.0% at
  5 mm; CFD2 surface drag sits ~−10% (the boundary layer is ~one cell);
  CFD2 lift −3.4 / +30.2 / +8.4 vs 10.53.
Suite defaults run CFD2 at ny = 62 and CFD3 at ny = 41 (cost); the
asserted bands are the measured ones (frequency 10%, mean drag 15%,
amplitude 35%), not accuracy claims; RTX_CFD2_NY / RTX_CFD3_NY run the
studies.

Also recorded: the CFD1 refinement study extended to h = 3.3 mm
(RTX_CFD1_NY): control-volume drag 14.8996 (+4.25%), apparent order
~0.70 sustained over four grids, control-volume lift 1.1332 vs 1.11905
(+1.3%).

rtx-cfd 318 -> 321 green (full suite 321 passed / 0 failed).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 11:37:04 -07:00
Omar SobhandClaude Fable 5 9969d8a661 feat(backend): add differentiable index_select / index_add row ops
Add two row-indexing ops along dim 0 to the `Backend` trait so gather /
scatter-add message passing (GNNs, segment softmax, bias tiling) can be
trained through `Autodiff<B>`:

- `index_select(tensor, indices)` — out[i, ..] = tensor[indices[i], ..]
- `index_add(tensor, indices, num_rows)` — out = zeros; out[idx[i], ..] += tensor[i, ..]

They are each other's adjoint, which is what the backward passes use.

Both trait methods have default bodies (host round-trip via to_data /
from_data) so every existing backend keeps compiling and is correct;
backends override with native kernels:

- rtx-backend-cpu: new ops/index.rs (rayon-parallel gather over output
  rows above a size threshold, sequential deterministic scatter-add),
  wired into CpuBackend and CpuBackendF64, with unit tests for D=1/2/3,
  duplicates, untouched rows, empty inputs, bounds panics and adjointness.
- rtx-autograd: Autodiff<B> overrides both ops and records
  IndexSelectBackward / IndexAddBackward (new ops/index.rs); finite-
  difference gradchecks on the real CpuBackend cover repeated-index
  accumulation, untouched-row zero grads, bias tiling via index_select
  of a [1,F] row, and a full per-segment softmax.
- rtx-fusion: forward both ops to the inner backend.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 11:05:42 -07:00
28 changed files with 3955 additions and 84 deletions
@@ -997,6 +997,54 @@ where
AutodiffTensor::with_node(result, node) AutodiffTensor::with_node(result, node)
} }
// ==================== Row Indexing ====================
fn index_select<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
) -> Self::TensorPrimitive<D> {
// Save indices + input row count for backward:
// grad_input = index_add(grad_output, indices, rows(input))
let num_rows = B::shape(&tensor.inner)[0];
let saved_indices: super::node::SavedTensor = Box::new(indices.to_vec());
let saved_rows: super::node::SavedTensor = Box::new(num_rows);
let tensor_inner = tensor.inner.clone();
let result = B::index_select(tensor_inner, indices);
if !is_grad_enabled() || !tensor.requires_grad() {
return AutodiffTensor::new(result);
}
let parents = create_parents_1(&tensor);
let backward_fn = Box::new(super::ops::IndexSelectBackward::<B, D>::new());
let node = AutodiffNode::new(parents, backward_fn, vec![saved_indices, saved_rows]);
AutodiffTensor::with_node(result, node)
}
fn index_add<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
num_rows: usize,
) -> Self::TensorPrimitive<D> {
// Save indices for backward: grad_input = index_select(grad_output, indices)
let saved_indices: super::node::SavedTensor = Box::new(indices.to_vec());
let tensor_inner = tensor.inner.clone();
let result = B::index_add(tensor_inner, indices, num_rows);
if !is_grad_enabled() || !tensor.requires_grad() {
return AutodiffTensor::new(result);
}
let parents = create_parents_1(&tensor);
let backward_fn = Box::new(super::ops::IndexAddBackward::<B, D>::new());
let node = AutodiffNode::new(parents, backward_fn, vec![saved_indices]);
AutodiffTensor::with_node(result, node)
}
// ==================== Activation Functions ==================== // ==================== Activation Functions ====================
fn relu<const D: usize>(tensor: Self::TensorPrimitive<D>) -> Self::TensorPrimitive<D> { fn relu<const D: usize>(tensor: Self::TensorPrimitive<D>) -> Self::TensorPrimitive<D> {
@@ -0,0 +1,164 @@
//! Backward functions for row-indexing operations (gather / scatter-add).
//!
//! ## Gradient Formulas
//!
//! `index_select` and `index_add` are each other's adjoint along dim 0:
//!
//! - `y = index_select(x, idx)` ⇒ `grad_x = index_add(grad_y, idx, rows(x))`
//! - `y = index_add(x, idx, num_rows)` ⇒ `grad_x = index_select(grad_y, idx)`
//!
//! Repeated indices in `index_select` mean a row of `x` fans out to several
//! rows of `y`, so its gradient is the *sum* of those rows — exactly what
//! `index_add` computes. Rows of the `index_add` output that no index touches
//! receive no gradient contribution, and `index_select` on the grad simply
//! never reads them.
use crate::autodiff::node::{AutodiffBackwardFn, GradTensor, SavedTensor};
use crate::autodiff::ops::into_dim;
use crate::error::{AutogradError, Result};
use rtx_backend::Backend;
use std::marker::PhantomData;
/// Backward for `index_select` (row gather along dim 0).
///
/// Saved tensors: `[0] = Vec<usize>` (indices), `[1] = usize` (rows of input).
pub struct IndexSelectBackward<B: Backend, const D: usize> {
_marker: PhantomData<B>,
}
impl<B: Backend, const D: usize> Default for IndexSelectBackward<B, D> {
fn default() -> Self {
Self::new()
}
}
impl<B: Backend, const D: usize> IndexSelectBackward<B, D> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<B: Backend, const D: usize> AutodiffBackwardFn<B> for IndexSelectBackward<B, D>
where
B::TensorPrimitive<D>: Clone,
B::TensorPrimitive<1>: Clone,
B::TensorPrimitive<2>: Clone,
B::TensorPrimitive<3>: Clone,
B::TensorPrimitive<4>: Clone,
B::TensorPrimitive<5>: Clone,
B::TensorPrimitive<6>: Clone,
{
fn backward(
&self,
grad_output: GradTensor<B>,
saved_tensors: &[SavedTensor],
) -> Result<Vec<Option<GradTensor<B>>>> {
let indices = saved_tensors[0]
.downcast_ref::<Vec<usize>>()
.ok_or_else(|| AutogradError::DowncastError("saved indices".to_string()))?;
let num_rows = saved_tensors[1]
.downcast_ref::<usize>()
.ok_or_else(|| AutogradError::DowncastError("saved num_rows".to_string()))?;
let grad = extract_tensor::<B, D>(&grad_output)?;
let grad_input = B::index_add(grad, indices, *num_rows);
Ok(vec![Some(wrap_tensor::<B, D>(grad_input)?)])
}
fn name(&self) -> &'static str {
"IndexSelectBackward"
}
}
/// Backward for `index_add` (row scatter-add along dim 0).
///
/// Saved tensors: `[0] = Vec<usize>` (indices).
pub struct IndexAddBackward<B: Backend, const D: usize> {
_marker: PhantomData<B>,
}
impl<B: Backend, const D: usize> Default for IndexAddBackward<B, D> {
fn default() -> Self {
Self::new()
}
}
impl<B: Backend, const D: usize> IndexAddBackward<B, D> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<B: Backend, const D: usize> AutodiffBackwardFn<B> for IndexAddBackward<B, D>
where
B::TensorPrimitive<D>: Clone,
B::TensorPrimitive<1>: Clone,
B::TensorPrimitive<2>: Clone,
B::TensorPrimitive<3>: Clone,
B::TensorPrimitive<4>: Clone,
B::TensorPrimitive<5>: Clone,
B::TensorPrimitive<6>: Clone,
{
fn backward(
&self,
grad_output: GradTensor<B>,
saved_tensors: &[SavedTensor],
) -> Result<Vec<Option<GradTensor<B>>>> {
let indices = saved_tensors[0]
.downcast_ref::<Vec<usize>>()
.ok_or_else(|| AutogradError::DowncastError("saved indices".to_string()))?;
let grad = extract_tensor::<B, D>(&grad_output)?;
let grad_input = B::index_select(grad, indices);
Ok(vec![Some(wrap_tensor::<B, D>(grad_input)?)])
}
fn name(&self) -> &'static str {
"IndexAddBackward"
}
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Pull the `D`-dimensional primitive out of a `GradTensor` whose runtime
/// variant matches `D`.
fn extract_tensor<B: Backend, const D: usize>(grad: &GradTensor<B>) -> Result<B::TensorPrimitive<D>>
where
B::TensorPrimitive<1>: Clone,
B::TensorPrimitive<2>: Clone,
B::TensorPrimitive<3>: Clone,
B::TensorPrimitive<4>: Clone,
B::TensorPrimitive<5>: Clone,
B::TensorPrimitive<6>: Clone,
{
match (grad, D) {
(GradTensor::D1(t), 1) => Ok(into_dim::<B, 1, D>(t.clone())),
(GradTensor::D2(t), 2) => Ok(into_dim::<B, 2, D>(t.clone())),
(GradTensor::D3(t), 3) => Ok(into_dim::<B, 3, D>(t.clone())),
(GradTensor::D4(t), 4) => Ok(into_dim::<B, 4, D>(t.clone())),
(GradTensor::D5(t), 5) => Ok(into_dim::<B, 5, D>(t.clone())),
(GradTensor::D6(t), 6) => Ok(into_dim::<B, 6, D>(t.clone())),
_ => Err(AutogradError::DimensionMismatch(
"index operation backward".to_string(),
)),
}
}
/// Wrap a `D`-dimensional primitive in the matching `GradTensor` variant.
fn wrap_tensor<B: Backend, const D: usize>(tensor: B::TensorPrimitive<D>) -> Result<GradTensor<B>> {
match D {
1 => Ok(GradTensor::D1(into_dim::<B, D, 1>(tensor))),
2 => Ok(GradTensor::D2(into_dim::<B, D, 2>(tensor))),
3 => Ok(GradTensor::D3(into_dim::<B, D, 3>(tensor))),
4 => Ok(GradTensor::D4(into_dim::<B, D, 4>(tensor))),
5 => Ok(GradTensor::D5(into_dim::<B, D, 5>(tensor))),
6 => Ok(GradTensor::D6(into_dim::<B, D, 6>(tensor))),
_ => Err(AutogradError::UnsupportedDimension(D)),
}
}
@@ -13,10 +13,12 @@
//! - `shapes.rs`: Reshape, Transpose, SwapDims //! - `shapes.rs`: Reshape, Transpose, SwapDims
//! - `llm.rs`: Softmax, LayerNorm, RMSNorm, RoPE, FlashAttention //! - `llm.rs`: Softmax, LayerNorm, RMSNorm, RoPE, FlashAttention
//! - `conv.rs`: Conv1d, Conv2d, Conv3d //! - `conv.rs`: Conv1d, Conv2d, Conv3d
//! - `index.rs`: IndexSelect, IndexAdd (row gather / scatter-add)
mod activations; mod activations;
mod basic; mod basic;
mod conv; mod conv;
mod index;
mod llm; mod llm;
mod matmul; mod matmul;
mod reductions; mod reductions;
@@ -62,6 +64,7 @@ pub use activations::{
}; };
pub use basic::{AddBackward, DivBackward, MulBackward, NegBackward, SubBackward}; pub use basic::{AddBackward, DivBackward, MulBackward, NegBackward, SubBackward};
pub use conv::{Conv1dBackward, Conv2dBackward, Conv3dBackward, ConvConfig}; pub use conv::{Conv1dBackward, Conv2dBackward, Conv3dBackward, ConvConfig};
pub use index::{IndexAddBackward, IndexSelectBackward};
pub use llm::{ pub use llm::{
FlashAttentionBackward, LayerNormBackward, RmsNormBackward, RopeBackward, SoftmaxBackward, FlashAttentionBackward, LayerNormBackward, RmsNormBackward, RopeBackward, SoftmaxBackward,
}; };
@@ -218,11 +218,20 @@ where
.downcast_ref::<B::TensorPrimitive<D>>() .downcast_ref::<B::TensorPrimitive<D>>()
.ok_or_else(|| AutogradError::DowncastError("saved input".to_string()))?; .ok_or_else(|| AutogradError::DowncastError("saved input".to_string()))?;
// Compute sign: x / |x| // Compute sign as x / (|x| + tiny): -1 for negative, +1 for positive,
// This gives -1 for negative, +1 for positive, and 0/0=NaN for zero // and — crucially — 0 for zero. The previous x / |x| form produced
// We handle this by computing: grad * (input / abs(input)) // 0/0 = NaN whenever an input element was exactly zero (|x| is
// non-differentiable there; the subgradient convention is sign(0)=0),
// and a single such element poisoned every upstream gradient. The
// tiny denominator offset only perturbs the sign of subnormal inputs.
use rtx_backend::FloatElement;
let abs_input = B::abs(input.clone()); let abs_input = B::abs(input.clone());
let sign = B::div(input.clone(), abs_input); let tiny = B::full(
B::shape(input),
B::FloatElem::from_f64(f64::from(f32::MIN_POSITIVE)),
&B::device(input),
);
let sign = B::div(input.clone(), B::add(abs_input, tiny));
// grad_input = grad_output * sign // grad_input = grad_output * sign
let grad_input = mul_grad::<B, D>(grad_output, &sign)?; let grad_input = mul_grad::<B, D>(grad_output, &sign)?;
@@ -265,3 +265,259 @@ fn gradcheck_gated_update() {
let numerical = fd_grad(&a, loss_of_a); let numerical = fd_grad(&a, loss_of_a);
assert_close(&analytical, &numerical, "gated_update d/d(gate logits)"); assert_close(&analytical, &numerical, "gated_update d/d(gate logits)");
} }
// ============================================================================
// Row indexing: index_select (gather) / index_add (scatter-add)
// ============================================================================
/// `loss = sum(index_select(X, idx) ⊙ W)` with repeated indices — checks the
/// gather VJP, specifically that a row gathered several times accumulates the
/// sum of its downstream gradients (index_add as the adjoint), and that rows
/// never gathered receive exactly zero.
#[test]
fn gradcheck_index_select_sum() {
let x = [0.5f32, -1.0, 2.0, 0.25, -0.5, 1.5, 3.0, -2.0]; // [4,2]
let idx = [2usize, 0, 2, 3, 0, 2]; // row 1 never gathered; row 2 thrice
let w: Vec<f32> = (0..idx.len() * 2).map(|i| 0.3 * i as f32 - 1.0).collect(); // [6,2]
let loss_of_x = |xv: &[f32]| -> f32 {
let xt = CpuBackend::from_data(xv, [4, 2], &cpu_dev());
let wt = CpuBackend::from_data(&w, [6, 2], &cpu_dev());
let g = CpuBackend::index_select(xt, &idx);
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::mul(g, wt)))[0]
};
let xt = leaf2(&x, [4, 2]);
let wt = Ad::from_data(&w, [6, 2], &ad_dev());
let x_id = xt.id().0;
let g = Ad::index_select(xt, &idx);
let loss = Ad::sum(Ad::mul(g, wt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, x_id);
let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "index_select_sum d/dX");
// Row 1 was never gathered → its gradient must be exactly zero.
assert_eq!(
&analytical[2..4],
&[0.0, 0.0],
"ungathered row gets zero grad"
);
// Row 2 was gathered three times → grad is the sum of three W rows.
let expected_row2: Vec<f32> = (0..2)
.map(|c| w[0 * 2 + c] + w[2 * 2 + c] + w[5 * 2 + c])
.collect();
assert_close(
&analytical[4..6],
&expected_row2,
"index_select_sum fan-out row",
);
}
/// `loss = sum(index_add(X, idx, n) ⊙ W)` — checks the scatter-add VJP
/// (index_select as the adjoint). Some output rows are never written, so W's
/// values there must not leak into any input gradient.
#[test]
fn gradcheck_index_add_sum() {
let x = [0.5f32, -1.0, 2.0, 0.25, -0.5, 1.5, 3.0, -2.0, 0.75, 1.25]; // [5,2]
let idx = [3usize, 0, 3, 3, 1]; // output rows 2 and 4 untouched
let n = 6;
let w: Vec<f32> = (0..n * 2).map(|i| 0.7 * (i as f32).cos()).collect(); // [6,2]
let loss_of_x = |xv: &[f32]| -> f32 {
let xt = CpuBackend::from_data(xv, [5, 2], &cpu_dev());
let wt = CpuBackend::from_data(&w, [n, 2], &cpu_dev());
let s = CpuBackend::index_add(xt, &idx, n);
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::mul(s, wt)))[0]
};
let xt = leaf2(&x, [5, 2]);
let wt = Ad::from_data(&w, [n, 2], &ad_dev());
let x_id = xt.id().0;
let s = Ad::index_add(xt, &idx, n);
assert_eq!(Ad::shape(&s), [n, 2]);
let loss = Ad::sum(Ad::mul(s, wt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, x_id);
let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "index_add_sum d/dX");
// Input row i's grad is exactly W[idx[i]].
for (i, &r) in idx.iter().enumerate() {
assert_close(
&analytical[i * 2..i * 2 + 2],
&w[r * 2..r * 2 + 2],
"index_add row grad",
);
}
}
/// Bias tiling: `index_select` of a `[1, F]` tensor with `&[0; N]` broadcasts
/// a bias row to N rows; its gradient must be the column-sum of the upstream
/// gradient (all N rows accumulate into the single source row).
#[test]
fn gradcheck_bias_tile_via_index_select() {
let b = [0.5f32, -1.0, 2.0]; // [1,3]
let n = 4;
let idx = [0usize; 4];
let w: Vec<f32> = (0..n * 3).map(|i| 0.25 * i as f32 - 1.0).collect(); // [4,3]
let loss_of_b = |bv: &[f32]| -> f32 {
let bt = CpuBackend::from_data(bv, [1, 3], &cpu_dev());
let wt = CpuBackend::from_data(&w, [n, 3], &cpu_dev());
let tiled = CpuBackend::index_select(bt, &idx);
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::mul(tiled, wt)))[0]
};
let bt = leaf2(&b, [1, 3]);
let wt = Ad::from_data(&w, [n, 3], &ad_dev());
let b_id = bt.id().0;
let tiled = Ad::index_select(bt, &idx);
assert_eq!(Ad::shape(&tiled), [n, 3]);
let loss = Ad::sum(Ad::mul(tiled, wt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let analytical = grad2(&storage, b_id);
let numerical = fd_grad(&b, loss_of_b);
assert_close(&analytical, &numerical, "bias_tile d/dB");
let colsum: Vec<f32> = (0..3).map(|c| (0..n).map(|r| w[r * 3 + c]).sum()).collect();
assert_close(&analytical, &colsum, "bias_tile equals column-sum of W");
}
/// Per-segment softmax built purely from tape ops plus a host-computed
/// (constant) per-segment max shift — the attention-coefficient pattern for
/// graph message passing:
///
/// ```text
/// shift = max over segment (host, constant) [E, F]
/// z = exp(X - shift) [E, F]
/// denom = index_add(z, seg, S) [S, F]
/// y = z / index_select(denom, seg) [E, F]
/// loss = sum(y ⊙ W)
/// ```
///
/// Checks the gradient wrt the logits `X` through exp → index_add →
/// index_select → div, including the fan-out of `z` into both the numerator
/// and the denominator.
#[test]
fn gradcheck_segment_softmax() {
// 6 "edges" grouped into 3 segments, 2 feature columns.
let seg = [0usize, 1, 0, 2, 1, 0];
let num_segments = 3;
let e = seg.len();
let f = 2;
let x = [
0.2f32, -0.5, // e0 (seg 0)
1.0, 0.3, // e1 (seg 1)
0.8, -1.2, // e2 (seg 0)
-0.4, 0.9, // e3 (seg 2)
0.1, 1.7, // e4 (seg 1)
-1.1, 0.6, // e5 (seg 0)
];
let w: Vec<f32> = (0..e * f)
.map(|i| 0.5 * (i as f32 * 1.3).sin() + 0.2)
.collect();
// Host-side per-segment, per-column max, expanded back to [E, F].
let shift_of = |xv: &[f32]| -> Vec<f32> {
let mut seg_max = vec![f32::NEG_INFINITY; num_segments * f];
for (i, &s) in seg.iter().enumerate() {
for c in 0..f {
let m = &mut seg_max[s * f + c];
*m = m.max(xv[i * f + c]);
}
}
let mut out = vec![0.0f32; e * f];
for (i, &s) in seg.iter().enumerate() {
out[i * f..(i + 1) * f].copy_from_slice(&seg_max[s * f..(s + 1) * f]);
}
out
};
let loss_of_x = |xv: &[f32]| -> f32 {
let dev = cpu_dev();
let xt = CpuBackend::from_data(xv, [e, f], &dev);
let shift = CpuBackend::from_data(&shift_of(xv), [e, f], &dev);
let z = CpuBackend::exp(CpuBackend::sub(xt, shift));
let denom = CpuBackend::index_add(z.clone(), &seg, num_segments);
let denom_e = CpuBackend::index_select(denom, &seg);
let y = CpuBackend::div(z, denom_e);
let wt = CpuBackend::from_data(&w, [e, f], &dev);
CpuBackend::to_data(&CpuBackend::sum(CpuBackend::mul(y, wt)))[0]
};
let xt = leaf2(&x, [e, f]);
let x_id = xt.id().0;
let shift = Ad::from_data(&shift_of(&x), [e, f], &ad_dev()); // constant, no grad
let z = Ad::exp(Ad::sub(xt, shift));
let denom = Ad::index_add(z.clone(), &seg, num_segments);
assert_eq!(Ad::shape(&denom), [num_segments, f]);
let denom_e = Ad::index_select(denom, &seg);
assert_eq!(Ad::shape(&denom_e), [e, f]);
let y = Ad::div(z, denom_e);
let wt = Ad::from_data(&w, [e, f], &ad_dev());
let loss = Ad::sum(Ad::mul(y.clone(), wt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
// Forward sanity: each segment's column sums to 1.
let y_host = CpuBackend::to_data(y.inner());
for s in 0..num_segments {
for c in 0..f {
let total: f32 = seg
.iter()
.enumerate()
.filter(|(_, ss)| **ss == s)
.map(|(i, _)| y_host[i * f + c])
.sum();
assert!(
(total - 1.0).abs() < 1e-5,
"segment {s} col {c} sums to {total}"
);
}
}
let analytical = grad2(&storage, x_id);
let numerical = fd_grad(&x, loss_of_x);
assert_close(&analytical, &numerical, "segment_softmax d/dX");
}
/// `abs` backward at an exactly-zero input element must yield gradient 0 for
/// that element (subgradient convention), never NaN. Regression: the previous
/// sign = x/|x| produced 0/0 = NaN and poisoned every upstream gradient
/// (surfaced by dg-gnn HetGAT training, where |pred target| hits exact
/// zeros over long runs).
#[test]
fn abs_backward_zero_input_is_zero_not_nan() {
let x = [0.5f32, 0.0, -2.0, 0.0, 1.0e-30, -0.0];
let xt = leaf2(&x, [3, 2]);
let x_id = xt.id().0;
let loss = Ad::sum(Ad::abs(xt));
let storage = backward_impl(
&loss,
Some(GradTensor::from_d1(CpuBackend::ones([1], &cpu_dev()))),
)
.expect("backward");
let g = grad2(&storage, x_id);
assert!(g.iter().all(|v| v.is_finite()), "abs grad has non-finite values: {g:?}");
assert_eq!(g[0], 1.0);
assert_eq!(g[1], 0.0, "sign(0) must be 0");
assert_eq!(g[2], -1.0);
assert_eq!(g[3], 0.0);
assert_eq!(g[5], 0.0, "sign(-0.0) must be 0");
}
+53
View File
@@ -289,6 +289,23 @@ impl Backend for CpuBackend {
ops::shape::swap_dims(&tensor, dim1, dim2) ops::shape::swap_dims(&tensor, dim1, dim2)
} }
// ==================== Row Indexing ====================
fn index_select<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
) -> Self::TensorPrimitive<D> {
ops::index::index_select(&tensor, indices)
}
fn index_add<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
num_rows: usize,
) -> Self::TensorPrimitive<D> {
ops::index::index_add(&tensor, indices, num_rows)
}
// ==================== LLM-Specific Operations ==================== // ==================== LLM-Specific Operations ====================
fn flash_attention( fn flash_attention(
@@ -641,6 +658,23 @@ impl Backend for CpuBackendF64 {
ops::shape::swap_dims(&tensor, dim1, dim2) ops::shape::swap_dims(&tensor, dim1, dim2)
} }
// ==================== Row Indexing ====================
fn index_select<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
) -> Self::TensorPrimitive<D> {
ops::index::index_select(&tensor, indices)
}
fn index_add<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
num_rows: usize,
) -> Self::TensorPrimitive<D> {
ops::index::index_add(&tensor, indices, num_rows)
}
fn flash_attention( fn flash_attention(
query: Self::TensorPrimitive<4>, query: Self::TensorPrimitive<4>,
key: Self::TensorPrimitive<4>, key: Self::TensorPrimitive<4>,
@@ -772,6 +806,25 @@ pub type CpuInference = CpuBackend;
mod f64_tests { mod f64_tests {
use super::*; use super::*;
#[test]
fn backend_trait_index_ops_f32_and_f64() {
let dev = CpuDevice::new();
let x = CpuBackend::from_data(&[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0], [3, 2], &dev);
let g = <CpuBackend as Backend>::index_select(x, &[2, 2, 0]);
assert_eq!(CpuBackend::shape(&g), [3, 2]);
assert_eq!(g.to_vec(), vec![5.0, 6.0, 5.0, 6.0, 1.0, 2.0]);
let s = <CpuBackend as Backend>::index_add(g, &[1, 1, 3], 4);
assert_eq!(CpuBackend::shape(&s), [4, 2]);
assert_eq!(s.to_vec(), vec![0.0, 0.0, 10.0, 12.0, 0.0, 0.0, 1.0, 2.0]);
let x = CpuBackendF64::from_data(&[1.0_f64, 2.0, 3.0, 4.0], [2, 2], &dev);
let g = <CpuBackendF64 as Backend>::index_select(x, &[1, 0, 1]);
assert_eq!(g.to_vec(), vec![3.0, 4.0, 1.0, 2.0, 3.0, 4.0]);
let s = <CpuBackendF64 as Backend>::index_add(g, &[0, 0, 2], 3);
assert_eq!(s.to_vec(), vec![4.0, 6.0, 0.0, 0.0, 3.0, 4.0]);
}
#[test] #[test]
fn cpu_backend_f64_add_and_matmul() { fn cpu_backend_f64_add_and_matmul() {
let dev = CpuDevice::new(); let dev = CpuDevice::new();
+22 -15
View File
@@ -20,26 +20,33 @@ pub fn matmul<E: CpuFloat>(
// Block size for cache efficiency // Block size for cache efficiency
const BLOCK_SIZE: usize = 64; const BLOCK_SIZE: usize = 64;
// Blocked matrix multiplication // Blocked matrix multiplication, parallelized over row blocks: each rayon
for i_block in (0..m).step_by(BLOCK_SIZE) { // task owns a disjoint `BLOCK_SIZE`-row slice of the result, so the inner
for j_block in (0..n).step_by(BLOCK_SIZE) { // blocked kernel is unchanged and no synchronization is needed.
for k_block in (0..k).step_by(BLOCK_SIZE) { result
let i_end = (i_block + BLOCK_SIZE).min(m); .par_chunks_mut(BLOCK_SIZE * n)
let j_end = (j_block + BLOCK_SIZE).min(n); .enumerate()
let k_end = (k_block + BLOCK_SIZE).min(k); .for_each(|(bi, res_rows)| {
let i_block = bi * BLOCK_SIZE;
let i_end = (i_block + BLOCK_SIZE).min(m);
for j_block in (0..n).step_by(BLOCK_SIZE) {
for k_block in (0..k).step_by(BLOCK_SIZE) {
let j_end = (j_block + BLOCK_SIZE).min(n);
let k_end = (k_block + BLOCK_SIZE).min(k);
for i in i_block..i_end { for i in i_block..i_end {
for j in j_block..j_end { let row = &mut res_rows[(i - i_block) * n..(i - i_block) * n + n];
let mut sum = result[i * n + j]; for j in j_block..j_end {
for kk in k_block..k_end { let mut sum = row[j];
sum = sum + lhs.data[i * k + kk] * rhs.data[kk * n + j]; for kk in k_block..k_end {
sum = sum + lhs.data[i * k + kk] * rhs.data[kk * n + j];
}
row[j] = sum;
} }
result[i * n + j] = sum;
} }
} }
} }
} });
}
CpuTensorPrimitive::new(result, [m, n], lhs.device.clone()) CpuTensorPrimitive::new(result, [m, n], lhs.device.clone())
} }
@@ -0,0 +1,265 @@
//! Row indexing operations: gather (`index_select`) and scatter-add (`index_add`).
//!
//! Both operate along dim 0 and treat every trailing dimension as a flat,
//! contiguous "row" of `shape[1..].product()` elements. They are each other's
//! adjoint, which is what lets a graph message-passing layer be trained with
//! `Autodiff<CpuBackend>`:
//!
//! - `index_select(x, idx)[i, ..] = x[idx[i], ..]`
//! - `index_add(x, idx, n)[idx[i], ..] += x[i, ..]` (starting from zeros)
//!
//! `CpuTensorPrimitive` data is always stored contiguously in row-major order
//! (every op materialises a fresh contiguous buffer), so slicing rows directly
//! out of `data` is valid.
use crate::{CpuFloat, CpuTensorPrimitive};
use rayon::prelude::*;
/// Minimum number of output elements before the gather goes parallel.
const PAR_THRESHOLD: usize = 1 << 12;
/// Gather rows along dim 0: `out[i, ..] = tensor[indices[i], ..]`.
///
/// Output shape is `[indices.len(), shape[1..]]`. Indices may repeat.
///
/// # Panics
/// Panics if any index is out of range or `D == 0`.
pub fn index_select<const D: usize, E: CpuFloat>(
tensor: &CpuTensorPrimitive<D, E>,
indices: &[usize],
) -> CpuTensorPrimitive<D, E> {
assert!(D >= 1, "index_select requires at least one dimension");
let num_rows = tensor.shape[0];
let row_len: usize = tensor.shape[1..].iter().product();
for &idx in indices {
assert!(
idx < num_rows,
"index_select: index {idx} out of range for {num_rows} rows"
);
}
let src = &tensor.data;
let total = indices.len() * row_len;
let mut out: Vec<E> = vec![E::zero(); total];
if row_len > 0 {
if total >= PAR_THRESHOLD {
out.par_chunks_mut(row_len)
.zip(indices.par_iter())
.for_each(|(dst, &idx)| {
dst.copy_from_slice(&src[idx * row_len..(idx + 1) * row_len]);
});
} else {
for (dst, &idx) in out.chunks_mut(row_len).zip(indices) {
dst.copy_from_slice(&src[idx * row_len..(idx + 1) * row_len]);
}
}
}
let mut out_shape = tensor.shape;
out_shape[0] = indices.len();
CpuTensorPrimitive::new(out, out_shape, tensor.device.clone())
}
/// Scatter-add rows along dim 0 into a zero tensor with `num_rows` rows:
/// `out[indices[i], ..] += tensor[i, ..]`.
///
/// Indices may repeat (contributions accumulate); rows never referenced stay
/// zero. Sequential so that repeated indices accumulate deterministically.
///
/// # Panics
/// Panics if `indices.len() != shape[0]`, any index is `>= num_rows`, or `D == 0`.
pub fn index_add<const D: usize, E: CpuFloat>(
tensor: &CpuTensorPrimitive<D, E>,
indices: &[usize],
num_rows: usize,
) -> CpuTensorPrimitive<D, E> {
assert!(D >= 1, "index_add requires at least one dimension");
assert_eq!(
indices.len(),
tensor.shape[0],
"index_add: indices.len() must equal the number of input rows"
);
let row_len: usize = tensor.shape[1..].iter().product();
let src = &tensor.data;
let mut out: Vec<E> = vec![E::zero(); num_rows * row_len];
for (i, &idx) in indices.iter().enumerate() {
assert!(
idx < num_rows,
"index_add: index {idx} out of range for {num_rows} rows"
);
let dst = &mut out[idx * row_len..(idx + 1) * row_len];
let row = &src[i * row_len..(i + 1) * row_len];
for (d, &s) in dst.iter_mut().zip(row) {
*d = *d + s;
}
}
let mut out_shape = tensor.shape;
out_shape[0] = num_rows;
CpuTensorPrimitive::new(out, out_shape, tensor.device.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CpuDevice;
fn t2(data: &[f32], shape: [usize; 2]) -> CpuTensorPrimitive<2> {
CpuTensorPrimitive::new(data.to_vec(), shape, CpuDevice::new())
}
#[test]
fn index_select_2d_gathers_rows_with_duplicates() {
// 3 rows x 2 cols
let x = t2(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [3, 2]);
let y = index_select(&x, &[2, 0, 2, 1]);
assert_eq!(y.shape(), &[4, 2]);
assert_eq!(y.to_vec(), vec![5.0, 6.0, 1.0, 2.0, 5.0, 6.0, 3.0, 4.0]);
}
#[test]
fn index_select_empty_indices_gives_zero_rows() {
let x = t2(&[1.0, 2.0, 3.0, 4.0], [2, 2]);
let y = index_select(&x, &[]);
assert_eq!(y.shape(), &[0, 2]);
assert!(y.to_vec().is_empty());
}
#[test]
fn index_select_1d_gathers_scalars() {
let x = CpuTensorPrimitive::<1>::new(vec![10.0, 20.0, 30.0], [3], CpuDevice::new());
let y = index_select(&x, &[1, 1, 0]);
assert_eq!(y.shape(), &[3]);
assert_eq!(y.to_vec(), vec![20.0, 20.0, 10.0]);
}
#[test]
fn index_select_3d_gathers_whole_slabs() {
// shape [2, 2, 2]
let x = CpuTensorPrimitive::<3>::new(
(0..8).map(|v| v as f32).collect(),
[2, 2, 2],
CpuDevice::new(),
);
let y = index_select(&x, &[1, 0]);
assert_eq!(y.shape(), &[2, 2, 2]);
assert_eq!(y.to_vec(), vec![4.0, 5.0, 6.0, 7.0, 0.0, 1.0, 2.0, 3.0]);
}
#[test]
fn index_select_large_goes_parallel_and_matches() {
let rows = 300;
let cols = 32;
let data: Vec<f32> = (0..rows * cols).map(|v| v as f32).collect();
let x = t2(&data, [rows, cols]);
let idx: Vec<usize> = (0..rows * 2).map(|i| (i * 7) % rows).collect();
let y = index_select(&x, &idx);
assert_eq!(y.shape(), &[rows * 2, cols]);
let out = y.to_vec();
for (i, &r) in idx.iter().enumerate() {
assert_eq!(
&out[i * cols..(i + 1) * cols],
&data[r * cols..(r + 1) * cols]
);
}
}
#[test]
#[should_panic(expected = "out of range")]
fn index_select_out_of_range_panics() {
let x = t2(&[1.0, 2.0, 3.0, 4.0], [2, 2]);
let _ = index_select(&x, &[2]);
}
#[test]
fn index_add_2d_accumulates_duplicates_and_leaves_untouched_zero() {
// 4 input rows x 2 cols scattered into 3 output rows; row 1 untouched.
let x = t2(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], [4, 2]);
let y = index_add(&x, &[2, 0, 2, 0], 3);
assert_eq!(y.shape(), &[3, 2]);
assert_eq!(
y.to_vec(),
vec![3.0 + 7.0, 4.0 + 8.0, 0.0, 0.0, 1.0 + 5.0, 2.0 + 6.0]
);
}
#[test]
fn index_add_1d_accumulates() {
let x = CpuTensorPrimitive::<1>::new(vec![1.0, 2.0, 3.0], [3], CpuDevice::new());
let y = index_add(&x, &[1, 1, 0], 4);
assert_eq!(y.shape(), &[4]);
assert_eq!(y.to_vec(), vec![3.0, 3.0, 0.0, 0.0]);
}
#[test]
fn index_add_3d_accumulates_slabs() {
let x = CpuTensorPrimitive::<3>::new(
(0..8).map(|v| v as f32).collect(),
[2, 2, 2],
CpuDevice::new(),
);
let y = index_add(&x, &[0, 0], 2);
assert_eq!(y.shape(), &[2, 2, 2]);
assert_eq!(y.to_vec(), vec![4.0, 6.0, 8.0, 10.0, 0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn index_add_empty_input_gives_zeros() {
let x = t2(&[], [0, 3]);
let y = index_add(&x, &[], 2);
assert_eq!(y.shape(), &[2, 3]);
assert_eq!(y.to_vec(), vec![0.0; 6]);
}
#[test]
#[should_panic(expected = "indices.len()")]
fn index_add_len_mismatch_panics() {
let x = t2(&[1.0, 2.0, 3.0, 4.0], [2, 2]);
let _ = index_add(&x, &[0], 2);
}
#[test]
#[should_panic(expected = "out of range")]
fn index_add_out_of_range_panics() {
let x = t2(&[1.0, 2.0, 3.0, 4.0], [2, 2]);
let _ = index_add(&x, &[0, 5], 2);
}
#[test]
fn select_and_add_are_adjoint() {
// <index_select(x, idx), g> == <x, index_add(g, idx, n)>
let n = 5;
let cols = 3;
let x_data: Vec<f32> = (0..n * cols).map(|v| (v as f32) * 0.5 - 2.0).collect();
let x = t2(&x_data, [n, cols]);
let idx = [4usize, 0, 4, 2, 2, 1];
let g_data: Vec<f32> = (0..idx.len() * cols).map(|v| (v as f32).sin()).collect();
let g = t2(&g_data, [idx.len(), cols]);
let lhs: f32 = index_select(&x, &idx)
.to_vec()
.iter()
.zip(&g_data)
.map(|(a, b)| a * b)
.sum();
let rhs: f32 = index_add(&g, &idx, n)
.to_vec()
.iter()
.zip(&x_data)
.map(|(a, b)| a * b)
.sum();
assert!((lhs - rhs).abs() < 1e-4, "lhs={lhs} rhs={rhs}");
}
#[test]
fn f64_path_works() {
let x =
CpuTensorPrimitive::<2, f64>::new(vec![1.0, 2.0, 3.0, 4.0], [2, 2], CpuDevice::new());
let y = index_select(&x, &[1, 1]);
assert_eq!(y.to_vec(), vec![3.0, 4.0, 3.0, 4.0]);
let z = index_add(&y, &[0, 0], 3);
assert_eq!(z.to_vec(), vec![6.0, 8.0, 0.0, 0.0, 0.0, 0.0]);
}
}
@@ -8,6 +8,7 @@ pub mod basic;
pub mod conv; pub mod conv;
pub mod creation; pub mod creation;
pub mod gemm; pub mod gemm;
pub mod index;
pub mod normalization; pub mod normalization;
pub mod pooling; pub mod pooling;
pub mod reduction; pub mod reduction;
+78
View File
@@ -280,6 +280,84 @@ pub trait Backend: Clone + Send + Sync + Debug + Default + 'static {
dim2: usize, dim2: usize,
) -> Self::TensorPrimitive<D>; ) -> Self::TensorPrimitive<D>;
// ==================== Row Indexing (gather / scatter-add) ====================
// Differentiable row indexing along dim 0. These two ops are each other's
// adjoint, which is exactly what message passing on a graph needs:
// d/dx index_select(x, idx) = index_add(grad, idx, rows(x))
// d/dx index_add(x, idx, num_rows) = index_select(grad, idx)
//
// Both have default bodies that round-trip through host memory via
// `to_data` / `from_data`, so every backend is correct out of the box;
// backends override them with native kernels for speed.
/// Gather rows along dim 0: `out[i, ..] = tensor[indices[i], ..]`.
///
/// Output shape is `[indices.len(), shape[1..]]`. Indices may repeat.
///
/// # Panics
/// Panics if any index is `>= shape[0]`, or if `D == 0`.
fn index_select<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
) -> Self::TensorPrimitive<D> {
let shape = Self::shape(&tensor);
assert!(D >= 1, "index_select requires at least one dimension");
let num_rows = shape[0];
let row_len: usize = shape[1..].iter().product();
let src = Self::to_data(&tensor);
let mut out = Vec::with_capacity(indices.len() * row_len);
for &idx in indices {
assert!(
idx < num_rows,
"index_select: index {idx} out of range for {num_rows} rows"
);
out.extend_from_slice(&src[idx * row_len..(idx + 1) * row_len]);
}
let mut out_shape = shape;
out_shape[0] = indices.len();
Self::from_data(&out, out_shape, &Self::device(&tensor))
}
/// Scatter-add rows along dim 0 into a zero tensor with `num_rows` rows:
/// `out = zeros([num_rows, shape[1..]]); out[indices[i], ..] += tensor[i, ..]`.
///
/// Indices may repeat (contributions accumulate); rows never referenced
/// stay zero. This is the adjoint of [`Backend::index_select`].
///
/// # Panics
/// Panics if `indices.len() != shape[0]`, if any index is `>= num_rows`,
/// or if `D == 0`.
fn index_add<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
num_rows: usize,
) -> Self::TensorPrimitive<D> {
let shape = Self::shape(&tensor);
assert!(D >= 1, "index_add requires at least one dimension");
assert_eq!(
indices.len(),
shape[0],
"index_add: indices.len() must equal the number of input rows"
);
let row_len: usize = shape[1..].iter().product();
let src = Self::to_data(&tensor);
let mut out = vec![Self::FloatElem::zero(); num_rows * row_len];
for (i, &idx) in indices.iter().enumerate() {
assert!(
idx < num_rows,
"index_add: index {idx} out of range for {num_rows} rows"
);
let dst = &mut out[idx * row_len..(idx + 1) * row_len];
let row = &src[i * row_len..(i + 1) * row_len];
for (d, &s) in dst.iter_mut().zip(row) {
*d = Self::FloatElem::from_f64(d.to_f64() + s.to_f64());
}
}
let mut out_shape = shape;
out_shape[0] = num_rows;
Self::from_data(&out, out_shape, &Self::device(&tensor))
}
// ==================== LLM-Specific Operations ==================== // ==================== LLM-Specific Operations ====================
// These delegate to hand-optimized kernels for maximum performance. // These delegate to hand-optimized kernels for maximum performance.
+23
View File
@@ -566,6 +566,29 @@ impl<B: Backend> Backend for Fusion<B> {
FusionTensor::from_primitive(result) FusionTensor::from_primitive(result)
} }
// ==================== Row Indexing (Sync Points) ====================
// Forward straight to the inner backend so its native gather / scatter
// kernels are used rather than the trait's host round-trip default.
fn index_select<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
) -> Self::TensorPrimitive<D> {
let inner = tensor.into_primitive();
let result = B::index_select(inner, indices);
FusionTensor::from_primitive(result)
}
fn index_add<const D: usize>(
tensor: Self::TensorPrimitive<D>,
indices: &[usize],
num_rows: usize,
) -> Self::TensorPrimitive<D> {
let inner = tensor.into_primitive();
let result = B::index_add(inner, indices, num_rows);
FusionTensor::from_primitive(result)
}
// ==================== LLM Operations (Sync Points) ==================== // ==================== LLM Operations (Sync Points) ====================
fn flash_attention( fn flash_attention(
@@ -39,6 +39,7 @@
use super::ale::{AleBoundaries, SideBoundary}; use super::ale::{AleBoundaries, SideBoundary};
use super::embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind}; use super::embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind};
use super::poisson::{MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg}; use super::poisson::{MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg};
use super::simple::ConvectionScheme;
use super::{FlowField, SolverResult}; use super::{FlowField, SolverResult};
use crate::{CfdConfig, CfdError, CfdResult}; use crate::{CfdConfig, CfdError, CfdResult};
@@ -60,6 +61,18 @@ pub struct EmbeddedParameters {
/// [`PoissonSolverKind::Sor`]). Both solve the same system to the same /// [`PoissonSolverKind::Sor`]). Both solve the same system to the same
/// true-residual stop; multigrid's cost is mesh-independent. /// true-residual stop; multigrid's cost is mesh-independent.
pub poisson_solver: PoissonSolverKind, pub poisson_solver: PoissonSolverKind,
/// Convective face values in the explicit predictor (default
/// [`ConvectionScheme::Upwind`], which is bit-identical to the fixed-grid
/// PISO). The TVD schemes add SIMPLE's limited correction to each
/// interior face — with an explicit predictor no deferred iteration is
/// needed, the limited flux is just used directly. First-order upwind's
/// numerical viscosity `|u| h / 2` exceeds the physical viscosity ten
/// times over on the TurekHron CFD3 grids and suppressed the vortex
/// shedding entirely; the limited scheme restores it. Faces whose
/// far-upwind node lies outside the domain, and domain-side faces, fall
/// back to pure upwind exactly as in SIMPLE; near the body the stencil
/// reads ghost values, which encode the wall.
pub convection_scheme: ConvectionScheme,
} }
impl Default for EmbeddedParameters { impl Default for EmbeddedParameters {
@@ -69,10 +82,19 @@ impl Default for EmbeddedParameters {
tolerance: 1e-6, tolerance: 1e-6,
boundaries: AleBoundaries::default(), boundaries: AleBoundaries::default(),
poisson_solver: PoissonSolverKind::Sor, poisson_solver: PoissonSolverKind::Sor,
convection_scheme: ConvectionScheme::Upwind,
} }
} }
} }
/// A snapshot of [`EmbeddedPisoSolver`]'s per-step state, for re-running a
/// step within a coupling subiteration. See [`EmbeddedPisoSolver::snapshot`].
pub struct EmbeddedSolverState {
mask: Option<EmbeddedMask>,
time: f64,
initialized: bool,
}
/// Result of one embedded PISO step. /// Result of one embedded PISO step.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EmbeddedResult { pub struct EmbeddedResult {
@@ -83,6 +105,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.
@@ -93,6 +118,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,
} }
@@ -108,6 +134,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,
}) })
@@ -131,12 +158,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.
@@ -154,6 +199,29 @@ impl EmbeddedPisoSolver {
self.time self.time
} }
/// Snapshot of the solver's own per-step state — the mask, the
/// accumulated time and the initialization flag. A coupling
/// subiteration re-runs one step from the same start: clone the
/// [`FlowField`], take this snapshot, and [`Self::restore`] both
/// before every re-run — otherwise the moving-body path's fresh-cell
/// detection compares against the *previous subiteration's* mask
/// instead of the committed step-start mask.
pub fn snapshot(&self) -> EmbeddedSolverState {
EmbeddedSolverState {
mask: self.mask.clone(),
time: self.time,
initialized: self.initialized,
}
}
/// Restore a [`Self::snapshot`]. The snapshot is cloned, so one
/// snapshot serves any number of re-runs.
pub fn restore(&mut self, state: &EmbeddedSolverState) {
self.mask = state.mask.clone();
self.time = state.time;
self.initialized = state.initialized;
}
/// Reset the accumulated time. /// Reset the accumulated time.
pub fn set_time(&mut self, t: f64) { pub fn set_time(&mut self, t: f64) {
self.time = t; self.time = t;
@@ -315,6 +383,46 @@ impl EmbeddedPisoSolver {
}) })
/ dy; / dy;
// Limited (TVD) corrections to the four convective face
// values; exactly zero-cost on the default upwind scheme.
let scheme = self.parameters.convection_scheme;
let mut conv_x = conv_x;
let mut conv_y = conv_y;
if scheme != ConvectionScheme::Upwind {
let delta_e = if ue_face >= 0.0 {
scheme.face_correction(Some(uo[(j, i - 1)]), uo[(j, i)], uo[(j, i + 1)])
} else {
let far = (i + 2 <= nx).then(|| uo[(j, i + 2)]);
scheme.face_correction(far, uo[(j, i + 1)], uo[(j, i)])
};
let delta_w = if uw_face >= 0.0 {
let far = (i >= 2).then(|| uo[(j, i - 2)]);
scheme.face_correction(far, uo[(j, i - 1)], uo[(j, i)])
} else {
scheme.face_correction(Some(uo[(j, i + 1)]), uo[(j, i)], uo[(j, i - 1)])
};
let delta_n = if north_is_wall {
0.0
} else if vn_face >= 0.0 {
let far = (j >= 1).then(|| uo[(j - 1, i)]);
scheme.face_correction(far, uo[(j, i)], uo[(j + 1, i)])
} else {
let far = (j + 2 < ny).then(|| uo[(j + 2, i)]);
scheme.face_correction(far, uo[(j + 1, i)], uo[(j, i)])
};
let delta_s = if south_is_wall {
0.0
} else if vs_face >= 0.0 {
let far = (j >= 2).then(|| uo[(j - 2, i)]);
scheme.face_correction(far, uo[(j - 1, i)], uo[(j, i)])
} else {
let far = (j + 1 < ny).then(|| uo[(j + 1, i)]);
scheme.face_correction(far, uo[(j, i)], uo[(j - 1, i)])
};
conv_x += (ue_face * delta_e - uw_face * delta_w) / dx;
conv_y += (vn_face * delta_n - vs_face * delta_s) / dy;
}
let diff_x = nu * (uo[(j, i + 1)] - 2.0 * u_p + uo[(j, i - 1)]) / (dx * dx); let diff_x = nu * (uo[(j, i + 1)] - 2.0 * u_p + uo[(j, i - 1)]) / (dx * dx);
// Wall-adjacent diffusive fluxes act over half a cell on a // Wall-adjacent diffusive fluxes act over half a cell on a
@@ -397,6 +505,44 @@ impl EmbeddedPisoSolver {
}) })
/ dx; / dx;
let scheme = self.parameters.convection_scheme;
let mut conv_x = conv_x;
let mut conv_y = conv_y;
if scheme != ConvectionScheme::Upwind {
let delta_n = if vn_face >= 0.0 {
scheme.face_correction(Some(vo[(j - 1, i)]), vo[(j, i)], vo[(j + 1, i)])
} else {
let far = (j + 2 <= ny).then(|| vo[(j + 2, i)]);
scheme.face_correction(far, vo[(j + 1, i)], vo[(j, i)])
};
let delta_s = if vs_face >= 0.0 {
let far = (j >= 2).then(|| vo[(j - 2, i)]);
scheme.face_correction(far, vo[(j - 1, i)], vo[(j, i)])
} else {
scheme.face_correction(Some(vo[(j + 1, i)]), vo[(j, i)], vo[(j - 1, i)])
};
let delta_e = if east_is_wall {
0.0
} else if ue_face >= 0.0 {
let far = (i >= 1).then(|| vo[(j, i - 1)]);
scheme.face_correction(far, vo[(j, i)], vo[(j, i + 1)])
} else {
let far = (i + 2 < nx).then(|| vo[(j, i + 2)]);
scheme.face_correction(far, vo[(j, i + 1)], vo[(j, i)])
};
let delta_w = if west_is_wall {
0.0
} else if uw_face >= 0.0 {
let far = (i >= 2).then(|| vo[(j, i - 2)]);
scheme.face_correction(far, vo[(j, i - 1)], vo[(j, i)])
} else {
let far = (i + 1 < nx).then(|| vo[(j, i + 1)]);
scheme.face_correction(far, vo[(j, i)], vo[(j, i - 1)])
};
conv_y += (vn_face * delta_n - vs_face * delta_s) / dy;
conv_x += (ue_face * delta_e - uw_face * delta_w) / dx;
}
let diff_y = nu * (vo[(j + 1, i)] - 2.0 * v_p + vo[(j - 1, i)]) / (dy * dy); let diff_y = nu * (vo[(j + 1, i)] - 2.0 * v_p + vo[(j - 1, i)]) / (dy * dy);
let flux_east = if east_is_wall { let flux_east = if east_is_wall {
@@ -757,6 +903,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();
@@ -791,6 +995,7 @@ impl EmbeddedPisoSolver {
}, },
corrector_steps_performed: total_corrector_steps, corrector_steps_performed: total_corrector_steps,
ghost_correction, ghost_correction,
fresh_cells,
}) })
} }
} }
@@ -156,6 +156,60 @@ impl EmbeddedBody {
body body
} }
/// A closed polygon (vertices in order, either winding), at rest. The
/// signed distance is exact (min distance to the edges, sign by even-odd
/// ray crossing); the sampler walks the edges with outward normals. A
/// coupling loop can rebuild the body each subiteration from a deformed
/// structure boundary — or share the vertex list behind a lock and let
/// the moving-body path pick the new shape up on its per-step rebuild.
pub fn polygon(vertices: Vec<(f64, f64)>) -> Self {
assert!(vertices.len() >= 3, "a polygon needs at least 3 vertices");
// Signed area decides which perpendicular points outward.
let signed_area: f64 = vertices
.iter()
.zip(vertices.iter().cycle().skip(1))
.map(|(a, b)| a.0 * b.1 - b.0 * a.1)
.take(vertices.len())
.sum::<f64>()
* 0.5;
let ccw = signed_area > 0.0;
let sdf_vertices = vertices.clone();
let mut body = Self::from_sdf(move |x, y, _| polygon_signed_distance(&sdf_vertices, x, y));
let sampler_vertices = vertices;
body.sampler = Some(Box::new(move |ds| {
let n = sampler_vertices.len();
let mut out = Vec::new();
for k in 0..n {
let (ax, ay) = sampler_vertices[k];
let (bx, by) = sampler_vertices[(k + 1) % n];
let (ex, ey) = (bx - ax, by - ay);
let len = (ex * ex + ey * ey).sqrt();
if len == 0.0 {
continue;
}
// Outward normal: right of the direction for CCW winding.
let (mut nx, mut ny) = (ey / len, -ex / len);
if !ccw {
nx = -nx;
ny = -ny;
}
let count = ((len / ds).ceil() as usize).max(1);
for q in 0..count {
let s = (q as f64 + 0.5) / count as f64;
out.push(SurfaceSample {
x: ax + s * ex,
y: ay + s * ey,
nx,
ny,
ds: len / count as f64,
});
}
}
out
}));
body
}
/// Union of two bodies: the SDF is the minimum; the surface velocity and /// Union of two bodies: the SDF is the minimum; the surface velocity and
/// samples come from whichever body a point is closer to. Samples of one /// samples come from whichever body a point is closer to. Samples of one
/// body lying inside the other are dropped. /// body lying inside the other are dropped.
@@ -236,6 +290,82 @@ pub struct SurfaceForce {
pub skipped: usize, pub skipped: usize,
} }
/// Signed distance to a closed polygon (negative inside, either winding):
/// minimum distance over the edges, sign by the even-odd ray-crossing rule.
/// Public so a coupling loop can build a time-dependent body from a shared,
/// mutating vertex list via [`EmbeddedBody::from_sdf`].
#[must_use]
pub fn polygon_signed_distance(vertices: &[(f64, f64)], x: f64, y: f64) -> f64 {
let n = vertices.len();
let mut dist2 = f64::MAX;
let mut inside = false;
for k in 0..n {
let (ax, ay) = vertices[k];
let (bx, by) = vertices[(k + 1) % n];
let (ex, ey) = (bx - ax, by - ay);
let len2 = ex * ex + ey * ey;
let s = if len2 > 0.0 {
(((x - ax) * ex + (y - ay) * ey) / len2).clamp(0.0, 1.0)
} else {
0.0
};
let (qx, qy) = (ax + s * ex - x, ay + s * ey - y);
dist2 = dist2.min(qx * qx + qy * qy);
if (ay > y) != (by > y) {
let x_cross = ax + (y - ay) / (by - ay) * ex;
if x < x_cross {
inside = !inside;
}
}
}
let dist = dist2.sqrt();
if inside { -dist } else { dist }
}
/// Velocity of the point on a closed polygon nearest to `(x, y)`, where
/// the vertices carry velocities: the nearest edge point is found exactly
/// as in [`polygon_signed_distance`], and that edge's endpoint velocities
/// are interpolated linearly along it. This is the no-slip closure of a
/// deforming body whose boundary nodes move with known velocities — exact
/// wherever the boundary velocity is linear along an edge, which is what a
/// finite-element interface hands over. `velocities` must have one entry
/// per vertex.
#[must_use]
pub fn polygon_interface_velocity(
vertices: &[(f64, f64)],
velocities: &[(f64, f64)],
x: f64,
y: f64,
) -> (f64, f64) {
assert_eq!(
vertices.len(),
velocities.len(),
"one velocity per polygon vertex"
);
let n = vertices.len();
let mut best = (f64::MAX, 0usize, 0.0f64);
for k in 0..n {
let (ax, ay) = vertices[k];
let (bx, by) = vertices[(k + 1) % n];
let (ex, ey) = (bx - ax, by - ay);
let len2 = ex * ex + ey * ey;
let s = if len2 > 0.0 {
(((x - ax) * ex + (y - ay) * ey) / len2).clamp(0.0, 1.0)
} else {
0.0
};
let (qx, qy) = (ax + s * ex - x, ay + s * ey - y);
let d2 = qx * qx + qy * qy;
if d2 < best.0 {
best = (d2, k, s);
}
}
let (_, k, s) = best;
let (vax, vay) = velocities[k];
let (vbx, vby) = velocities[(k + 1) % n];
(vax + s * (vbx - vax), vay + s * (vby - vay))
}
/// What a velocity face is. /// What a velocity face is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaceKind { pub enum FaceKind {
@@ -281,7 +411,10 @@ struct Ghost {
flux_sign: f64, flux_sign: f64,
} }
/// Classification of a grid against a body at one instant. /// Classification of a grid against a body at one instant. `Clone` so a
/// coupling loop can snapshot the solver's step state and re-run a step
/// within a subiteration ([`super::EmbeddedPisoSolver::snapshot`]).
#[derive(Clone)]
pub struct EmbeddedMask { pub struct EmbeddedMask {
nx: usize, nx: usize,
ny: usize, ny: usize,
@@ -525,6 +658,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 +702,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;
@@ -613,49 +774,17 @@ impl EmbeddedMask {
t: f64, t: f64,
ds: f64, ds: f64,
) -> SurfaceForce { ) -> SurfaceForce {
let h = self.dx.min(self.dy);
let samples = body.surface_samples(ds); let samples = body.surface_samples(ds);
let (mut fx, mut fy) = (0.0, 0.0); let (mut fx, mut fy) = (0.0, 0.0);
let mut skipped = 0; let mut skipped = 0;
for s in &samples { for s in &samples {
let d1 = h; match self.traction_at(body, u, v, p, mu, t, s.x, s.y, s.nx, s.ny) {
let d2 = 2.0 * h; Some((tx, ty)) => {
let probes = (|| { fx += tx * s.ds;
let p1 = self.pressure_at(p, s.x + d1 * s.nx, s.y + d1 * s.ny)?; fy += ty * s.ds;
let p2 = self.pressure_at(p, s.x + d2 * s.nx, s.y + d2 * s.ny)?; }
let v1 = self.velocity_at(body, u, v, s.x + d1 * s.nx, s.y + d1 * s.ny, t)?; None => skipped += 1,
let v2 = self.velocity_at(body, u, v, s.x + d2 * s.nx, s.y + d2 * s.ny, t)?; }
Some((p1, p2, v1, v2))
})();
let Some((p1, p2, (u1, v1), (u2, v2))) = probes else {
skipped += 1;
continue;
};
let p_wall = p1 + (p1 - p2) * d1 / (d2 - d1);
let (tx, ty) = (-s.ny, s.nx);
let (u_s, v_s) = body.surface_velocity(s.x, s.y, t);
let ut_wall = u_s * tx + v_s * ty;
let un_wall = u_s * s.nx + v_s * s.ny;
// Wall gradient of a quadratic `a s + b s^2` through the two
// probes (values relative to the wall).
let wall_gradient =
|f1: f64, f2: f64| (f1 * d2 * d2 - f2 * d1 * d1) / (d1 * d2 * (d2 - d1));
let dn_ut = wall_gradient(u1 * tx + v1 * ty - ut_wall, u2 * tx + v2 * ty - ut_wall);
let dn_un = wall_gradient(
u1 * s.nx + v1 * s.ny - un_wall,
u2 * s.nx + v2 * s.ny - un_wall,
);
// Tangential derivative of (u . n) along the surface, n fixed.
let eps = 1e-6 * h;
let (up, vp) = body.surface_velocity(s.x + eps * tx, s.y + eps * ty, t);
let (um, vm) = body.surface_velocity(s.x - eps * tx, s.y - eps * ty, t);
let dt_un = ((up - um) * s.nx + (vp - vm) * s.ny) / (2.0 * eps);
let traction_n = -p_wall + 2.0 * mu * dn_un;
let traction_t = mu * (dn_ut + dt_un);
fx += (traction_n * s.nx + traction_t * tx) * s.ds;
fy += (traction_n * s.ny + traction_t * ty) * s.ds;
} }
SurfaceForce { SurfaceForce {
fx, fx,
@@ -665,7 +794,57 @@ impl EmbeddedMask {
} }
} }
/// Force on the body by a momentum balance over the rectangle of whole /// The reconstructed traction `sigma . n` (force per unit area) at one
/// surface point with outward normal `(nx, ny)` — the per-sample core
/// of [`Self::surface_force`], exposed so a coupling loop can hand the
/// fluid load to a structure at its own quadrature points. `None` when
/// a probe cannot be reconstructed (deep concave corner).
#[allow(clippy::too_many_arguments)]
pub fn traction_at(
&self,
body: &EmbeddedBody,
u: &DMatrix<f64>,
v: &DMatrix<f64>,
p: &DMatrix<f64>,
mu: f64,
t: f64,
x: f64,
y: f64,
nx: f64,
ny: f64,
) -> Option<(f64, f64)> {
let h = self.dx.min(self.dy);
let d1 = h;
let d2 = 2.0 * h;
let p1 = self.pressure_at(p, x + d1 * nx, y + d1 * ny)?;
let p2 = self.pressure_at(p, x + d2 * nx, y + d2 * ny)?;
let (u1, v1) = self.velocity_at(body, u, v, x + d1 * nx, y + d1 * ny, t)?;
let (u2, v2) = self.velocity_at(body, u, v, x + d2 * nx, y + d2 * ny, t)?;
let p_wall = p1 + (p1 - p2) * d1 / (d2 - d1);
let (tx, ty) = (-ny, nx);
let (u_s, v_s) = body.surface_velocity(x, y, t);
let ut_wall = u_s * tx + v_s * ty;
let un_wall = u_s * nx + v_s * ny;
let wall_gradient =
|f1: f64, f2: f64| (f1 * d2 * d2 - f2 * d1 * d1) / (d1 * d2 * (d2 - d1));
let dn_ut = wall_gradient(u1 * tx + v1 * ty - ut_wall, u2 * tx + v2 * ty - ut_wall);
let dn_un = wall_gradient(u1 * nx + v1 * ny - un_wall, u2 * nx + v2 * ny - un_wall);
// Tangential derivative of (u . n) along the surface, n fixed.
let eps = 1e-6 * h;
let (up, vp) = body.surface_velocity(x + eps * tx, y + eps * ty, t);
let (um, vm) = body.surface_velocity(x - eps * tx, y - eps * ty, t);
let dt_un = ((up - um) * nx + (vp - vm) * ny) / (2.0 * eps);
let traction_n = -p_wall + 2.0 * mu * dn_un;
let traction_t = mu * (dn_ut + dt_un);
Some((
traction_n * nx + traction_t * tx,
traction_n * ny + traction_t * ty,
))
}
/// Force on the body by a momentum balance over the rectangle of whole /// Force on the body by a momentum balance over the rectangle of whole
/// cells `[i0, i1) x [j0, j1)` (which must enclose the body and lie in /// cells `[i0, i1) x [j0, j1)` (which must enclose the body and lie in
/// the fluid on its boundary): /// the fluid on its boundary):
/// `F = sum_outer (sigma.n - rho u (u.n)) A - d/dt int rho u dV + int f dV`, /// `F = sum_outer (sigma.n - rho u (u.n)) A - d/dt int rho u dV + int f dV`,
@@ -991,6 +1170,28 @@ mod tests {
} }
} }
#[test]
fn interface_velocity_interpolates_along_the_nearest_edge() {
// Unit square, CCW; each vertex carries a distinct velocity.
let vertices = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let velocities = vec![(0.0, 0.0), (1.0, -1.0), (2.0, 4.0), (3.0, 9.0)];
// Near the bottom edge at s = 0.25: linear interpolation of the
// edge's endpoint velocities, regardless of the offset off the edge.
let (u, v) = polygon_interface_velocity(&vertices, &velocities, 0.25, -0.3);
assert!((u - 0.25).abs() < 1e-14 && (v + 0.25).abs() < 1e-14);
let (u, v) = polygon_interface_velocity(&vertices, &velocities, 0.25, 0.1);
assert!((u - 0.25).abs() < 1e-14 && (v + 0.25).abs() < 1e-14);
// Near a vertex (outside the corner): the vertex velocity.
let (u, v) = polygon_interface_velocity(&vertices, &velocities, 1.2, 1.3);
assert!((u - 2.0).abs() < 1e-14 && (v - 4.0).abs() < 1e-14);
// Midpoint of the right edge.
let (u, v) = polygon_interface_velocity(&vertices, &velocities, 1.4, 0.5);
assert!((u - 1.5).abs() < 1e-14 && (v - 1.5).abs() < 1e-14);
}
#[test] #[test]
fn body_touching_the_boundary_is_refused() { fn body_touching_the_boundary_is_refused() {
let body = EmbeddedBody::circle(0.0, 0.5, 0.2); let body = EmbeddedBody::circle(0.0, 0.5, 0.2);
@@ -39,8 +39,11 @@ pub use ale::{
pub use boundary_conditions::{ pub use boundary_conditions::{
BoundaryCondition, BoundaryConditions, BoundaryLocation, BoundaryType, BoundaryCondition, BoundaryConditions, BoundaryLocation, BoundaryType,
}; };
pub use embedded::{EmbeddedParameters, EmbeddedPisoSolver, EmbeddedResult}; pub use embedded::{EmbeddedParameters, EmbeddedPisoSolver, EmbeddedResult, EmbeddedSolverState};
pub use embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind, SurfaceForce, SurfaceSample}; pub use embedded_body::{
EmbeddedBody, EmbeddedMask, FaceKind, SurfaceForce, SurfaceSample, polygon_interface_velocity,
polygon_signed_distance,
};
pub use flow_field::FlowField; pub use flow_field::FlowField;
pub use piso::{PisoParameters, PisoResult, PisoSolver}; pub use piso::{PisoParameters, PisoResult, PisoSolver};
#[cfg(feature = "cuda")] #[cfg(feature = "cuda")]
@@ -62,8 +62,14 @@ impl ConvectionScheme {
/// The limited correction `u_face_HO - u_face_upwind` for one face, given /// The limited correction `u_face_HO - u_face_upwind` for one face, given
/// the far-upwind, upwind and downwind values along the flow direction. /// the far-upwind, upwind and downwind values along the flow direction.
/// `None` for the far-upwind value means it lies outside the domain, and /// `None` for the far-upwind value means it lies outside the domain, and
/// the face falls back to pure upwind. /// the face falls back to pure upwind. `pub(crate)` so the embedded
fn face_correction(self, far_upwind: Option<f64>, upwind: f64, downwind: f64) -> f64 { /// solver's explicit predictor can use the same limited fluxes.
pub(crate) fn face_correction(
self,
far_upwind: Option<f64>,
upwind: f64,
downwind: f64,
) -> f64 {
let Some(far) = far_upwind else { let Some(far) = far_upwind else {
return 0.0; return 0.0;
}; };
@@ -22,8 +22,8 @@
//! is exactly what the ghost reconstruction has to get right. //! is exactly what the ghost reconstruction has to get right.
use rtx_cfd::solvers::incompressible::{ use rtx_cfd::solvers::incompressible::{
BoundaryConditions, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FaceKind, FlowField, BoundaryConditions, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
IncompressibleSolver, PisoParameters, PisoSolver, FaceKind, FlowField, IncompressibleSolver, PisoParameters, PisoSolver,
}; };
use rtx_cfd::{CfdConfig, CfdResult}; use rtx_cfd::{CfdConfig, CfdResult};
use std::f64::consts::PI; use std::f64::consts::PI;
@@ -205,6 +205,10 @@ struct Measurement {
/// March the manufactured problem with the embedded circle to steady /// March the manufactured problem with the embedded circle to steady
/// state on an `n` by `n` grid and measure everything. /// state on an `n` by `n` grid and measure everything.
async fn measure(n: usize) -> CfdResult<Measurement> { async fn measure(n: usize) -> CfdResult<Measurement> {
measure_with_scheme(n, ConvectionScheme::Upwind).await
}
async fn measure_with_scheme(n: usize, scheme: ConvectionScheme) -> CfdResult<Measurement> {
let dx = 1.0 / n as f64; let dx = 1.0 / n as f64;
let dt = time_step(n); let dt = time_step(n);
@@ -213,6 +217,7 @@ async fn measure(n: usize) -> CfdResult<Measurement> {
EmbeddedParameters { EmbeddedParameters {
corrector_steps: 2, corrector_steps: 2,
tolerance: 1e-8, tolerance: 1e-8,
convection_scheme: scheme,
..EmbeddedParameters::default() ..EmbeddedParameters::default()
}, },
)?; )?;
@@ -470,3 +475,30 @@ async fn embedded_circle_recovers_the_manufactured_solution() -> CfdResult<()> {
); );
Ok(()) Ok(())
} }
/// The TVD convection scheme on the embedded problem: the error must sit
/// below upwind's on the same grids and fall at a higher observed order.
/// (Upwind measured 8.489e-3 / 4.341e-3 at n = 32 / 64, order 0.97; SIMPLE's
/// TVD on the plain cavity measured orders 1.59-1.84.)
#[tokio::test]
async fn tvd_convection_beats_upwind_on_the_embedded_circle() -> CfdResult<()> {
let coarse = measure_with_scheme(32, ConvectionScheme::TvdVanAlbada).await?;
let fine = measure_with_scheme(64, ConvectionScheme::TvdVanAlbada).await?;
let order = (coarse.l2_velocity / fine.l2_velocity).log2();
println!(
" TVD: L2 u {:.4e} -> {:.4e}, order {order:.2} (upwind: 8.489e-3 -> 4.341e-3, 0.97)",
coarse.l2_velocity, fine.l2_velocity
);
assert!(
coarse.l2_velocity < 8.489e-3 && fine.l2_velocity < 4.341e-3,
"TVD error not below upwind's: {:.4e}, {:.4e}",
coarse.l2_velocity,
fine.l2_velocity
);
assert!(
order > 1.1,
"TVD observed order {order:.2} not above upwind's ~1"
);
assert!(fine.max_div < 1e-5, "divergence {:.3e}", fine.max_div);
Ok(())
}
@@ -0,0 +1,374 @@
//! Rung F2 of the TurekHron 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(())
}
/// The subiteration seam: snapshot the solver + clone the field mid-run of
/// a MOVING body, advance further (a discarded coupling candidate), then
/// restore and advance the same steps again — the re-run must be
/// bit-identical to a run that never diverted. This is what lets an FSI
/// coupling re-run one fluid step under updated interface geometry.
#[tokio::test]
async fn snapshot_restore_rerun_is_bit_identical() -> CfdResult<()> {
let n = 24;
let dt = time_step(n);
let mover = || phantom_circle(|t| 0.42 + 0.30 * t, |t| 0.48 + 0.15 * t, 0.2);
// Reference: an uninterrupted run of 30 steps.
let mut reference = solver(n)?;
reference.set_moving_body(mover());
let mut ref_field = exact_field(n)?;
for _ in 0..30 {
reference.advance(&mut ref_field, dt).await?;
}
// Diverted run: 18 steps, snapshot, 12 steps of a discarded candidate,
// restore, the real 12 steps.
let mut solver_d = solver(n)?;
solver_d.set_moving_body(mover());
let mut field = exact_field(n)?;
for _ in 0..18 {
solver_d.advance(&mut field, dt).await?;
}
let saved_state = solver_d.snapshot();
let saved_field = field.clone();
for _ in 0..12 {
solver_d.advance(&mut field, dt).await?; // discarded candidate
}
solver_d.restore(&saved_state);
field = saved_field;
let mut rerun_fresh = 0usize;
for _ in 0..12 {
rerun_fresh += solver_d.advance(&mut field, dt).await?.fresh_cells;
}
// Cells must actually flip in the re-run window, or the restore of the
// mask was never exercised against a mask that changes.
assert!(
rerun_fresh > 0,
"no cells flipped after the restore — the test is vacuous"
);
assert_eq!(
solver_d.time().to_bits(),
reference.time().to_bits(),
"restored time diverges"
);
let mut max_diff: f64 = 0.0;
for (x, y) in field.u.iter().zip(ref_field.u.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in field.v.iter().zip(ref_field.v.iter()) {
max_diff = max_diff.max((x - y).abs());
}
for (x, y) in field.p.iter().zip(ref_field.p.iter()) {
max_diff = max_diff.max((x - y).abs());
}
assert!(
max_diff == 0.0,
"restored re-run differs from the uninterrupted run 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(())
}
@@ -611,6 +611,7 @@ async fn channel_with_circle(kind: PoissonSolverKind) -> CfdResult<(FlowField, u
top: SideBoundary::Velocity, top: SideBoundary::Velocity,
}, },
poisson_solver: kind, poisson_solver: kind,
..EmbeddedParameters::default()
}, },
)?; )?;
let inflow = move |y: f64| 1.5 * u_mean * y * (height - y) / (0.5 * height).powi(2); let inflow = move |y: f64| 1.5 * u_mean * y * (height - y) / (0.5 * height).powi(2);
@@ -119,6 +119,7 @@ async fn run_cfd1(ny: usize) -> CfdResult<Cfd1> {
top: SideBoundary::Velocity, top: SideBoundary::Velocity,
}, },
poisson_solver: PoissonSolverKind::Multigrid, poisson_solver: PoissonSolverKind::Multigrid,
..EmbeddedParameters::default()
}; };
let mut solver = EmbeddedPisoSolver::new(config, params)?; let mut solver = EmbeddedPisoSolver::new(config, params)?;
solver.set_boundary_velocity(|x, y, _| { solver.set_boundary_velocity(|x, y, _| {
@@ -0,0 +1,363 @@
//! TurekHron CFD2 (steady, Re = 100) and CFD3 (periodic vortex shedding,
//! Re = 200) past the rigid cylinder + flag, on the embedded-boundary PISO
//! solver with the multigrid projection.
//!
//! Geometry, parameters and reference values from the FEATFLOW benchmark
//! tables (sourced 2026-08-20, omni-cortex
//! `docs/turek_hron_geometry_decision.md`); the body model and conventions
//! are `tests/turek_hron_cfd.rs`'s (flag extended into the cylinder, loads
//! by the surface-stress and control-volume routes):
//!
//! - CFD2: `U = 1`, Re = 100, steady. Reference (level 6): **drag 136.700,
//! lift 10.5343**.
//! - CFD3: `U = 2`, Re = 200, periodic. Reference (level 4, dt 0.005):
//! **drag 439.45 ± 5.6183, lift 11.893 ± 437.81, frequency 4.3956 Hz**,
//! with the benchmark's inflow ramp `(1 cos(pi t / 2)) / 2` for
//! `t < 2 s`.
//!
//! Both cases run the inflow ramp (it is part of CFD3's definition and a
//! gentler start for CFD2's explicit march). CFD2 is settled the way CFD1
//! is: the control-volume drag stagnant to 1e-4 relative over 200 steps
//! after one flow-through time. CFD3 marches to `t = 9 s` and measures over
//! `t in [6, 9]` (~13 shedding periods): mean and amplitude as
//! `(max + min)/2 ± (max min)/2` of the control-volume series, the
//! frequency from linearly-interpolated upward zero crossings of the lift
//! about its mean, and a periodicity check that the two halves of the
//! window agree on the lift amplitude.
//!
//! Measured (TVD van Albada + multigrid, dev profile; surface route
//! primary, control volume printed as the diagnostic — its central-
//! difference evaluation truncation grows with the convective flux and the
//! two routes differ ~1525% here where they agreed to 0.6% at Re 20):
//!
//! | case | ny | surface drag | surface lift | f (Hz) | wall |
//! |------|----|--------------|--------------|--------|------|
//! | CFD2 | 41 | 119.9 ± 0.000 (12.3%) | 3.4 | steady | 57 s |
//! | CFD2 | 62 | 121.4 ± 0.000 (11.2%) | +30.2 | steady | 176 s |
//! | CFD3 | 41 | 409.0 ± 8.2 (6.9%) | 184 ± 438.0 (amp +0.05%) | 4.2746 (2.8%) | 184 s |
//! | CFD3 | 62 | 413.0 ± 11.9 (6.0%) | +160 ± 555.6 (amp +27%) | 4.3400 (1.3%) | 618 s |
//! | CFD2 | 82 | 122.6 ± 0.000 (10.3%) | +8.4 | steady | 496 s |
//! | CFD3 | 82 | 394.2 ± 9.5 (10.3%) | 2.6 ± 557.2 (amp +27%) | 4.3939 (**0.04%**) | 1506 s |
//!
//! References: CFD2 drag 136.700, lift 10.5343; CFD3 drag 439.45 ± 5.62,
//! lift 11.893 ± 437.81, f 4.3956. What holds and what does not: the
//! shedding frequency converges cleanly (2.8% → 1.3% → **0.04%** at
//! h = 5 mm) and the lift MEAN collapses onto the reference (184 → +160 →
//! 2.6 vs 11.9); the CFD2 control-volume drag converges (152.4 → 143.3 →
//! 139.4, +2.0% at 5 mm) while its surface drag sits ~10% (the Re 100200
//! boundary layer is ~510 mm — barely a cell); the CFD3 lift amplitude
//! reads +27% at both 6.6 and 5 mm, unconverged (the flag is 3 / 4 cells
//! thick, and the reference itself needed their level 4). Pre-asymptotic
//! numbers are recorded, not asserted. Suite defaults: CFD2 at ny = 62,
//! CFD3 at ny = 41 (its cost); `RTX_CFD2_NY` / `RTX_CFD3_NY` override.
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
FlowField, PoissonSolverKind, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
const L: f64 = 2.5;
const H: f64 = 0.41;
const RHO: f64 = 1000.0;
const NU: f64 = 1e-3;
const CFD2_U: f64 = 1.0;
const CFD2_REF_DRAG: f64 = 136.700;
const CFD2_REF_LIFT: f64 = 10.5343;
const CFD3_U: f64 = 2.0;
const CFD3_REF_DRAG_MEAN: f64 = 439.45;
const CFD3_REF_DRAG_AMP: f64 = 5.6183;
const CFD3_REF_LIFT_MEAN: f64 = -11.893;
const CFD3_REF_LIFT_AMP: f64 = 437.81;
const CFD3_REF_FREQUENCY: f64 = 4.3956;
fn body() -> EmbeddedBody {
EmbeddedBody::union(
EmbeddedBody::circle(0.2, 0.2, 0.05),
EmbeddedBody::rectangle(0.20, 0.19, 0.6, 0.21),
)
}
/// The ramped parabolic inflow of the benchmark definition.
fn inflow(u_mean: f64, y: f64, t: f64) -> f64 {
let ramp = if t < 2.0 {
0.5 * (1.0 - (std::f64::consts::PI * t / 2.0).cos())
} else {
1.0
};
ramp * 1.5 * u_mean * y * (H - y) / (0.5 * H).powi(2)
}
struct Runner {
solver: EmbeddedPisoSolver,
field: FlowField,
nx: usize,
ny: usize,
h: f64,
dt: f64,
mu: f64,
cv: (usize, usize, usize, usize),
}
impl Runner {
fn new(u_mean: f64, ny: usize) -> CfdResult<Self> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO * NU;
// Combined explicit criterion with the blockage's local peak
// (see turek_hron_cfd.rs for the failure that taught it).
let u_peak = 1.5 * 1.5 * u_mean;
let dt = 0.25 / (2.0 * u_peak / h + 4.0 * NU / (h * h));
let config = CfdConfig::new()
.with_density(RHO)
.with_viscosity(mu)
.with_reference_velocity(u_mean)
.with_reference_length(0.1);
let params = EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
// Upwind's numerical viscosity (|u| h / 2 ~ 10x the physical nu
// on these grids) suppressed CFD3's vortex shedding entirely:
// the ny = 41 upwind run produced ONE lift zero-crossing in
// three seconds. The limited scheme restores the physics.
convection_scheme: ConvectionScheme::TvdVanAlbada,
};
let mut solver = EmbeddedPisoSolver::new(config, params)?;
solver.set_boundary_velocity(move |x, y, t| {
if x <= 0.0 {
(inflow(u_mean, y, t), 0.0)
} else {
(0.0, 0.0)
}
});
solver.set_body(body());
// Start at rest: the ramp brings the inflow up from zero.
let mut field = FlowField::new(nx, ny, h, h)?;
solver.initialize(&mut field)?;
let cv = (
(0.10 / h).round() as usize,
(0.75 / h).round() as usize,
(0.05 / h).round() as usize,
(0.36 / h).round() as usize,
);
Ok(Self {
solver,
field,
nx,
ny,
h,
dt,
mu,
cv,
})
}
fn cv_force(&self) -> (f64, f64) {
self.solver.mask().unwrap().control_volume_force(
&self.field.u,
&self.field.v,
&self.field.p,
&self.field.u_old,
&self.field.v_old,
self.dt,
RHO,
self.mu,
None,
self.cv,
)
}
fn surface_force(&self) -> rtx_cfd::solvers::incompressible::SurfaceForce {
self.solver.mask().unwrap().surface_force(
self.solver.body().unwrap(),
&self.field.u,
&self.field.v,
&self.field.p,
self.mu,
self.solver.time(),
0.5 * self.h,
)
}
async fn step(&mut self) -> CfdResult<()> {
self.solver.advance(&mut self.field, self.dt).await?;
let umax = self
.field
.u
.iter()
.fold(0.0f64, |acc, &value| acc.max(value.abs()));
assert!(
umax.is_finite(),
"velocity became non-finite at t = {:.3}",
self.solver.time()
);
Ok(())
}
}
/// One sampled series of both load routes.
struct Series {
times: Vec<f64>,
surface_drag: Vec<f64>,
surface_lift: Vec<f64>,
skipped_max: usize,
cv_drag: Vec<f64>,
cv_lift: Vec<f64>,
steps: usize,
seconds: f64,
}
/// March to `t_end`, sampling both load routes every 25 steps once
/// `t >= t_start`. With the TVD convection even the nominally steady CFD2
/// oscillates a little on coarse grids (the upwind run was steady only
/// because its numerical viscosity was ten times the physical one), so
/// every case is measured the same way: time statistics over a window,
/// never a single snapshot.
async fn run_sampled(u_mean: f64, ny: usize, t_start: f64, t_end: f64) -> CfdResult<Series> {
let mut runner = Runner::new(u_mean, ny)?;
let start = std::time::Instant::now();
let mut series = Series {
times: Vec::new(),
surface_drag: Vec::new(),
surface_lift: Vec::new(),
skipped_max: 0,
cv_drag: Vec::new(),
cv_lift: Vec::new(),
steps: 0,
seconds: 0.0,
};
while runner.solver.time() < t_end {
runner.step().await?;
series.steps += 1;
if series.steps % 25 == 0 && runner.solver.time() >= t_start {
let surface = runner.surface_force();
let (cx, cy) = runner.cv_force();
series.times.push(runner.solver.time());
series.surface_drag.push(surface.fx);
series.surface_lift.push(surface.fy);
series.skipped_max = series.skipped_max.max(surface.skipped);
series.cv_drag.push(cx);
series.cv_lift.push(cy);
}
}
series.seconds = start.elapsed().as_secs_f64();
assert!(series.times.len() > 50, "too few samples in the window");
Ok(series)
}
/// Mid-range mean and half-range amplitude of a series.
fn mid_amp(series: &[f64]) -> (f64, f64) {
let max = series.iter().copied().fold(f64::MIN, f64::max);
let min = series.iter().copied().fold(f64::MAX, f64::min);
(0.5 * (max + min), 0.5 * (max - min))
}
/// Frequency from linearly-interpolated upward zero crossings about the
/// mean; `None` with fewer than four crossings.
fn crossing_frequency(times: &[f64], series: &[f64]) -> Option<f64> {
let (mean, _) = mid_amp(series);
let mut crossings: Vec<f64> = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - mean, series[k] - mean);
if a < 0.0 && b >= 0.0 {
let frac = a / (a - b);
crossings.push(times[k - 1] + frac * (times[k] - times[k - 1]));
}
}
(crossings.len() >= 4).then(|| {
(crossings.len() - 1) as f64 / (crossings.last().unwrap() - crossings.first().unwrap())
})
}
fn ny_list(var: &str, default: &[usize]) -> Vec<usize> {
std::env::var(var)
.ok()
.map(|s| {
s.split(',')
.map(|t| t.trim().parse().expect("integer ny"))
.collect()
})
.unwrap_or_else(|| default.to_vec())
}
#[tokio::test]
async fn cfd2_steady_drag_and_lift() -> CfdResult<()> {
let resolutions = ny_list("RTX_CFD2_NY", &[62]);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
for &ny in &resolutions {
let r = run_sampled(CFD2_U, ny, 8.0, 10.0).await?;
let (drag_s, drag_s_amp) = mid_amp(&r.surface_drag);
let (lift_s, lift_s_amp) = mid_amp(&r.surface_lift);
let (drag_c, _) = mid_amp(&r.cv_drag);
let (lift_c, _) = mid_amp(&r.cv_lift);
println!(
" CFD2 ny = {ny:3} (h = {:.4}) surface: drag {drag_s:.3} ± {drag_s_amp:.3} lift {lift_s:.3} ± {lift_s_amp:.3} (skipped ≤ {}) \
control volume means: drag {drag_c:.3} lift {lift_c:.3} [{} steps, {:.0} s] reference drag {CFD2_REF_DRAG} lift {CFD2_REF_LIFT}",
H / ny as f64,
r.skipped_max,
r.steps,
r.seconds
);
assert!(
rel(drag_s, CFD2_REF_DRAG) < 0.15,
"ny = {ny}: surface drag mean {drag_s:.3} vs reference {CFD2_REF_DRAG}"
);
}
Ok(())
}
#[tokio::test]
async fn cfd3_shedding_frequency_and_loads() -> CfdResult<()> {
let resolutions = ny_list("RTX_CFD3_NY", &[41]);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
for &ny in &resolutions {
let r = run_sampled(CFD3_U, ny, 6.0, 9.0).await?;
let (drag_mean, drag_amp) = mid_amp(&r.surface_drag);
let (lift_mean, lift_amp) = mid_amp(&r.surface_lift);
let frequency = crossing_frequency(&r.times, &r.surface_lift);
let half = r.surface_lift.len() / 2;
let (_, amp_first) = mid_amp(&r.surface_lift[..half]);
let (_, amp_second) = mid_amp(&r.surface_lift[half..]);
let (cv_drag_mean, cv_drag_amp) = mid_amp(&r.cv_drag);
let (cv_lift_mean, cv_lift_amp) = mid_amp(&r.cv_lift);
println!(
" CFD3 ny = {ny:3} (h = {:.4}) surface: drag {drag_mean:.2} ± {drag_amp:.2}, lift {lift_mean:.2} ± {lift_amp:.2}, f = {frequency:?} Hz (skipped ≤ {}) \
CV: drag {cv_drag_mean:.2} ± {cv_drag_amp:.2}, lift {cv_lift_mean:.2} ± {cv_lift_amp:.2} half-window lift amps {amp_first:.2}/{amp_second:.2} \
[{} steps, {:.0} s] reference drag {CFD3_REF_DRAG_MEAN} ± {CFD3_REF_DRAG_AMP}, lift {CFD3_REF_LIFT_MEAN} ± {CFD3_REF_LIFT_AMP}, f {CFD3_REF_FREQUENCY}",
H / ny as f64,
r.skipped_max,
r.steps,
r.seconds
);
let frequency = frequency.expect("the wake must shed: fewer than four lift zero-crossings");
assert!(
(amp_first - amp_second).abs() < 0.15 * amp_second.max(1e-9),
"ny = {ny}: lift amplitude still drifting: halves {amp_first:.2} / {amp_second:.2}"
);
assert!(
rel(frequency, CFD3_REF_FREQUENCY) < 0.10,
"ny = {ny}: shedding frequency {frequency:.4} vs reference {CFD3_REF_FREQUENCY}"
);
assert!(
rel(drag_mean, CFD3_REF_DRAG_MEAN) < 0.15,
"ny = {ny}: mean surface drag {drag_mean:.2} vs reference {CFD3_REF_DRAG_MEAN}"
);
assert!(
rel(lift_amp, CFD3_REF_LIFT_AMP) < 0.35,
"ny = {ny}: surface lift amplitude {lift_amp:.2} vs reference {CFD3_REF_LIFT_AMP}"
);
}
Ok(())
}
@@ -9,6 +9,7 @@
pub mod dynamic_analysis; pub mod dynamic_analysis;
pub mod modal_analysis; pub mod modal_analysis;
pub mod nonlinear_analysis; pub mod nonlinear_analysis;
pub mod nonlinear_dynamic;
pub mod static_analysis; pub mod static_analysis;
use crate::assembly::AssemblyOptions; use crate::assembly::AssemblyOptions;
@@ -23,6 +24,7 @@ use std::time::Duration;
pub use dynamic_analysis::*; pub use dynamic_analysis::*;
pub use modal_analysis::*; pub use modal_analysis::*;
pub use nonlinear_analysis::*; pub use nonlinear_analysis::*;
pub use nonlinear_dynamic::*;
pub use static_analysis::*; pub use static_analysis::*;
/// Base trait for all finite element analyses. /// Base trait for all finite element analyses.
@@ -49,6 +49,10 @@ pub struct NonlinearStaticAnalysis {
/// (`∫ N_i f dV`) into the external force vector. /// (`∫ N_i f dV`) into the external force vector.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
body_force: Option<Box<dyn Fn(Vector3<f64>) -> Vector3<f64> + Send + Sync>>, body_force: Option<Box<dyn Fn(Vector3<f64>) -> Vector3<f64> + Send + Sync>>,
/// Concentrated nodal forces (e.g. a fluid load transferred to the
/// wetted boundary nodes), added to the consistent external force and
/// scaled by the load factor like everything else.
nodal_forces: Vec<(crate::mesh::NodeId, Vector3<f64>)>,
/// Use the total-Lagrangian finite-deformation formulation with a /// Use the total-Lagrangian finite-deformation formulation with a
/// St. VenantKirchhoff law built from each material's Lamé parameters /// St. VenantKirchhoff law built from each material's Lamé parameters
/// (plane strain in 2-D), instead of the small-strain path through /// (plane strain in 2-D), instead of the small-strain path through
@@ -83,10 +87,17 @@ impl NonlinearStaticAnalysis {
progress: 0.0, progress: 0.0,
complete: false, complete: false,
body_force: None, body_force: None,
nodal_forces: Vec::new(),
total_lagrangian: false, total_lagrangian: false,
} }
} }
/// Replace the concentrated nodal forces (a coupling loop calls this
/// every subiteration with the freshly transferred fluid load).
pub fn set_nodal_forces(&mut self, forces: Vec<(crate::mesh::NodeId, Vector3<f64>)>) {
self.nodal_forces = forces;
}
/// Switch to the total-Lagrangian finite-deformation formulation /// Switch to the total-Lagrangian finite-deformation formulation
/// (`elements::total_lagrangian`): GreenLagrange strain, second /// (`elements::total_lagrangian`): GreenLagrange strain, second
/// PiolaKirchhoff stress from a St. VenantKirchhoff law with the /// PiolaKirchhoff stress from a St. VenantKirchhoff law with the
@@ -197,6 +208,14 @@ impl NonlinearStaticAnalysis {
num_free: usize, num_free: usize,
) -> FeaResult<DVector<f64>> { ) -> FeaResult<DVector<f64>> {
let mut external = DVector::zeros(num_free); let mut external = DVector::zeros(num_free);
for (node, force) in &self.nodal_forces {
let dofs = dof_numbering.get_node_dofs(*node);
for (component, &dof) in dofs.iter().enumerate() {
if let Some(free) = free_index[dof] {
external[free] += force[component];
}
}
}
let Some(force) = self.body_force.as_ref() else { let Some(force) = self.body_force.as_ref() else {
return Ok(external); return Ok(external);
}; };
@@ -0,0 +1,618 @@
//! Nonlinear transient analysis: Newmark-β time integration with a full
//! Newton solve on the internal force inside every step.
//!
//! The existing [`super::dynamic_analysis`] stepper is linear by
//! construction — it factorises `M + γΔt C + βΔt² K` once and reuses it,
//! which is exactly right for constant matrices and exactly wrong for
//! finite deformation. This analysis solves, at every step,
//!
//! ```text
//! M a_{n+1} + f_int(u_{n+1}) = F_ext(t_{n+1})
//! a_{n+1} = (u_{n+1} - u_pred) / (β Δt²),
//! u_pred = u_n + Δt v_n + Δt² (1/2 - β) a_n,
//! v_{n+1} = v_n + Δt ((1 - γ) a_n + γ a_{n+1})
//! ```
//!
//! by Newton on `R(u) = F_ext - f_int(u) - M a(u)` with the consistent
//! Jacobian `K_T(u) + M / (β Δt²)`, where `f_int` and `K_T` come from the
//! total-Lagrangian St. VenantKirchhoff path
//! ([`crate::elements::total_lagrangian`]) or the small-strain path, the
//! same seam the nonlinear static analysis uses. The consistent mass is
//! assembled once (element mass matrices are configuration-independent in
//! a total-Lagrangian setting); no damping (Rayleigh damping can be added
//! when something needs it — the TurekHron CSM3 benchmark is undamped).
//!
//! # Two ways to drive it
//!
//! [`NonlinearDynamicAnalysis::run`] marches `num_steps` steps from rest —
//! the benchmark shape (CSM3: gravity switched on at rest).
//!
//! [`NonlinearDynamicAnalysis::stepper`] hands out the same machinery one
//! step at a time, for a partitioned coupling loop: the caller owns the
//! state ([`DynamicState`]), sets the interface load with
//! [`NonlinearDynamicStepper::set_nodal_forces`], and calls
//! [`NonlinearDynamicStepper::step`] — which reads the start-of-step state
//! and *does not commit anything*, so a subiteration can re-run the same
//! step from the same state under an updated load as many times as the
//! interface fixed point takes (the semantics the coupled piston benchmark
//! established). `run` is implemented on the stepper, so the benchmark
//! tests pin both.
//!
//! Limits, stated up front: Dirichlet conditions must be homogeneous
//! (`u = 0` — a clamped edge); the body force is constant in time, applied
//! fully from `t = 0` (CSM3's definition: gravity switched on at rest, the
//! structure oscillates about its static deflection). Nodal forces may
//! change between steps (and between subiterations of one step) through
//! the stepper.
use super::{AnalysisConfig, ConvergenceCriteria};
use crate::assembly::SparseMatrix;
use crate::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy};
use crate::boundary::{BoundaryCondition, BoundaryConditionSet};
use crate::elements::total_lagrangian::{self, saint_venant_kirchhoff};
use crate::elements::{ElementMatrixComputer, StandardFiniteElement};
use crate::error::{AnalysisError, FeaResult};
use crate::materials::{MaterialDatabase, reduced_constitutive};
use crate::mesh::{Mesh, NodeId};
use crate::solvers::{LinearSolver, LuDirect, SolverOptions};
use nalgebra::{DMatrix, DVector, Vector3};
/// Time histories and final state of a nonlinear transient run.
#[derive(Debug, Clone)]
pub struct NonlinearDynamicResults {
/// Sample times `t_1..t_N` (end of each step).
pub times: Vec<f64>,
/// Per tracked node: its displacement components at every sample time,
/// in the order the nodes were passed to `track_node`.
pub tracked: Vec<Vec<DVector<f64>>>,
/// Full displacement, velocity, acceleration at the final time.
pub displacement: DVector<f64>,
/// Final velocity.
pub velocity: DVector<f64>,
/// Final acceleration.
pub acceleration: DVector<f64>,
/// Newton iterations summed over all steps.
pub total_iterations: usize,
/// Largest Newton iteration count of any step.
pub max_iterations_per_step: usize,
}
/// The full kinematic state at one instant: displacement, velocity and
/// acceleration as full-length vectors under the analysis's DOF numbering
/// (constrained entries zero). The caller owns it; a coupling loop clones
/// the committed state and re-steps from it freely.
#[derive(Debug, Clone)]
pub struct DynamicState {
/// Displacement.
pub displacement: DVector<f64>,
/// Velocity.
pub velocity: DVector<f64>,
/// Acceleration.
pub acceleration: DVector<f64>,
}
/// Per-element setup computed once: coordinates, DOFs, the DOF-expanded
/// consistent mass (configuration-independent), and the material.
struct ElementCache {
coords: Vec<Vector3<f64>>,
dofs: Vec<usize>,
element_type: crate::mesh::ElementType,
mass: DMatrix<f64>,
material_id: crate::mesh::MaterialId,
}
/// Nonlinear Newmark transient analysis. See the module docs.
pub struct NonlinearDynamicAnalysis {
mesh: Mesh,
materials: MaterialDatabase,
boundary_conditions: BoundaryConditionSet,
#[allow(dead_code)]
config: AnalysisConfig,
criteria: ConvergenceCriteria,
dt: f64,
num_steps: usize,
gamma: f64,
beta: f64,
total_lagrangian: bool,
#[allow(clippy::type_complexity)]
body_force: Option<Box<dyn Fn(Vector3<f64>) -> Vector3<f64> + Send + Sync>>,
nodal_forces: Vec<(NodeId, Vector3<f64>)>,
tracked_nodes: Vec<NodeId>,
}
impl NonlinearDynamicAnalysis {
/// Average-acceleration Newmark (γ = 1/2, β = 1/4), the benchmark's
/// scheme and the unconditionally stable one for linear problems.
pub fn new(
mesh: Mesh,
materials: MaterialDatabase,
boundary_conditions: BoundaryConditionSet,
dt: f64,
num_steps: usize,
config: AnalysisConfig,
) -> Self {
Self {
mesh,
materials,
boundary_conditions,
config,
criteria: ConvergenceCriteria::default(),
dt,
num_steps,
gamma: 0.5,
beta: 0.25,
total_lagrangian: false,
body_force: None,
nodal_forces: Vec::new(),
tracked_nodes: Vec::new(),
}
}
/// Switch to the total-Lagrangian St. VenantKirchhoff formulation
/// (plane strain in 2-D), as on the nonlinear static analysis.
#[must_use]
pub fn with_total_lagrangian(mut self) -> Self {
self.total_lagrangian = true;
self
}
/// Newmark parameters (default γ = 1/2, β = 1/4).
#[must_use]
pub fn with_newmark_parameters(mut self, gamma: f64, beta: f64) -> Self {
self.gamma = gamma;
self.beta = beta;
self
}
/// Convergence criteria for the per-step Newton loop.
#[must_use]
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
self.criteria = criteria;
self
}
/// Constant body force per unit (reference) volume, applied from t = 0.
pub fn set_body_force<F>(&mut self, f: F)
where
F: Fn(Vector3<f64>) -> Vector3<f64> + Send + Sync + 'static,
{
self.body_force = Some(Box::new(f));
}
/// Concentrated nodal forces, added to the external force (as on
/// [`super::NonlinearStaticAnalysis`]). For a load that changes in
/// time, use [`Self::stepper`] and set the forces before each step.
pub fn set_nodal_forces(&mut self, forces: Vec<(NodeId, Vector3<f64>)>) {
self.nodal_forces = forces;
}
/// Record this node's displacement at every step of [`Self::run`].
pub fn track_node(&mut self, node: NodeId) {
self.tracked_nodes.push(node);
}
/// Build the single-step driver: DOF numbering, element caches, the
/// consistent mass, and the constant external force, assembled once.
pub fn stepper(&self) -> FeaResult<NonlinearDynamicStepper<'_>> {
NonlinearDynamicStepper::build(self)
}
/// March `num_steps` steps of `dt` from rest, via the same stepper a
/// coupling loop would drive.
pub fn run(&mut self) -> FeaResult<NonlinearDynamicResults> {
let mut stepper = self.stepper()?;
let mut state = stepper.rest_state()?;
let mut times = Vec::with_capacity(self.num_steps);
let mut tracked: Vec<Vec<DVector<f64>>> =
vec![Vec::with_capacity(self.num_steps); self.tracked_nodes.len()];
let mut total_iterations = 0usize;
let mut max_iterations_per_step = 0usize;
for step in 1..=self.num_steps {
let (new_state, iterations) = stepper.step(&state)?;
state = new_state;
total_iterations += iterations;
max_iterations_per_step = max_iterations_per_step.max(iterations);
times.push(step as f64 * self.dt);
for (slot, node) in self.tracked_nodes.iter().enumerate() {
let dofs = stepper.node_dofs(*node);
let mut value = DVector::zeros(dofs.len());
for (c, &dof) in dofs.iter().enumerate() {
value[c] = state.displacement[dof];
}
tracked[slot].push(value);
}
}
Ok(NonlinearDynamicResults {
times,
tracked,
displacement: state.displacement,
velocity: state.velocity,
acceleration: state.acceleration,
total_iterations,
max_iterations_per_step,
})
}
/// The DOF indices of a node under the analysis's own numbering, for
/// reading the returned full-length vectors.
pub fn node_dofs(&self, node: NodeId) -> FeaResult<Vec<usize>> {
let numbering =
AdvancedDofNumbering::displacement_only(&self.mesh, DofMappingStrategy::Sequential)?;
Ok(numbering.get_node_dofs(node))
}
}
/// The single-step NewmarkNewton driver behind
/// [`NonlinearDynamicAnalysis`]. Holds everything assembled once (DOF
/// numbering, element caches, consistent mass, the constant body-force
/// vector); the mutable pieces are the nodal forces and the linear solver.
///
/// [`Self::step`] is a pure function of the start-of-step [`DynamicState`]
/// and the current forces: nothing is committed, so a partitioned coupling
/// can re-run one step under updated interface loads until the interface
/// converges, then keep the accepted state.
pub struct NonlinearDynamicStepper<'a> {
analysis: &'a NonlinearDynamicAnalysis,
dof_numbering: AdvancedDofNumbering,
free_dofs: Vec<usize>,
free_index: Vec<Option<usize>>,
total_dofs: usize,
caches: Vec<ElementCache>,
/// Free-free consistent mass, for consistent initial accelerations.
mass_free: SparseMatrix,
/// The body-force part of the external force (constant).
external_body: DVector<f64>,
/// Body force plus the current nodal forces.
external: DVector<f64>,
solver: LuDirect,
solver_options: SolverOptions,
}
impl<'a> NonlinearDynamicStepper<'a> {
#[allow(clippy::too_many_lines)]
fn build(analysis: &'a NonlinearDynamicAnalysis) -> FeaResult<Self> {
let dim = analysis.mesh.spatial_dimension;
let mut dof_numbering = AdvancedDofNumbering::displacement_only(
&analysis.mesh,
DofMappingStrategy::Sequential,
)?;
// Homogeneous Dirichlet only (see the module docs).
for condition in analysis.boundary_conditions.conditions() {
if let BoundaryCondition::Dirichlet(dirichlet) = condition {
for &node in &dirichlet.nodes {
let position = analysis
.mesh
.get_node(node)
.ok_or_else(|| {
AnalysisError::InvalidConfiguration(format!(
"Dirichlet condition names missing node {node:?}"
))
})?
.position();
for component in &dirichlet.components {
if component.canonical_index() >= dim {
continue;
}
let value = dirichlet.get_value(0.0, &position);
if value.abs() > 1e-14 {
return Err(AnalysisError::InvalidConfiguration(
"NonlinearDynamicAnalysis supports homogeneous Dirichlet \
conditions only"
.to_string(),
)
.into());
}
let Some(dof) = dof_numbering.get_dof(node, *component) else {
continue;
};
dof_numbering.constrain_dof(dof)?;
}
}
}
}
let total_dofs = dof_numbering.total_dofs;
let free_dofs = dof_numbering.free_dofs.clone();
let num_free = free_dofs.len();
let mut free_index = vec![None; total_dofs];
for (k, &dof) in free_dofs.iter().enumerate() {
free_index[dof] = Some(k);
}
let mut caches = Vec::with_capacity(analysis.mesh.elements.len());
for element in analysis.mesh.elements.values() {
let coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| analysis.mesh.get_node(*id).unwrap().position())
.collect();
let dofs: Vec<usize> = element
.nodes
.iter()
.flat_map(|node| dof_numbering.get_node_dofs(*node))
.collect();
let material = analysis
.materials
.get_material(element.material_id)
.ok_or_else(|| {
AnalysisError::InvalidConfiguration(format!(
"Material {} not found",
element.material_id.0
))
})?;
let density = material.properties().density;
let fe = StandardFiniteElement::new(element.element_type, coords.clone());
let scalar =
ElementMatrixComputer::compute_consistent_mass_matrix(&fe, &coords, density, None)?;
let nodes = element.nodes.len();
let mut mass = DMatrix::zeros(nodes * dim, nodes * dim);
for a in 0..nodes {
for b in 0..nodes {
let m = scalar.matrix[(a, b)];
for d in 0..dim {
mass[(a * dim + d, b * dim + d)] = m;
}
}
}
caches.push(ElementCache {
coords,
dofs,
element_type: element.element_type,
mass,
material_id: element.material_id,
});
}
// Free-free consistent mass (for initial accelerations).
let mut mass_free = SparseMatrix::new(num_free, num_free);
for cache in &caches {
for (local_row, &dof_row) in cache.dofs.iter().enumerate() {
let Some(free_row) = free_index[dof_row] else {
continue;
};
for (local_col, &dof_col) in cache.dofs.iter().enumerate() {
if let Some(free_col) = free_index[dof_col] {
let value = cache.mass[(local_row, local_col)];
if value != 0.0 {
mass_free.add_entry(free_row, free_col, value)?;
}
}
}
}
}
mass_free.finalize()?;
// Constant consistent external force from the body-force field.
let mut external_body: DVector<f64> = DVector::zeros(num_free);
if let Some(force) = &analysis.body_force {
for cache in &caches {
let fe = StandardFiniteElement::new(cache.element_type, cache.coords.clone());
let f_e = ElementMatrixComputer::compute_body_force_vector(
&fe,
&cache.coords,
force.as_ref(),
None,
)?;
for (local, &dof) in cache.dofs.iter().enumerate() {
if let Some(free) = free_index[dof] {
external_body[free] += f_e[local];
}
}
}
}
let mut stepper = Self {
analysis,
dof_numbering,
free_dofs,
free_index,
total_dofs,
caches,
mass_free,
external_body: external_body.clone(),
external: external_body,
solver: LuDirect::new(),
solver_options: SolverOptions::default(),
};
stepper.set_nodal_forces(&analysis.nodal_forces);
Ok(stepper)
}
/// Replace the concentrated nodal forces (the interface load of a
/// coupling subiteration). The body-force part is unaffected.
pub fn set_nodal_forces(&mut self, forces: &[(NodeId, Vector3<f64>)]) {
self.external.copy_from(&self.external_body);
for (node, force) in forces {
let dofs = self.dof_numbering.get_node_dofs(*node);
for (component, &dof) in dofs.iter().enumerate() {
if let Some(free) = self.free_index[dof] {
self.external[free] += force[component];
}
}
}
}
/// The state at rest under the *current* external force: `u = v = 0`,
/// the acceleration consistent with `M a0 = F_ext - f_int(0)`.
pub fn rest_state(&mut self) -> FeaResult<DynamicState> {
let u = DVector::zeros(self.total_dofs);
let (f_int0, _) = self.assemble(&u, false)?;
let residual0 = &self.external - &f_int0;
let (a0_free, _) = self
.solver
.solve(&self.mass_free, &residual0, &self.solver_options)?;
let mut a = DVector::zeros(self.total_dofs);
for (k, &dof) in self.free_dofs.iter().enumerate() {
a[dof] = a0_free[k];
}
Ok(DynamicState {
displacement: DVector::zeros(self.total_dofs),
velocity: DVector::zeros(self.total_dofs),
acceleration: a,
})
}
/// One Newmark step of the analysis's `dt` from `state` under the
/// current forces. Returns the end-of-step state and the Newton
/// iteration count; commits nothing — calling again with the same
/// state and forces returns the identical result.
pub fn step(&mut self, state: &DynamicState) -> FeaResult<(DynamicState, usize)> {
let dt = self.analysis.dt;
let gamma = self.analysis.gamma;
let beta = self.analysis.beta;
let criteria = &self.analysis.criteria;
let force_scale = self.external.norm().max(1.0);
let mut u_pred = DVector::zeros(self.total_dofs);
for &dof in &self.free_dofs {
u_pred[dof] = state.displacement[dof]
+ dt * state.velocity[dof]
+ dt * dt * (0.5 - beta) * state.acceleration[dof];
}
let inv_beta_dt2 = 1.0 / (beta * dt * dt);
// Newton on the end-of-step displacement, starting from the
// predictor (a_new = 0 there).
let mut u_iter = u_pred.clone();
let mut step_converged = false;
let mut iterations = 0usize;
for _ in 0..criteria.max_iterations {
let mut a_new = DVector::zeros(self.total_dofs);
for &dof in &self.free_dofs {
a_new[dof] = inv_beta_dt2 * (u_iter[dof] - u_pred[dof]);
}
let (f_int, tangent) = self.assemble(&u_iter, true)?;
let residual = &self.external - &f_int - self.mass_times(&a_new);
if residual.norm() < criteria.force_tolerance * force_scale {
step_converged = true;
break;
}
iterations += 1;
let (delta, _) = self
.solver
.solve(&tangent, &residual, &self.solver_options)?;
for (k, &dof) in self.free_dofs.iter().enumerate() {
u_iter[dof] += delta[k];
}
if delta.norm() < criteria.displacement_tolerance * u_iter.norm().max(1.0) {
step_converged = true;
break;
}
}
if !step_converged {
return Err(AnalysisError::ConvergenceFailed { iterations }.into());
}
let mut a_new = DVector::zeros(self.total_dofs);
let mut v_new = DVector::zeros(self.total_dofs);
for &dof in &self.free_dofs {
a_new[dof] = inv_beta_dt2 * (u_iter[dof] - u_pred[dof]);
v_new[dof] = state.velocity[dof]
+ dt * ((1.0 - gamma) * state.acceleration[dof] + gamma * a_new[dof]);
}
Ok((
DynamicState {
displacement: u_iter,
velocity: v_new,
acceleration: a_new,
},
iterations,
))
}
/// The DOF indices of a node, for reading [`DynamicState`] vectors.
pub fn node_dofs(&self, node: NodeId) -> Vec<usize> {
self.dof_numbering.get_node_dofs(node)
}
/// Internal force and (optionally) tangent at a full displacement
/// vector, reduced to the free DOFs. The tangent includes the Newmark
/// mass term `M / (β Δt²)`.
fn assemble(
&self,
solution: &DVector<f64>,
with_tangent: bool,
) -> FeaResult<(DVector<f64>, SparseMatrix)> {
let dim = self.analysis.mesh.spatial_dimension;
let num_free = self.free_dofs.len();
let mut internal = DVector::zeros(num_free);
let mut tangent = SparseMatrix::new(num_free, num_free);
let inv_beta_dt2 = 1.0 / (self.analysis.beta * self.analysis.dt * self.analysis.dt);
for cache in &self.caches {
let material = self
.analysis
.materials
.get_material(cache.material_id)
.unwrap();
let fe = StandardFiniteElement::new(cache.element_type, cache.coords.clone());
let mut element_displacement = DVector::zeros(cache.dofs.len());
for (local, &dof) in cache.dofs.iter().enumerate() {
element_displacement[local] = solution[dof];
}
let (f_int, k_t) = if self.analysis.total_lagrangian {
let (lambda, mu) = material.properties().lame_parameters();
let constitutive = saint_venant_kirchhoff(lambda, mu, dim);
total_lagrangian::internal_force_and_tangent(
&fe,
&cache.coords,
&element_displacement,
constitutive.as_ref(),
None,
)?
} else {
let constitutive = reduced_constitutive(material, dim)?;
ElementMatrixComputer::compute_internal_force_and_tangent(
&fe,
&cache.coords,
&element_displacement,
constitutive.as_ref(),
None,
)?
};
for (local_row, &dof_row) in cache.dofs.iter().enumerate() {
let Some(free_row) = self.free_index[dof_row] else {
continue;
};
internal[free_row] += f_int[local_row];
if with_tangent {
for (local_col, &dof_col) in cache.dofs.iter().enumerate() {
if let Some(free_col) = self.free_index[dof_col] {
let value = k_t[(local_row, local_col)]
+ inv_beta_dt2 * cache.mass[(local_row, local_col)];
if value != 0.0 {
tangent.add_entry(free_row, free_col, value)?;
}
}
}
}
}
}
if with_tangent {
tangent.finalize()?;
}
Ok((internal, tangent))
}
/// M times a full-length vector, reduced to the free DOFs.
fn mass_times(&self, a_full: &DVector<f64>) -> DVector<f64> {
let num_free = self.free_dofs.len();
let mut out = DVector::zeros(num_free);
for cache in &self.caches {
let mut a_e = DVector::zeros(cache.dofs.len());
for (local, &dof) in cache.dofs.iter().enumerate() {
a_e[local] = a_full[dof];
}
let m_a = &cache.mass * a_e;
for (local, &dof) in cache.dofs.iter().enumerate() {
if let Some(free) = self.free_index[dof] {
out[free] += m_a[local];
}
}
}
out
}
}
@@ -0,0 +1,449 @@
//! Rung S2: the nonlinear Newmark analysis
//! (`analysis::nonlinear_dynamic`), verified in two steps:
//!
//! 1. **Linear limit**: under a load small enough that finite-strain terms
//! vanish (strains ~1e-9), the total-Lagrangian nonlinear stepper must
//! reproduce the verified linear `NewmarkStepper` marching the same
//! consistent mass and (plane-strain) stiffness, step for step.
//!
//! 2. **TurekHron CSM3**: the flag under gravity switched on at rest,
//! plane-strain St. VenantKirchhoff, Newmark average acceleration,
//! Δt = 0.005 — the benchmark's own time step. Reference (FEATFLOW):
//! `ux(A) = 14.305 ± 14.305 [1.0995 Hz]`,
//! `uy(A) = 63.607 ± 65.160 [1.0995 Hz]`.
//! Measured values and bands are in the test body; the mesh is the
//! 35×2 Quad8 the static CSM tests bounded at ~1.5%.
use nalgebra::{DMatrix, DVector, Vector3};
use rtx_fea::analysis::{
Analysis, AnalysisConfig, NewmarkStepper, NonlinearConfig, NonlinearDynamicAnalysis,
NonlinearStaticAnalysis,
};
use rtx_fea::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy};
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::elements::{ElementMatrixComputer, FiniteElement, StandardFiniteElement};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
const E_MOD: f64 = 1.4e6;
const NU: f64 = 0.4;
const RHO: f64 = 1000.0;
const G: f64 = 2.0;
/// `nx` by `ny` Quad8 mesh of `[x0, x1] x [y0, y1]` (serendipity lattice),
/// as in `tests/total_lagrangian_svk.rs`.
fn quad8_rect_mesh(x0: f64, x1: f64, y0: f64, y1: f64, nx: usize, ny: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = x0 + (x1 - x0) * i as f64 / (2 * nx) as f64;
let y = y0 + (y1 - y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
fn materials() -> MaterialDatabase {
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(E_MOD, NU).with_density(RHO),
None,
);
db
}
fn clamp_left(mesh: &Mesh, x_left: f64) -> BoundaryConditionSet {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - x_left).abs() < 1e-12)
.map(|(&id, _)| id)
.collect();
let mut set = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
set.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
set
}
fn point_a(mesh: &Mesh, x: f64, y: f64) -> NodeId {
mesh.nodes
.iter()
.find(|(_, node)| {
(node.position().x - x).abs() < 1e-12 && (node.position().y - y).abs() < 1e-12
})
.map(|(&id, _)| id)
.expect("tracking point must be a mesh node")
}
fn mid_amp(series: &[f64]) -> (f64, f64) {
let max = series.iter().copied().fold(f64::MIN, f64::max);
let min = series.iter().copied().fold(f64::MAX, f64::min);
(0.5 * (max + min), 0.5 * (max - min))
}
fn crossing_frequency(times: &[f64], series: &[f64]) -> f64 {
let (mean, _) = mid_amp(series);
let mut crossings: Vec<f64> = Vec::new();
for k in 1..series.len() {
let (a, b) = (series[k - 1] - mean, series[k] - mean);
if a < 0.0 && b >= 0.0 {
crossings.push(times[k - 1] + (a / (a - b)) * (times[k] - times[k - 1]));
}
}
assert!(crossings.len() >= 3, "too few oscillation periods");
(crossings.len() - 1) as f64 / (crossings.last().unwrap() - crossings.first().unwrap())
}
/// 1. Linear limit: tiny gravity, TL nonlinear stepper vs the linear
/// `NewmarkStepper` on the same dense M, K, F.
#[test]
fn linear_limit_matches_the_linear_newmark_stepper() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 10, 2);
let scale = 1e-6;
let dt = 0.005;
let steps = 120;
let a_node = point_a(&mesh, 0.6, 0.2);
// Nonlinear TL run.
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_body_force(move |_| Vector3::new(0.0, -RHO * G * scale, 0.0));
analysis.track_node(a_node);
let results = analysis.run().unwrap();
let uy_nonlinear: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
// Linear reference: dense free-free M, K (plane strain, the TL tangent
// at u = 0), consistent F; the verified linear stepper.
let mut numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
for (&id, node) in mesh.nodes.iter() {
if (node.position().x - 0.25).abs() < 1e-12 {
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
let dof = numbering.get_dof(id, component).unwrap();
numbering.constrain_dof(dof).unwrap();
}
}
}
let free = numbering.free_dofs.clone();
let mut free_index = vec![None; numbering.total_dofs];
for (k, &dof) in free.iter().enumerate() {
free_index[dof] = Some(k);
}
let n = free.len();
let mut mass = DMatrix::zeros(n, n);
let mut stiffness = DMatrix::zeros(n, n);
let mut force = DVector::zeros(n);
let mu = E_MOD / (2.0 * (1.0 + NU));
let lambda = E_MOD * NU / ((1.0 + NU) * (1.0 - 2.0 * NU));
let mut d = DMatrix::zeros(3, 3);
d[(0, 0)] = lambda + 2.0 * mu;
d[(1, 1)] = lambda + 2.0 * mu;
d[(0, 1)] = lambda;
d[(1, 0)] = lambda;
d[(2, 2)] = mu;
let linear = move |strain: &DVector<f64>| Ok((&d * strain, d.clone()));
for element in mesh.elements.values() {
let coords: Vec<Vector3<f64>> = element
.nodes
.iter()
.map(|id| mesh.get_node(*id).unwrap().position())
.collect();
let fe = StandardFiniteElement::new(element.element_type, coords.clone());
let dofs: Vec<usize> = element
.nodes
.iter()
.flat_map(|node| numbering.get_node_dofs(*node))
.collect();
let zero = DVector::zeros(dofs.len());
let (_, k_e) = ElementMatrixComputer::compute_internal_force_and_tangent(
&fe, &coords, &zero, &linear, None,
)
.unwrap();
let m_scalar =
ElementMatrixComputer::compute_consistent_mass_matrix(&fe, &coords, RHO, None).unwrap();
let f_e = ElementMatrixComputer::compute_body_force_vector(
&fe,
&coords,
&|_| Vector3::new(0.0, -RHO * G * scale, 0.0),
None,
)
.unwrap();
for (lr, &dr) in dofs.iter().enumerate() {
let Some(fr) = free_index[dr] else { continue };
force[fr] += f_e[lr];
for (lc, &dc) in dofs.iter().enumerate() {
if let Some(fc) = free_index[dc] {
stiffness[(fr, fc)] += k_e[(lr, lc)];
let (a, b) = (lr / 2, lc / 2);
if lr % 2 == lc % 2 {
mass[(fr, fc)] += m_scalar.matrix[(a, b)];
}
}
}
}
}
let damping = DMatrix::zeros(n, n);
let stepper = NewmarkStepper::average_acceleration(mass, damping, stiffness, dt).unwrap();
let mut state = stepper
.initial_state(DVector::zeros(n), DVector::zeros(n), &force)
.unwrap();
let a_dofs = numbering.get_node_dofs(a_node);
let a_free = free_index[a_dofs[1]].expect("point A is free");
let mut uy_linear = Vec::with_capacity(steps);
for _ in 0..steps {
state = stepper.step(&state, &force).unwrap();
uy_linear.push(state.displacement[a_free]);
}
let amplitude = uy_linear.iter().fold(0.0f64, |acc, &x| acc.max(x.abs()));
let max_diff = uy_nonlinear
.iter()
.zip(&uy_linear)
.fold(0.0f64, |acc, (a, b)| acc.max((a - b).abs()));
println!(
" linear limit: amplitude {amplitude:.3e}, max |nonlinear - linear| {max_diff:.3e} \
({:.2e} relative)",
max_diff / amplitude
);
assert!(
max_diff < 1e-4 * amplitude,
"nonlinear stepper deviates from the linear one in the linear limit: \
{max_diff:.3e} vs amplitude {amplitude:.3e}"
);
}
/// 2. TurekHron CSM3. Bands are the measured ones for the 35×2 Quad8 mesh
/// (recorded in the printed line; the static CSM tests bound this mesh's
/// spatial error at ~1.5%).
#[test]
fn turek_hron_csm3_oscillation() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 35, 2);
let dt = 0.005;
let steps = 1200; // 6 s, ~6.6 oscillation periods (2000 steps measured the same bands in 372 s)
let a_node = point_a(&mesh, 0.6, 0.2);
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_body_force(|_| Vector3::new(0.0, -RHO * G, 0.0));
analysis.track_node(a_node);
let start = std::time::Instant::now();
let results = analysis.run().unwrap();
let seconds = start.elapsed().as_secs_f64();
let ux: Vec<f64> = results.tracked[0].iter().map(|u| u[0]).collect();
let uy: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
let (ux_mean, ux_amp) = mid_amp(&ux);
let (uy_mean, uy_amp) = mid_amp(&uy);
let frequency = crossing_frequency(&results.times, &uy);
let half = uy.len() / 2;
let (_, amp_first) = mid_amp(&uy[..half]);
let (_, amp_second) = mid_amp(&uy[half..]);
println!(
" CSM3 35x2 Quad8, dt = {dt}: ux(A) {:.3} ± {:.3} mm, uy(A) {:.3} ± {:.3} mm, \
f = {frequency:.4} Hz; half-window uy amps {:.3}/{:.3} mm; \
{} Newton iterations total (max {}/step); {seconds:.0} s. \
Reference: ux 14.305 ± 14.305, uy 63.607 ± 65.160 [1.0995 Hz]",
ux_mean * 1e3,
ux_amp * 1e3,
uy_mean * 1e3,
uy_amp * 1e3,
amp_first * 1e3,
amp_second * 1e3,
results.total_iterations,
results.max_iterations_per_step,
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
// Undamped average acceleration: the amplitude must persist.
assert!(
(amp_first - amp_second).abs() < 0.02 * amp_second,
"the undamped oscillation is losing amplitude: {amp_first:.4} vs {amp_second:.4}"
);
assert!(
rel(frequency, 1.0995) < 0.02,
"frequency {frequency:.4} vs reference 1.0995"
);
assert!(
rel(uy_mean, -63.607e-3) < 0.03,
"uy mean {:.4e} vs reference -63.607e-3",
uy_mean
);
assert!(
rel(uy_amp, 65.160e-3) < 0.03,
"uy amplitude {:.4e} vs reference 65.160e-3",
uy_amp
);
assert!(
rel(ux_mean, -14.305e-3) < 0.05 && rel(ux_amp, 14.305e-3) < 0.05,
"ux {:.4e} ± {:.4e} vs reference -14.305e-3 ± 14.305e-3",
ux_mean,
ux_amp
);
assert!(
results.max_iterations_per_step <= 5,
"Newton needed {} iterations in one step",
results.max_iterations_per_step
);
}
/// 3. The stepper under a nodal step load — the FSI2 seam. Three claims:
///
/// a. Driving the stepper by hand (set the nodal force, step, commit) is
/// bit-identical to `run()` with the same force set on the analysis —
/// one code path, verified from both ends.
/// b. `step` commits nothing: repeating a step from the same state under
/// the same force is bit-identical; changing the force between the
/// repeats changes the answer (the subiteration a coupling loop needs).
/// c. The undamped step response oscillates about the static deflection:
/// `mid_amp` of the tip trajectory must match the *static* nonlinear
/// analysis under the identical nodal force — a different code path —
/// in both mean and amplitude (`u(t) ≈ u_s (1 cos ωt)` while the
/// first mode dominates a tip-loaded cantilever).
#[test]
fn stepper_nodal_step_load_oscillates_about_the_static_deflection() {
let mesh = quad8_rect_mesh(0.25, 0.6, 0.19, 0.21, 10, 2);
let dt = 0.005;
let steps = 400; // 2 s: two periods of the ~1 Hz first mode
let a_node = point_a(&mesh, 0.6, 0.2);
let tip_force = Vector3::new(0.0, -0.1, 0.0);
// run() with the force set on the analysis.
let mut analysis = NonlinearDynamicAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
dt,
steps,
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_nodal_forces(vec![(a_node, tip_force)]);
analysis.track_node(a_node);
let results = analysis.run().unwrap();
let uy_run: Vec<f64> = results.tracked[0].iter().map(|u| u[1]).collect();
// The same march, driven by hand through the stepper.
let mut stepper = analysis.stepper().unwrap();
stepper.set_nodal_forces(&[(a_node, tip_force)]);
let mut state = stepper.rest_state().unwrap();
let a_dofs = stepper.node_dofs(a_node);
let mut uy_manual = Vec::with_capacity(steps);
for step in 0..steps {
if step == 7 {
// b. Re-running the same step is bit-identical; a different
// force from the same state gives a different answer and
// leaves no trace once the force is restored.
let (first, _) = stepper.step(&state).unwrap();
let (again, _) = stepper.step(&state).unwrap();
assert_eq!(
first.displacement, again.displacement,
"re-running a step from the same state changed the answer"
);
stepper.set_nodal_forces(&[(a_node, 2.0 * tip_force)]);
let (other, _) = stepper.step(&state).unwrap();
// One step's response to an extra force is ~ ΔF β Δt² / m_modal
// (≈ 1% of the accumulated displacement here), downward.
let moved = other.displacement[a_dofs[1]] - first.displacement[a_dofs[1]];
assert!(
moved < -1e-3 * first.displacement[a_dofs[1]].abs(),
"doubling the interface force did not move the step down: \
delta {moved:.3e} vs u {:.3e}",
first.displacement[a_dofs[1]]
);
stepper.set_nodal_forces(&[(a_node, tip_force)]);
}
let (new_state, _) = stepper.step(&state).unwrap();
state = new_state;
uy_manual.push(state.displacement[a_dofs[1]]);
}
// a. One code path, verified from both ends.
assert_eq!(
uy_run, uy_manual,
"manual stepper drive deviates from run()"
);
// c. Static deflection under the identical nodal force, from the
// nonlinear *static* analysis.
let mut static_analysis = NonlinearStaticAnalysis::new(
mesh.clone(),
materials(),
clamp_left(&mesh, 0.25),
NonlinearConfig::default(),
AnalysisConfig::default(),
)
.with_total_lagrangian();
static_analysis.set_nodal_forces(vec![(a_node, tip_force)]);
let static_results = static_analysis.run().unwrap();
assert!(static_results.convergence.converged);
let numbering =
AdvancedDofNumbering::displacement_only(&mesh, DofMappingStrategy::Sequential).unwrap();
let uy_static = static_results.displacements[numbering.get_node_dofs(a_node)[1]];
let (uy_mean, uy_amp) = mid_amp(&uy_manual);
println!(
" step load: static uy = {uy_static:.4e}, dynamic mid ± amp = \
{uy_mean:.4e} ± {uy_amp:.4e}"
);
assert!(
uy_static < -1e-4,
"static deflection suspiciously small: {uy_static:.3e}"
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
assert!(
rel(uy_mean, uy_static) < 0.03,
"oscillation midpoint {uy_mean:.4e} vs static deflection {uy_static:.4e}"
);
assert!(
rel(uy_amp, -uy_static) < 0.06,
"oscillation amplitude {uy_amp:.4e} vs |static| {:.4e}",
-uy_static
);
}
+3 -2
View File
@@ -15,10 +15,11 @@ nalgebra = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[dev-dependencies] [dev-dependencies]
# The coupled piston benchmark drives the real ALE fluid; the coupling # The coupled benchmarks drive the real fluid and structure solvers; the
# library itself still depends on no solver. # coupling library itself still depends on no solver.
futures = { workspace = true } futures = { workspace = true }
rtx-cfd = { workspace = true } rtx-cfd = { workspace = true }
rtx-fea = { workspace = true }
[lints] [lints]
workspace = true workspace = true
+50 -9
View File
@@ -106,10 +106,34 @@ impl WettedSurface {
let mut rows = Vec::with_capacity(fluid_faces.len()); let mut rows = Vec::with_capacity(fluid_faces.len());
for (face_index, face) in fluid_faces.iter().enumerate() { for (face_index, face) in fluid_faces.iter().enumerate() {
let recruited = nearest(structure_nodes, face.centroid, NEIGHBOURS); // Adaptive recruitment: a NEARLY collinear neighbourhood (the
let weights = solve_weights(structure_nodes, &recruited, face.centroid) // nearest nodes of a face on a smoothly DEFORMED edge — y is
.ok_or(FsiError::DegenerateNeighbourhood { face: face_index })?; // almost a linear function of x, off by the curvature sagitta)
rows.push(recruited.into_iter().zip(weights).collect()); // cannot satisfy exact centroid reproduction with bounded
// weights: the offending singular value is too large to
// truncate and too small to invert accurately, and any
// truncated solve misses reproduction by the sagitta. No
// weight choice fixes that; a two-dimensional neighbourhood
// does. So on failure the recruitment widens (8 → 16 → 32 →
// everything) until the verified constraints hold — for a thin
// structure that pulls in the opposite face's nodes, which is
// exactly the transverse spread the constraint system needs.
// Found by the TurekHron FSI1 flag's deformed bottom edge.
let mut solved = None;
let mut count = NEIGHBOURS;
loop {
let recruited = nearest(structure_nodes, face.centroid, count);
if let Some(weights) = solve_weights(structure_nodes, &recruited, face.centroid) {
solved = Some(recruited.into_iter().zip(weights).collect());
break;
}
if count >= structure_nodes.len() {
break;
}
count = (count * 2).min(structure_nodes.len());
}
let row = solved.ok_or(FsiError::DegenerateNeighbourhood { face: face_index })?;
rows.push(row);
} }
Ok(Self { Ok(Self {
@@ -245,21 +269,38 @@ fn solve_weights(
recruited: &[usize], recruited: &[usize],
centroid: Vector3<f64>, centroid: Vector3<f64>,
) -> Option<Vec<f64>> { ) -> Option<Vec<f64>> {
// Centre on the face and scale by the neighbourhood radius before
// forming the constraint Gram. The constraints are unchanged —
// `sum w = 1` and `sum w (node - centroid)/scale = 0` is exactly
// partition of unity plus reproduction of the centroid — but the
// conditioning is O(1) instead of growing with (position / spacing)^2:
// with raw coordinates, nodes near x ~ 0.26 spaced 0.005 apart put the
// Gram's condition number past 1e4, the 4x4 SVD pseudo-inverse lost
// enough accuracy that the verification below rejected a perfectly
// healthy neighbourhood, and the operator's behaviour depended on WHERE
// the interface sat — found by the Turek-Hron FSI1 flag, whose bottom
// edge is exactly such a neighbourhood. A transfer operator must be
// translation-invariant; centring makes it so.
let scale = recruited
.iter()
.map(|index| (nodes[*index] - centroid).norm())
.fold(0.0_f64, f64::max)
.max(1e-300);
let mut gram = Matrix4::zeros(); let mut gram = Matrix4::zeros();
for index in recruited { for index in recruited {
let node = nodes[*index]; let local = (nodes[*index] - centroid) / scale;
let row = Vector4::new(1.0, node.x, node.y, node.z); let row = Vector4::new(1.0, local.x, local.y, local.z);
gram += row * row.transpose(); gram += row * row.transpose();
} }
let target = Vector4::new(1.0, centroid.x, centroid.y, centroid.z); let target = Vector4::new(1.0, 0.0, 0.0, 0.0);
let lambda = gram.pseudo_inverse(SINGULAR_TOLERANCE).ok()? * target; let lambda = gram.pseudo_inverse(SINGULAR_TOLERANCE).ok()? * target;
let weights: Vec<f64> = recruited let weights: Vec<f64> = recruited
.iter() .iter()
.map(|index| { .map(|index| {
let node = nodes[*index]; let local = (nodes[*index] - centroid) / scale;
Vector4::new(1.0, node.x, node.y, node.z).dot(&lambda) Vector4::new(1.0, local.x, local.y, local.z).dot(&lambda)
}) })
.collect(); .collect();
@@ -0,0 +1,41 @@
use nalgebra::Vector3;
use rtx_fsi::{FluidFace, WettedSurface};
/// A face on a smoothly curved, nearly collinear edge (the deformed-flag
/// neighbourhood that broke the fixed-count recruitment): the transfer must
/// widen the neighbourhood until the opposite face's nodes give it a
/// two-dimensional spread, and the verified constraints must then hold.
#[test]
fn nearly_collinear_curved_edge_recruits_wider() {
let mut nodes = Vec::new();
let mut x = 0.255;
// Deformed edges: y = y0 + kappa * (x - 0.25)^2 with the FSI1 scale.
while x < 0.6 + 1e-9 {
let bend = 0.013 * (x - 0.25) * (x - 0.25) / 2.0;
nodes.push(Vector3::new(x, 0.19 + bend, 0.0));
nodes.push(Vector3::new(x, 0.21 + bend, 0.0));
x += 0.005;
}
let bend = |x: f64| 0.013 * (x - 0.25) * (x - 0.25) / 2.0;
let faces: Vec<FluidFace> = (0..60)
.map(|k| {
let x = 0.26 + 0.005 * k as f64 + 0.00125;
FluidFace {
centroid: Vector3::new(x, 0.19 + bend(x), 0.0),
normal: Vector3::new(0.0, -1.0, 0.0),
area: 0.0025,
}
})
.collect();
let surface = WettedSurface::build(&faces, &nodes).expect("adaptive recruitment must succeed");
for face in 0..faces.len() {
let weights = surface.weights_for(face);
let unity: f64 = weights.iter().map(|(_, w)| w).sum();
assert!((unity - 1.0).abs() < 1e-9, "face {face}: unity {unity}");
let max_weight = weights.iter().map(|(_, w)| w.abs()).fold(0.0, f64::max);
assert!(
max_weight < 100.0,
"face {face}: weight blow-up {max_weight}"
);
}
}
@@ -0,0 +1,607 @@
//! TurekHron FSI1: the first coupled cylinder-plus-flag computation —
//! rung C1 of the ladder (omni-cortex `docs/turek_hron_geometry_decision.md`).
//!
//! Re = 20 channel flow past the rigid cylinder with the ELASTIC flag:
//! the embedded-boundary fluid (TVD + multigrid, rung F1/F2) provides
//! tractions on the deformed flag surface, `rtx-fsi`'s `WettedSurface`
//! carries them to the flag's boundary nodes (rebuilt on the deformed
//! interface every subiteration — the small-displacement limit retired in
//! practice), the total-Lagrangian St. VenantKirchhoff flag (rung S1)
//! solves statically, and `Subiterated::aitken` drives the exchange to a
//! fixed point. The flag's wetted boundary lives as a polygon whose vertex
//! list sits behind a lock: the fluid's moving-body path re-reads it on
//! every step's mask rebuild (rung F2), so a shape update is just a write
//! to that list.
//!
//! FSI1 is steady and its tip displacement (reference `ux(A) = 0.0227 mm`,
//! `uy(A) = 0.8209 mm`) is a fifth of a fluid cell — it validates the
//! COUPLING machinery, not large deformation: loads, transfer,
//! conservation, and the fixed point. Reference values (FEATFLOW level 7):
//! `ux(A) = 2.270493e-5 m`, `uy(A) = 8.208773e-4 m`, drag 14.29426, lift
//! 0.763746 on cylinder + flag.
//!
//! Measured values and the assertion bands are recorded at the bottom once
//! the first run lands; conservation of the transferred load (partition of
//! unity) is asserted at 1e-10 every pass.
use std::cell::RefCell;
use std::sync::{Arc, RwLock};
use nalgebra::Vector3;
use rtx_cfd::CfdConfig;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver,
FlowField, PoissonSolverKind, SideBoundary, polygon_signed_distance,
};
use rtx_fea::analysis::{Analysis, AnalysisConfig, NonlinearConfig, NonlinearStaticAnalysis};
use rtx_fea::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy};
use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType};
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
use rtx_fsi::{FluidFace, Subiterated, WettedSurface};
const L: f64 = 2.5;
const H: f64 = 0.41;
const RHO_F: f64 = 1000.0;
const NU_F: f64 = 1e-3;
const U_MEAN: f64 = 0.2;
const E_S: f64 = 1.4e6;
const NU_S: f64 = 0.4;
const FLAG_X0: f64 = 0.25;
const FLAG_X1: f64 = 0.6;
const FLAG_Y0: f64 = 0.19;
const FLAG_Y1: f64 = 0.21;
const REF_UX: f64 = 2.270_493e-5;
const REF_UY: f64 = 8.208_773e-4;
const REF_DRAG: f64 = 14.294_26;
const REF_LIFT: f64 = 0.763_746;
fn circle_sdf(x: f64, y: f64) -> f64 {
((x - 0.2).powi(2) + (y - 0.2).powi(2)).sqrt() - 0.05
}
fn inflow(y: f64) -> f64 {
1.5 * U_MEAN * y * (H - y) / (0.5 * H).powi(2)
}
/// The flag's Quad8 mesh (as in rtx-fea's CSM tests).
fn flag_mesh(nx: usize, ny: usize) -> Mesh {
let mut mesh = Mesh::new(2).unwrap();
let (lx, ly) = (2 * nx + 1, 2 * ny + 1);
let mut grid = vec![vec![None; ly]; lx];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
if i % 2 == 1 && j % 2 == 1 {
continue;
}
let x = FLAG_X0 + (FLAG_X1 - FLAG_X0) * i as f64 / (2 * nx) as f64;
let y = FLAG_Y0 + (FLAG_Y1 - FLAG_Y0) * j as f64 / (2 * ny) as f64;
*slot = Some(mesh.add_node(Node::new_2d(x, y)));
}
}
for i in 0..nx {
for j in 0..ny {
let (a, b) = (2 * i, 2 * j);
let nodes = vec![
grid[a][b].unwrap(),
grid[a + 2][b].unwrap(),
grid[a + 2][b + 2].unwrap(),
grid[a][b + 2].unwrap(),
grid[a + 1][b].unwrap(),
grid[a + 2][b + 1].unwrap(),
grid[a + 1][b + 2].unwrap(),
grid[a][b + 1].unwrap(),
];
mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
mesh
}
struct Interface {
/// Wetted boundary nodes (everything on the bottom/tip/top edges except
/// the clamped left corners), sorted by id — the coupling vector is
/// their `(ux, uy)` pairs in this order.
wetted: Vec<NodeId>,
/// Reference positions of the wetted nodes.
reference: Vec<(f64, f64)>,
/// The ordered boundary walk for the polygon: indices into `wetted`
/// (`usize::MAX` marks the fixed anchor vertices).
walk: Vec<(usize, (f64, f64))>,
}
impl Interface {
fn build(mesh: &Mesh) -> Self {
let eps = 1e-9;
let on_bottom = |p: Vector3<f64>| (p.y - FLAG_Y0).abs() < eps;
let on_top = |p: Vector3<f64>| (p.y - FLAG_Y1).abs() < eps;
let on_tip = |p: Vector3<f64>| (p.x - FLAG_X1).abs() < eps;
let clamped = |p: Vector3<f64>| (p.x - FLAG_X0).abs() < eps;
let mut wetted: Vec<(NodeId, (f64, f64))> = mesh
.nodes
.iter()
.filter(|(_, node)| {
let p = node.position();
(on_bottom(p) || on_top(p) || on_tip(p)) && !clamped(p)
})
.map(|(&id, node)| (id, (node.position().x, node.position().y)))
.collect();
wetted.sort_by_key(|(id, _)| *id);
let index_of = |id: NodeId| wetted.iter().position(|(w, _)| *w == id).unwrap();
// Ordered walk, counterclockwise: anchor inside the cylinder, the
// clamped bottom corner, bottom edge left -> right, tip bottom ->
// top, top edge right -> left, the clamped top corner, anchor.
let mut bottom: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| on_bottom(n.position()) && !clamped(n.position()))
.map(|(&id, n)| (id, n.position().x))
.collect();
bottom.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut tip: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| {
let p = n.position();
on_tip(p) && !on_bottom(p) && !on_top(p)
})
.map(|(&id, n)| (id, n.position().y))
.collect();
tip.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut top: Vec<(NodeId, f64)> = mesh
.nodes
.iter()
.filter(|(_, n)| on_top(n.position()) && !clamped(n.position()))
.map(|(&id, n)| (id, n.position().x))
.collect();
top.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
let mut walk: Vec<(usize, (f64, f64))> = Vec::new();
walk.push((usize::MAX, (0.22, FLAG_Y0)));
walk.push((usize::MAX, (FLAG_X0, FLAG_Y0)));
for (id, _) in &bottom {
walk.push((index_of(*id), (0.0, 0.0)));
}
for (id, _) in &tip {
walk.push((index_of(*id), (0.0, 0.0)));
}
for (id, _) in &top {
walk.push((index_of(*id), (0.0, 0.0)));
}
walk.push((usize::MAX, (FLAG_X0, FLAG_Y1)));
walk.push((usize::MAX, (0.22, FLAG_Y1)));
let reference = wetted.iter().map(|(_, p)| *p).collect();
Self {
wetted: wetted.into_iter().map(|(id, _)| id).collect(),
reference,
walk,
}
}
/// Deformed polygon vertices for the interface vector `d`.
fn polygon(&self, d: &[f64]) -> Vec<(f64, f64)> {
self.walk
.iter()
.map(|&(k, anchor)| {
if k == usize::MAX {
anchor
} else {
let (x0, y0) = self.reference[k];
(x0 + d[2 * k], y0 + d[2 * k + 1])
}
})
.collect()
}
/// Deformed wetted node positions for the transfer.
fn deformed_nodes(&self, d: &[f64]) -> Vec<Vector3<f64>> {
self.reference
.iter()
.enumerate()
.map(|(k, &(x0, y0))| Vector3::new(x0 + d[2 * k], y0 + d[2 * k + 1], 0.0))
.collect()
}
}
/// One static TL solve of the flag under the given wetted nodal forces;
/// returns the new interface vector and the tip displacement.
fn solve_flag(
mesh: &Mesh,
interface: &Interface,
forces: &[Vector3<f64>],
a_node: NodeId,
) -> (Vec<f64>, (f64, f64), usize) {
let clamped: Vec<NodeId> = mesh
.nodes
.iter()
.filter(|(_, node)| (node.position().x - FLAG_X0).abs() < 1e-9)
.map(|(&id, _)| id)
.collect();
let mut bcs = BoundaryConditionSet::new();
for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] {
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC {
nodes: clamped.clone(),
components: vec![component],
condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))),
time_range: None,
ramping_factor: 1.0,
gradual_enforcement: false,
}));
}
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(E_S, NU_S).with_density(RHO_F),
None,
);
let mut analysis = NonlinearStaticAnalysis::new(
mesh.clone(),
db,
bcs,
NonlinearConfig::default(),
AnalysisConfig::default(),
)
.with_total_lagrangian();
analysis.set_nodal_forces(
interface
.wetted
.iter()
.zip(forces)
.map(|(&id, &f)| (id, f))
.collect(),
);
let results = analysis.run().unwrap();
assert!(
results.convergence.converged,
"flag Newton did not converge"
);
let numbering =
AdvancedDofNumbering::displacement_only(mesh, DofMappingStrategy::Sequential).unwrap();
let mut d = vec![0.0; 2 * interface.wetted.len()];
for (k, &id) in interface.wetted.iter().enumerate() {
let dofs = numbering.get_node_dofs(id);
d[2 * k] = results.displacements[dofs[0]];
d[2 * k + 1] = results.displacements[dofs[1]];
}
let a_dofs = numbering.get_node_dofs(a_node);
(
d,
(
results.displacements[a_dofs[0]],
results.displacements[a_dofs[1]],
),
results.convergence.iterations,
)
}
#[test]
fn fsi1_coupled_cylinder_and_flag() {
let ny: usize = std::env::var("RTX_FSI1_NY")
.map(|v| v.parse().unwrap())
.unwrap_or(62);
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO_F * NU_F;
let u_peak = 1.5 * 1.5 * U_MEAN;
let dt = 0.25 / (2.0 * u_peak / h + 4.0 * NU_F / (h * h));
let mesh = flag_mesh(35, 2);
let interface = Interface::build(&mesh);
let a_node = mesh
.nodes
.iter()
.find(|(_, n)| (n.position().x - 0.6).abs() < 1e-9 && (n.position().y - 0.2).abs() < 1e-9)
.map(|(&id, _)| id)
.expect("point A");
// The deformable geometry: the flag polygon behind a lock; the fluid's
// per-step mask rebuild reads it.
let vertices = Arc::new(RwLock::new(
interface.polygon(&vec![0.0; 2 * interface.wetted.len()]),
));
let sdf_vertices = vertices.clone();
let config = CfdConfig::new()
.with_density(RHO_F)
.with_viscosity(mu)
.with_reference_velocity(U_MEAN)
.with_reference_length(0.1);
let params = EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
// Upwind, deliberately: FSI1 is a steady FIXED-POINT problem, and
// the TVD limiter's switching keeps the steady load chattering by
// ~0.5% (a known property of limited schemes — they stall short of
// machine steady state), which the coupling inherits as a ±4% tip
// jitter and a 2e-5 interface-residual floor. Upwind converges to
// machine steady, the coupling map is deterministic, and at Re 20
// its loads are within a few percent (CFD1: surface lift +2.3% at
// this grid). The unsteady FSI2/FSI3 march in time and keep TVD.
convection_scheme: ConvectionScheme::Upwind,
};
let mut solver = EmbeddedPisoSolver::new(config, params).unwrap();
solver.set_boundary_velocity(|x, y, _| {
if x <= 0.0 {
(inflow(y), 0.0)
} else {
(0.0, 0.0)
}
});
solver.set_moving_body(EmbeddedBody::from_sdf(move |x, y, _| {
let poly = sdf_vertices.read().unwrap();
circle_sdf(x, y).min(polygon_signed_distance(&poly, x, y))
}));
let mut field = FlowField::new(nx, ny, h, h).unwrap();
for j in 0..ny {
let u0 = inflow((j as f64 + 0.5) * h);
for i in 0..=nx {
field.u[(j, i)] = u0;
}
}
solver.initialize(&mut field).unwrap();
// Warm-start the fluid on the undeformed geometry.
let start = std::time::Instant::now();
for _ in 0..std::env::var("FSI1_WARM")
.map(|v| v.parse().unwrap())
.unwrap_or(6000)
{
futures::executor::block_on(solver.advance(&mut field, dt)).unwrap();
}
println!(
" warm start: 6000 steps, {:.0} s",
start.elapsed().as_secs_f64()
);
// Everything the coupling pass mutates.
let state = RefCell::new((solver, field));
let tip = RefCell::new((0.0f64, 0.0f64));
let previous_d = RefCell::new(vec![0.0f64; 2 * interface.wetted.len()]);
let state_mask_cells = std::cell::Cell::new(0usize);
let worst_conservation = RefCell::new(0.0f64);
let total_skipped = RefCell::new(0usize);
let pass = |d: &[f64]| -> Vec<f64> {
// 1. The fluid sees the deformed flag, and marches until the
// sampled flag load has stopped moving — the coupling map must be
// a deterministic function of the geometry, or Aitken chases the
// fluid's own transient (measured: fixed-length passes left a
// 0.5% load jitter and a 2e-5 interface plateau).
*vertices.write().unwrap() = interface.polygon(d);
let (solver, field) = &mut *state.borrow_mut();
let poly_probe = EmbeddedBody::polygon(vertices.read().unwrap().clone());
let cap: usize = std::env::var("FSI1_PASS")
.map(|v| v.parse().unwrap())
.unwrap_or(6000);
let mut history: Vec<f64> = Vec::new();
let mut marched = 0usize;
loop {
for _ in 0..100 {
futures::executor::block_on(solver.advance(field, dt)).unwrap();
}
marched += 100;
let mask_now = solver.mask().unwrap();
let body_now = solver.body().unwrap();
let mut lift = 0.0;
for s in poly_probe.surface_samples(h) {
if circle_sdf(s.x, s.y) < 1e-9 {
continue;
}
if let Some((_, ty)) = mask_now.traction_at(
body_now, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
lift += ty * s.ds;
}
}
history.push(lift);
if history.len() >= 4 {
let now = history[history.len() - 1];
let then = history[history.len() - 4];
if ((now - then) / now.abs().max(1e-30)).abs() < 2e-5 || marched >= cap {
break;
}
}
}
// 2. Tractions on the flag's wetted samples.
let poly_body = EmbeddedBody::polygon(vertices.read().unwrap().clone());
let mask = solver.mask().unwrap();
state_mask_cells.set(mask.fluid_cells());
let body = solver.body().unwrap();
let mut faces = Vec::new();
let mut tractions = Vec::new();
let mut skipped = 0usize;
for s in poly_body.surface_samples(0.5 * h) {
if circle_sdf(s.x, s.y) < 1e-9 {
continue; // buried in the cylinder
}
match mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
Some((tx, ty)) => {
faces.push(FluidFace {
centroid: Vector3::new(s.x, s.y, 0.0),
normal: Vector3::new(s.nx, s.ny, 0.0),
area: s.ds,
});
tractions.push(Vector3::new(tx, ty, 0.0));
}
None => skipped += 1,
}
}
*total_skipped.borrow_mut() += skipped;
// 3. rtx-fsi carries the load to the deformed structure nodes.
let nodes_now = interface.deformed_nodes(d);
let surface = match WettedSurface::build(&faces, &nodes_now) {
Ok(surface) => surface,
Err(rtx_fsi::FsiError::DegenerateNeighbourhood { face }) => {
let c = faces[face].centroid;
eprintln!("degenerate face {face} centroid ({:.6}, {:.6})", c.x, c.y);
let mut dists: Vec<(f64, usize)> = nodes_now
.iter()
.enumerate()
.map(|(i, n)| ((n - c).norm(), i))
.collect();
dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
for (dist, i) in dists.iter().take(10) {
eprintln!(
" node {i} at ({:.6}, {:.6}) dist {dist:.6}",
nodes_now[*i].x, nodes_now[*i].y
);
}
panic!("degenerate neighbourhood at face {face}");
}
Err(e) => panic!("transfer build failed: {e:?}"),
};
let nodal = surface.transfer_load(&faces, &tractions).unwrap();
let total_sampled: Vector3<f64> =
faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum();
let total_nodal: Vector3<f64> = nodal.iter().sum();
let conservation = (total_nodal - total_sampled).norm() / total_sampled.norm().max(1e-30);
let mut worst = worst_conservation.borrow_mut();
*worst = worst.max(conservation);
// 4. The flag answers.
let (d_new, tip_now, _newton) = solve_flag(&mesh, &interface, &nodal, a_node);
*tip.borrow_mut() = tip_now;
// Diagnostics: the residual trajectory, the tip, the total load,
// and a mask fingerprint (fluid-cell count) to see chatter.
{
let mut previous = previous_d.borrow_mut();
let delta: f64 = d_new
.iter()
.zip(previous.iter())
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let fluid_cells = state_mask_cells.get();
eprintln!(
" pass: |d_new - d_prev| = {delta:.3e}, tip = ({:.4e}, {:.4e}), \
total sampled force = ({:.4}, {:.4}), fluid cells = {fluid_cells}, \
marched {marched}",
tip_now.0, tip_now.1, total_sampled.x, total_sampled.y
);
*previous = d_new.clone();
}
d_new
};
// Tolerance from measurement: with the upwind fluid and load-stagnation
// passes the interface still carries a ~3e-5 noise floor (each geometry
// nudge re-excites a slow settle the stagnation window cuts short), so
// the fixed point is determined to about ±2% of the tip — 8e-5 is what
// this coupling can honestly promise, and the run terminates as soon as
// a pass lands inside that band.
let mut coupling = Subiterated::aitken(25, 8e-5).unwrap();
let d0 = vec![0.0; 2 * interface.wetted.len()];
let converged = coupling
.solve(&d0, pass)
.expect("coupling did not converge");
println!(
" coupling: {} Aitken passes, residual {:.2e}; worst conservation defect {:.2e}; \
skipped samples total {}",
converged.iterations,
converged.residual,
*worst_conservation.borrow(),
*total_skipped.borrow(),
);
// Settle the fluid on the final geometry and measure the total load on
// cylinder + flag (both by surface tractions).
let (solver, field) = &mut *state.borrow_mut();
for _ in 0..2000 {
futures::executor::block_on(solver.advance(field, dt)).unwrap();
}
let mask = solver.mask().unwrap();
let body = solver.body().unwrap();
let final_vertices = vertices.read().unwrap().clone();
let mut drag = 0.0;
let mut lift = 0.0;
let poly_body = EmbeddedBody::polygon(final_vertices.clone());
for s in poly_body.surface_samples(0.5 * h) {
if circle_sdf(s.x, s.y) < 1e-9 {
continue;
}
if let Some((tx, ty)) = mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
drag += tx * s.ds;
lift += ty * s.ds;
}
}
let circle_body = EmbeddedBody::circle(0.2, 0.2, 0.05);
for s in circle_body.surface_samples(0.5 * h) {
if polygon_signed_distance(&final_vertices, s.x, s.y) < 1e-9 {
continue;
}
if let Some((tx, ty)) = mask.traction_at(
body, &field.u, &field.v, &field.p, mu, 0.0, s.x, s.y, s.nx, s.ny,
) {
drag += tx * s.ds;
lift += ty * s.ds;
}
}
let (ux_a, uy_a) = *tip.borrow();
println!(
" FSI1 (fluid ny = {ny}, flag 35x2 Quad8): ux(A) = {:.4e} m (ref {REF_UX:.4e}), \
uy(A) = {:.4e} m (ref {REF_UY:.4e}), drag {drag:.3} (ref {REF_DRAG}), \
lift {lift:.4} (ref {REF_LIFT}); total wall {:.0} s",
ux_a,
uy_a,
start.elapsed().as_secs_f64(),
);
let rel = |a: f64, b: f64| ((a - b) / b).abs();
let worst = *worst_conservation.borrow();
assert!(worst < 1e-10, "load transfer lost force: {worst:.3e}");
assert!(
rel(drag, REF_DRAG) < 0.15,
"drag {drag:.3} vs reference {REF_DRAG}"
);
assert!(
rel(lift, REF_LIFT) < 0.35,
"lift {lift:.4} vs reference {REF_LIFT}"
);
assert!(
rel(ux_a, REF_UX) < 0.30,
"ux(A) {ux_a:.4e} vs reference {REF_UX:.4e} (measured +16.6% at ny = 62)"
);
if ny >= 82 {
// Measured +37% at h = 5 mm (1.124e-3): the resolutions BRACKET the
// reference — 3.8e-4 (54%) at 6.6 mm, 1.12e-3 (+37%) at 5 mm —
// nonmonotone through the flag's 3 → 4-cell thickness transition,
// exactly like the rigid-flag lift. ux converges cleanly (+16.6% →
// +6.1%). The band is the measured value, not an accuracy claim.
assert!(
rel(uy_a, REF_UY) < 0.45,
"uy(A) {uy_a:.4e} vs reference {REF_UY:.4e}"
);
} else {
// The measured band of this resolution, not an accuracy claim.
assert!(
(3.0e-4..5.0e-4).contains(&uy_a),
"uy(A) {uy_a:.4e} outside the measured ny = 62 band [3.0e-4, 5.0e-4]"
);
}
assert!(
uy_a > 0.0 && ux_a > 0.0,
"tip displacement direction wrong: ({ux_a:.3e}, {uy_a:.3e})"
);
}