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
This commit is contained in:
Omar Sobh
2026-08-20 20:00:49 -07:00
co-authored by Claude Fable 5
parent c0f5a86f03
commit 4534d90684
6 changed files with 651 additions and 224 deletions
@@ -87,6 +87,14 @@ impl Default for EmbeddedParameters {
}
}
/// 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.
#[derive(Debug, Clone)]
pub struct EmbeddedResult {
@@ -191,6 +199,29 @@ impl EmbeddedPisoSolver {
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.
pub fn set_time(&mut self, t: f64) {
self.time = t;
@@ -174,8 +174,7 @@ impl EmbeddedBody {
* 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 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();
@@ -291,7 +290,6 @@ pub struct SurfaceForce {
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,
@@ -324,6 +322,50 @@ pub fn polygon_signed_distance(vertices: &[(f64, f64)], x: f64, y: f64) -> f64 {
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaceKind {
@@ -369,7 +411,10 @@ struct Ghost {
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 {
nx: usize,
ny: usize,
@@ -1125,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]
fn body_touching_the_boundary_is_refused() {
let body = EmbeddedBody::circle(0.0, 0.5, 0.2);
@@ -39,9 +39,10 @@ pub use ale::{
pub use boundary_conditions::{
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, polygon_signed_distance,
EmbeddedBody, EmbeddedMask, FaceKind, SurfaceForce, SurfaceSample, polygon_interface_velocity,
polygon_signed_distance,
};
pub use flow_field::FlowField;
pub use piso::{PisoParameters, PisoResult, PisoSolver};