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
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:
co-authored by
Claude Fable 5
parent
4bd98b5264
commit
c25f15b3c4
@@ -0,0 +1,258 @@
|
||||
//! Turek–Hron 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user