rtx-cfd + rtx-fea: embedded-boundary PISO and total-Lagrangian SVK — the first two Turek–Hron rungs
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

The Turek–Hron geometry decision (omni-cortex
docs/turek_hron_geometry_decision.md) chose an embedded boundary on the
fixed Cartesian MAC grid over body-fitted unstructured ALE; this commit
builds the first rung on each side of the ladder, verified MMS-first.

rtx-cfd — solvers::incompressible::{embedded, embedded_body}:
EmbeddedPisoSolver is the fixed-grid PISO predictor/projection with
per-side domain boundaries (ALE's SideBoundary semantics, so the channel
has an outlet), a (x, y, t) boundary-velocity function, and an optional
EmbeddedBody (signed distance + surface velocity; circle / rectangle /
union). EmbeddedMask classifies cells (fluid iff phi > 0 at the centre)
and faces (fluid iff both cells fluid; ghost within 1.5 h; solid deeper);
the predictor updates fluid faces only, the projection enforces continuity
on fluid cells with zero coefficient across prescribed faces, ghost faces
are re-imposed after each projection from a boundary-intercept
least-squares linear fit (exact for linear fields), the net ghost mass flux
is removed uniformly so a Neumann projection stays compatible, and loads
come by two routes: surface-stress reconstruction (full viscous traction)
and a control-volume momentum balance.

Verified (tests/embedded_mms.rs, tests/turek_hron_cfd.rs):
- no body, closed box: bit-identical to PisoSolver over 200 steps;
- embedded off-centre circle MMS 16/32/64: velocity orders 0.92, 0.97
  (plain PISO 0.85, 0.91), pressure 0.96, 0.90, max |div u| <= 9e-8 on
  every fluid cell, compatibility correction 6e-4 -> 3e-5; force on the
  circle vs the exact surface integral: surface route 0.52 -> 0.29 -> 0.15,
  control-volume route 0.61 -> 0.30 -> 0.15 (both first order, two
  unrelated readings of the same solution);
- Turek–Hron CFD1 (Re 20, h = 10 mm, flag two cells thick), settled to
  four digits: surface drag 15.71 / lift 0.94, control-volume drag 15.62 /
  lift 1.08 vs reference 14.29 / 1.119 — the drag routes agree to 0.6%,
  both +9.5%. A coarse first number; the refinement study waits on a
  multigrid projection (SOR: 0.1 s/step at 250x41 in the test profile).

Fourteenth defect of the campaign: the fixed-grid PISO predictor zeroes
the transverse convective face velocity on its domain sides (exact for
walls); carried into a solver with an outlet it dropped the OUTGOING
momentum flux through the outlet side of the v control volumes, the last
column accumulated, and CFD1 went NaN at t ~ 4 s. Found by printing where
max |u| lived (x = 2.5) after halving dt changed nothing. Fluxes now come
from the stored boundary faces on every side.

rtx-fea — elements::total_lagrangian + NonlinearStaticAnalysis::
with_total_lagrangian(): Green–Lagrange strain, second Piola–Kirchhoff
stress from a St. Venant–Kirchhoff law on the material's Lamé parameters
(plane strain in 2-D), B_L of the current deformation, material plus
geometric tangent; dead-load body force per reference volume.

Verified (tests/total_lagrangian_svk.rs):
- zero displacement: the plane-strain stiffness to 1e-13;
- tangent = d f_int/du by central differences at 20% random displacement
  (Quad4, Quad8, Hex8): relative < 1e-7, symmetric to 1e-12;
- a 34-degree rigid rotation produces no internal force; the small-strain
  routine does (negative control);
- manufactured finite-strain solution, body force by FD of the exact
  P = F S: Quad4 orders 1.95, 1.98; Quad8 2.93, 3.03, 3.02 (an 8%
  amplitude, Green–Lagrange strain to -0.25 near SVK's compressive limit
  E = -1/3, broke Newton on fine meshes — the material, not the code; 3%
  is clean);
- Turek–Hron CSM1 at 70x4 Quad8: u(A) = (-7.060, -65.43) mm vs
  (-7.188, -66.10), 1.0% / 1.8%, converging from below (35x2: -65.14);
  CSM2: (-0.4604, -16.79) vs (-0.4690, -16.97), 1.1% / 1.8%.

rtx-cfd 293 -> 301 green (5 unit + 3 integration), rtx-fea 559 -> 564.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-20 08:51:32 -07:00
co-authored by Claude Fable 5
parent 4bd98b5264
commit c25f15b3c4
9 changed files with 3290 additions and 9 deletions
@@ -0,0 +1,258 @@
//! TurekHron CFD1: steady laminar flow (Re = 20) past the rigid cylinder
//! with the rigid flag attached, on the embedded-boundary PISO solver.
//!
//! Geometry and parameters from the FEATFLOW benchmark definition (sourced
//! 2026-08-20, see omni-cortex `docs/turek_hron_geometry_decision.md`):
//! channel `[0, 2.5] x [0, 0.41]`, cylinder centre (0.2, 0.2) radius 0.05,
//! flag `[0.25, 0.6] x [0.19, 0.21]`, `rho = 1000`, `nu = 1e-3`, parabolic
//! inflow with mean `U = 0.2` (max 0.3), no-slip walls, outlet at the right.
//! Reference (level 6): **drag 14.2929, lift 1.11905** on cylinder + flag.
//!
//! The flag is modelled as `[0.20, 0.6] x [0.19, 0.21]`: its left 5 cm lie
//! inside the cylinder, which removes the two 1 mm fluid wedges the literal
//! corners (0.25, 0.19 ± 0.01) would leave between bar and circle — below
//! the grid scale here, and filled in the benchmark's own meshes.
//!
//! This is the first quantitative claim of the embedded solver against an
//! external reference. The in-suite resolution is bounded by the dev
//! profile's speed (the SOR projection: ~0.1 s/step at h = 10 mm, an hour
//! per run at 5 mm); the assertion is correspondingly the measured band,
//! not an accuracy claim, with the two load routes required to agree with
//! each other as well. Measured at h = 10 mm (flag two cells thick), fully
//! settled (drag stagnant to four digits): surface route drag 15.71, lift
//! 0.936 (3 junction samples skipped); control-volume route drag 15.62,
//! lift 1.079 — drag routes agree to 0.6%, both +9.5% on the reference.
//! The refinement study that turns this into a claim waits on a multigrid
//! Poisson solver (falsifier 4 of the geometry decision).
use rtx_cfd::solvers::incompressible::{
AleBoundaries, EmbeddedBody, EmbeddedParameters, EmbeddedPisoSolver, FlowField, 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 U_MEAN: f64 = 0.2;
const REF_DRAG: f64 = 14.2929;
const REF_LIFT: f64 = 1.11905;
fn inflow(y: f64) -> f64 {
1.5 * U_MEAN * y * (H - y) / (0.5 * H).powi(2)
}
fn body() -> EmbeddedBody {
EmbeddedBody::union(
EmbeddedBody::circle(0.2, 0.2, 0.05),
EmbeddedBody::rectangle(0.20, 0.19, 0.6, 0.21),
)
}
struct Cfd1 {
drag_surface: f64,
lift_surface: f64,
skipped: usize,
drag_cv: f64,
lift_cv: f64,
steps: usize,
seconds: f64,
}
/// March CFD1 to a steady state on a grid of `ny` cells across the channel.
/// Steady means the control-volume drag has stopped moving: its relative
/// change over the last 200 steps below `1e-4`, after at least one flow-
/// through time — "the answer stopped moving", not a residual.
async fn run_cfd1(ny: usize) -> CfdResult<Cfd1> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO * NU;
// Explicit predictor: the COMBINED criterion — convective Courant numbers
// in both directions plus the diffusion number must stay below one —
// with the local peak velocity taken as 1.5x the inflow peak for the
// 24% blockage. (Taking 0.4 of the smaller single limit, as the MMS
// tests do, went NaN here at t ~ 7 s: both limits are active at once.)
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,
},
};
let mut solver = EmbeddedPisoSolver::new(config, params)?;
solver.set_boundary_velocity(|x, y, _| {
if x <= 0.0 {
(inflow(y), 0.0)
} else {
(0.0, 0.0)
}
});
solver.set_body(body());
let mut field = FlowField::new(nx, ny, h, h)?;
// Start from the inflow profile everywhere (the body's faces are
// overwritten by the mask at initialisation).
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)?;
// Control volume for the momentum balance: whole cells, in the fluid
// on its boundary, enclosing cylinder and flag.
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,
);
let cv_force =
|field: &FlowField, mask: &rtx_cfd::solvers::incompressible::EmbeddedMask, dt: f64| {
mask.control_volume_force(
&field.u,
&field.v,
&field.p,
&field.u_old,
&field.v_old,
dt,
RHO,
mu,
None,
cv,
)
};
let start = std::time::Instant::now();
let flow_through = L / U_MEAN;
let min_steps = (flow_through / dt).ceil() as usize;
let mut history: Vec<f64> = Vec::new();
let mut steps = 0;
loop {
let result = solver.advance(&mut field, dt).await?;
steps += 1;
if steps % 50 == 0 {
let (fx, _) = cv_force(&field, solver.mask().unwrap(), dt);
history.push(fx);
// Diagnostics: where is the velocity largest, did the projection
// converge, how big was the ghost correction.
let (mut umax, mut at) = (0.0f64, (0usize, 0usize));
for j in 0..ny {
for i in 0..=nx {
let a = field.u[(j, i)].abs();
if a > umax {
umax = a;
at = (j, i);
}
}
}
if steps % 250 == 0 || umax > 3.0 * 1.5 * U_MEAN || !umax.is_finite() {
println!(
" ny = {ny}: step {steps} t = {:.2} s drag_cv = {fx:.4} max|u| = {umax:.4} at (x={:.3}, y={:.3}) \
projection: converged {} residual {:.2e} passes {} ghost corr {:.2e} [{:.0} s wall]",
solver.time(),
at.1 as f64 * h,
(at.0 as f64 + 0.5) * h,
result.solver_result.converged,
result.solver_result.final_residual,
result.corrector_steps_performed,
result.ghost_correction,
start.elapsed().as_secs_f64()
);
}
assert!(
umax.is_finite(),
"velocity became non-finite at step {steps}"
);
if steps >= min_steps && history.len() > 4 {
let now = history[history.len() - 1];
let then = history[history.len() - 5];
if ((now - then) / now).abs() < 1e-4 {
break;
}
}
}
assert!(steps < 400_000, "CFD1 at ny = {ny} did not settle");
}
let seconds = start.elapsed().as_secs_f64();
let mask = solver.mask().unwrap();
let surface = mask.surface_force(
solver.body().unwrap(),
&field.u,
&field.v,
&field.p,
mu,
solver.time(),
0.5 * h,
);
let (drag_cv, lift_cv) = cv_force(&field, mask, dt);
Ok(Cfd1 {
drag_surface: surface.fx,
lift_surface: surface.fy,
skipped: surface.skipped,
drag_cv,
lift_cv,
steps,
seconds,
})
}
#[tokio::test]
async fn cfd1_drag_and_lift_against_the_featflow_reference() -> CfdResult<()> {
// One resolution until the projection has a multigrid solver: at the
// SOR cost a 62-cell run takes over an hour in the test profile.
let resolutions = [41usize];
let mut results = Vec::new();
for &ny in &resolutions {
let r = run_cfd1(ny).await?;
println!(
" ny = {ny:3} (h = {:.4}) surface: drag {:.4} lift {:.4} (skipped {}) \
control volume: drag {:.4} lift {:.4} [{} steps, {:.0} s] reference drag {REF_DRAG} lift {REF_LIFT}",
H / ny as f64,
r.drag_surface,
r.lift_surface,
r.skipped,
r.drag_cv,
r.lift_cv,
r.steps,
r.seconds
);
results.push(r);
}
let fine = results.last().unwrap();
let rel = |a: f64, b: f64| ((a - b) / b).abs();
// Both routes within the measured band of the reference (9.5% at this
// grid) and of each other.
assert!(
rel(fine.drag_surface, REF_DRAG) < 0.12 && rel(fine.drag_cv, REF_DRAG) < 0.12,
"drag: surface {:.4}, control volume {:.4}, reference {REF_DRAG}",
fine.drag_surface,
fine.drag_cv
);
assert!(
rel(fine.drag_surface, fine.drag_cv) < 0.05,
"the two drag routes disagree: surface {:.4} vs control volume {:.4}",
fine.drag_surface,
fine.drag_cv
);
assert!(
rel(fine.lift_surface, REF_LIFT) < 0.25 && rel(fine.lift_cv, REF_LIFT) < 0.25,
"lift: surface {:.4}, control volume {:.4}, reference {REF_LIFT}",
fine.lift_surface,
fine.lift_cv
);
Ok(())
}