//! embedded3 S2-3: the free-ended flag with prescribed motion — the first
//! honest 3D wake. The Turek–Hron channel (2.5 × 0.41) extruded to depth
//! 0.41 with the cylinder (D 0.1 at (0.2, 0.2)) across the width, the flag
//! (`RTX_E3_FLAG_HEIGHT` / `RTX_E3_FLAG_DEPTH` grow the duct around the
//! unchanged body, re-centred: the 2026-09-22/23 explorations), the flag
//! 0.35 × 0.02 × 0.2 centred in z (z 0.105–0.305), its centreline deflected
//! as the first clamped-free beam mode with the 2D FSI2 flat-tip record's
//! tip amplitude 84 mm at 1.930 Hz (motion prescribed, no structure), the
//! flag's span edges rounded to one cell and its tip a semicircle (the
//! linear cut geometry needs smooth edges; disclosed). Inflow parabolic
//! in y and z with U_m 2.25 (Ū 1.0 = FSI2's mean, Re 100 on D), ρ 1000,
//! ν 1e-3. CutCell wall with merging, TVD, moving body on the device
//! (S2-2a: the host rebuild per step).
//!
//! Gate (`docs/embedded3_campaign.md` S2-3): ny 62, two full periods, no
//! death, mass residual ≤ 1e-8 every step; the mid-plane per-span loads
//! within 30 % of the 2D FSI2 record (drag mean 224.6 N/m, lift swing
//! ±215 flat tip / ±256 semicircle); 32 VTK phases of the last period.
//!
//! `RTX_E3_FLAG_NY=62 RTX_E3_FLAG_PERIODS=2 RTX_E3_FLAG_VTK=
RTX_E3_FLAG_CSV= \
//! RTX_CUDA_ARCH=sm_120 cargo test --release -p rtx-cfd --features cuda --test embedded3_flag_wake -- --ignored --nocapture`
#![cfg(feature = "cuda")]
mod embedded3_flag_kinematics;
use embedded3_flag_kinematics::{Recorded, recorded};
use rtx_cfd::solvers::incompressible::ConvectionScheme;
use rtx_cfd::solvers::incompressible::embedded3::step::device::DeviceStep;
use rtx_cfd::solvers::incompressible::embedded3::{
Body, Boundaries, DeviceSdf, FAR_OUTSIDE, Field, Fluid, Grid, HexPlate, Parameters,
PlateSurface, Side, Solver, WallScheme, write_vtk,
};
use std::io::Write as _;
const H: f64 = 0.41;
const L: f64 = 2.5;
const CX: f64 = 0.2;
const CY: f64 = 0.2;
const R_CYL: f64 = 0.05;
/// The flag's ROOT: the cylinder's rear (the Turek–Hron flag runs from the
/// cylinder to its tip A at x = 0.6). Until 2026-09-18 this was 0.6 — the
/// flag sat DETACHED, its root where the benchmark's tip is; every flag
/// record before that date is of that geometry.
const FLAG_X0: f64 = 0.25;
const FLAG_LEN: f64 = 0.35;
const FLAG_HALF: f64 = 0.01;
const FLAG_SPAN: f64 = 0.2;
const AMP: f64 = 0.084;
const FREQ: f64 = 1.930;
const U_M: f64 = 2.25;
const RHO: f64 = 1000.0;
const NU: f64 = 1e-3;
/// The first clamped-free beam mode's `β L`.
const BETA_L: f64 = 1.875_104_069;
fn env_f(name: &str, default: f64) -> f64 {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
/// The first mode shape normalised to 1 at the tip, `s ∈ [0, 1]`.
fn mode(s: f64) -> f64 {
let b = BETA_L;
let sigma = (b.sinh() - b.sin()) / (b.cosh() + b.cos());
let phi = |s: f64| (b * s).cosh() - (b * s).cos() - sigma * ((b * s).sinh() - (b * s).sin());
phi(s) / phi(1.0)
}
/// Centreline deflection and its velocity at arc parameter `s`, time `t`.
fn deflection(s: f64, t: f64) -> (f64, f64) {
let w = 2.0 * std::f64::consts::PI * FREQ;
let amp = amplitude();
(
amp * mode(s) * (w * t).sin(),
amp * mode(s) * w * (w * t).cos(),
)
}
/// The tip amplitude: `RTX_E3_FLAG_AMP` (default 0.084; 0 freezes the flag —
/// the static control of the load routes).
fn amplitude() -> f64 {
env_f("RTX_E3_FLAG_AMP", AMP)
}
/// R3: the root fillet radius (`RTX_E3_FLAG_FILLET`, metres; 0 = the sharp
/// concave corner of the plain union; the overset outline carries 5 mm).
fn root_fillet() -> f64 {
env_f("RTX_E3_FLAG_FILLET", 0.0)
}
/// R3 (2026-09-21): the capsule's tip inset (`RTX_E3_FLAG_TIP_INSET`,
/// metres, default `FLAG_HALF`): the centreline polyline is shortened by
/// this much along its last segment so the capsule's apex sits ON the
/// benchmark's tip A (the overset outline's semicircle apex). Every flag
/// record before this date had the apex 10 mm beyond A (`=0` restores
/// them): at ny 62 on the recorded motion that was drag 253.3 → 240.0 and
/// the lift swing 1,005 → 836 (the overset's 867).
///
/// R8-h: with the flat tip the default inset is 0 (the flat face through A).
fn tip_inset() -> f64 {
env_f(
"RTX_E3_FLAG_TIP_INSET",
if flat_tip().is_some() { 0.0 } else { FLAG_HALF },
)
}
/// R8-h (2026-09-25): the tip's shape. `RTX_E3_FLAG_TIP=flat` gives the
/// flag a FLAT tip through the centreline's last point (A, the inset
/// defaulting to 0), its corners rounded to `RTX_E3_FLAG_TIP_CORNER`
/// metres (default 0.00125, the 2D overset's recommended line; at most
/// `FLAG_HALF`); unset or `capsule` = the capsule (the semicircular tip).
/// The flat tip's host φ and surface velocity are the device form's
/// (`DeviceSdf::phi_host`, the kernel's arithmetic).
fn flat_tip() -> Option {
match std::env::var("RTX_E3_FLAG_TIP").as_deref() {
Err(_) | Ok("capsule") => None,
Ok("flat") => {
let rc = env_f("RTX_E3_FLAG_TIP_CORNER", 0.00125);
assert!(
(0.0..=FLAG_HALF).contains(&rc),
"RTX_E3_FLAG_TIP_CORNER {rc} outside [0, {FLAG_HALF}]"
);
Some(rc)
}
Ok(v) => panic!("RTX_E3_FLAG_TIP={v}: flat or capsule"),
}
}
/// Smooth union with a concave fillet of radius `r` (the plain `min` at r = 0).
fn fillet_union(d1: f64, d2: f64, r: f64) -> f64 {
if r > 0.0 && d1 < r && d2 < r {
r - ((r - d1).powi(2) + (r - d2).powi(2)).sqrt()
} else {
d1.min(d2)
}
}
/// Pull the polyline's last point back by `inset` along its last segment.
fn inset_last(pts: &mut [(f64, f64, f64, f64)], inset: f64) {
if inset <= 0.0 || pts.len() < 2 {
return;
}
let n = pts.len();
let (ax, ay, _, _) = pts[n - 2];
let (bx, by, bvx, bvy) = pts[n - 1];
let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();
let f = (1.0 - inset / len).max(0.0);
pts[n - 1] = (ax + f * (bx - ax), ay + f * (by - ay), bvx, bvy);
}
/// Signed distance to the deflected flag's cross-section (a capsule
/// around the centreline polyline of `n` segments) and the centreline's
/// velocity at the closest point (transverse only in the analytic mode).
fn flag_2d(x: f64, y: f64, t: f64) -> (f64, (f64, f64)) {
if let Some(rec) = recorded() {
return flag_2d_recorded(rec, x, y, t);
}
let (d, v) = flag_2d_analytic(x, y, t);
(d, (0.0, v))
}
/// The recorded centreline's capsule and velocity at the closest point.
fn flag_2d_recorded(rec: &Recorded, x: f64, y: f64, t: f64) -> (f64, (f64, f64)) {
thread_local! {
static POLY: std::cell::RefCell<(f64, Vec<(f64, f64, f64, f64)>)> =
const { std::cell::RefCell::new((f64::NAN, Vec::new())) };
}
POLY.with(|cell| {
let mut c = cell.borrow_mut();
if c.0.to_bits() != t.to_bits() {
c.1 = recorded_polyline(rec, t);
c.0 = t;
}
let pts = &c.1;
let mut best = f64::INFINITY;
let mut v_best = (0.0, 0.0);
for m in 0..pts.len() - 1 {
let (ax, ay, avx, avy) = pts[m];
let (bx, by, bvx, bvy) = pts[m + 1];
let (ex, ey) = (bx - ax, by - ay);
let l2 = ex * ex + ey * ey;
let u = (((x - ax) * ex + (y - ay) * ey) / l2).clamp(0.0, 1.0);
let (px, py) = (ax + u * ex, ay + u * ey);
let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt();
if d < best {
best = d;
v_best = (avx + u * (bvx - avx), avy + u * (bvy - avy));
}
}
(best - FLAG_HALF, v_best)
})
}
/// The recorded centreline at `t` with the tip inset.
fn recorded_polyline(rec: &Recorded, t: f64) -> Vec<(f64, f64, f64, f64)> {
let mut pts = rec.at(t, body_cy());
inset_last(&mut pts, tip_inset());
pts
}
/// The analytic centreline's segments.
const N: usize = 40;
/// The analytic centreline at `t` (x, y, transverse velocity), the tip inset.
fn analytic_polyline(t: f64) -> [(f64, f64, f64); N + 1] {
let mut p = [(0.0, 0.0, 0.0); N + 1];
for (m, q) in p.iter_mut().enumerate() {
let s = m as f64 / N as f64;
let (d, v) = deflection(s, t);
*q = (FLAG_X0 + s * FLAG_LEN, body_cy() + d, v);
}
let inset = tip_inset();
if inset > 0.0 {
let (ax, ay, _) = p[N - 1];
let (bx, by, bv) = p[N];
let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();
let f = (1.0 - inset / len).max(0.0);
p[N] = (ax + f * (bx - ax), ay + f * (by - ay), bv);
}
p
}
fn flag_2d_analytic(x: f64, y: f64, t: f64) -> (f64, f64) {
// The centreline polyline at `t`, once per thread and time (PERF-3
// P1-2): the solver asks for the surface velocity at ~10⁶ faces per
// step and each call rebuilt the 41 points (four hyperbolic / trigonometric
// evaluations each). Same arithmetic, same digits.
thread_local! {
static POLYLINE: std::cell::RefCell<(f64, [(f64, f64, f64); N + 1])> =
const { std::cell::RefCell::new((f64::NAN, [(0.0, 0.0, 0.0); N + 1])) };
}
let pts = POLYLINE.with(|cell| {
let mut c = cell.borrow_mut();
if c.0.to_bits() != t.to_bits() {
c.1 = analytic_polyline(t);
c.0 = t;
}
c.1
});
let mut best = f64::INFINITY;
let mut v_best = 0.0;
let point = |m: usize| pts[m];
for m in 0..N {
let (ax, ay, av) = point(m);
let (bx, by, bv) = point(m + 1);
let (ex, ey) = (bx - ax, by - ay);
let l2 = ex * ex + ey * ey;
let u = (((x - ax) * ex + (y - ay) * ey) / l2).clamp(0.0, 1.0);
let (px, py) = (ax + u * ex, ay + u * ey);
let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt();
if d < best {
best = d;
v_best = av + u * (bv - av);
}
}
(best - FLAG_HALF, v_best)
}
/// The flag's span: `RTX_E3_FLAG_SPAN` (default 0.2); at the duct's
/// full depth the flag is the 2D geometry extruded (S2-3b).
fn flag_span() -> f64 {
env_f("RTX_E3_FLAG_SPAN", FLAG_SPAN)
}
/// The duct's depth (the z extent): `RTX_E3_FLAG_DEPTH` (default H, the
/// square duct). A wider duct than the flag's span (2026-09-22, the
/// "wide span" exploration) puts a finite-span plate in free flow between
/// the side walls — not the benchmark's geometry.
fn duct_depth() -> f64 {
env_f("RTX_E3_FLAG_DEPTH", H)
}
/// The duct's height (the y extent): `RTX_E3_FLAG_HEIGHT` (default H).
/// The cell size stays `H / ny` (the benchmark's rung definition); the
/// body is re-centred in the taller duct (2026-09-23, the "big duct").
fn duct_height() -> f64 {
env_f("RTX_E3_FLAG_HEIGHT", H)
}
/// The cylinder's centre and the flag's rest centreline: `CY` in the
/// benchmark duct, lifted by half the added height otherwise.
fn body_cy() -> f64 {
CY + 0.5 * (duct_height() - H)
}
/// The flag in 3D: the extruded capsule cut to the span with edges
/// rounded to radius `r` (no cut at the full width).
fn flag_3d(x: f64, y: f64, z: f64, t: f64, r: f64) -> (f64, (f64, f64)) {
let (d2, v) = flag_2d(x, y, t);
let span = flag_span();
if span >= duct_depth() {
return (d2, v);
}
let zc = 0.5 * duct_depth();
let q1 = d2 + r;
let q2 = (z - zc).abs() - 0.5 * span + r;
let outside = (q1.max(0.0).powi(2) + q2.max(0.0).powi(2)).sqrt();
(outside + q1.max(q2).min(0.0) - r, v)
}
/// The finite cylinder: the 2D circle cut to `span` in z with the same
/// rounded edges as the flag (`RTX_E3_FLAG_CYL_SPAN=flag`, 2026-09-23: the
/// whole body a finite object in the wider ducts); wall to wall otherwise.
fn cylinder_3d(d2: f64, z: f64, r: f64) -> f64 {
let span = flag_span();
if span >= duct_depth() || !std::env::var("RTX_E3_FLAG_CYL_SPAN").is_ok_and(|v| v == "flag") {
return d2;
}
let zc = 0.5 * duct_depth();
let q1 = d2 + r;
let q2 = (z - zc).abs() - 0.5 * span + r;
let outside = (q1.max(0.0).powi(2) + q2.max(0.0).powi(2)).sqrt();
outside + q1.max(q2).min(0.0) - r
}
/// R8-c: the flag as a deformed plate (`RTX_E3_FLAG_BODY=plate`): the
/// span stations (`RTX_E3_FLAG_STATIONS`, default 21, spread over the
/// flag's span) each carry the centreline polyline. `RTX_E3_FLAG_TWIST=κ`
/// (default 0; analytic mode only) scales each station's deflection and
/// velocity by `1 + κ ζ`, `ζ = (z − z_c)/(span/2)` — the first bending mode
/// times a span-linear twist. At κ = 0 every station is the polyline as it
/// is (the G1 identity with the polyline capsule).
fn plate_body() -> bool {
std::env::var("RTX_E3_FLAG_BODY").is_ok_and(|v| v == "plate")
}
fn twist() -> f64 {
env_f("RTX_E3_FLAG_TWIST", 0.0)
}
/// The span stations' z (ascending) and their ζ.
fn stations() -> Vec {
let n = (env_f("RTX_E3_FLAG_STATIONS", 21.0) as usize).max(2);
let (zc, span) = (0.5 * duct_depth(), flag_span());
(0..n)
.map(|k| zc - 0.5 * span + span * k as f64 / (n - 1) as f64)
.collect()
}
/// The span factor `1 + κ ζ` of the deflection at `z` (clamped to the span).
fn span_factor(z: f64) -> f64 {
let (zc, span) = (0.5 * duct_depth(), flag_span());
let zeta = ((z - zc) / (0.5 * span)).clamp(-1.0, 1.0);
1.0 + twist() * zeta
}
/// The plate at `t`: per station the centreline (and its velocity).
fn plate_at(t: f64) -> PlateSurface {
let z = stations();
let (row, vel): (Vec<[f64; 2]>, Vec<[f64; 2]>) = match recorded() {
Some(rec) => recorded_polyline(rec, t)
.iter()
.map(|p| ([p.0, p.1], [p.2, p.3]))
.unzip(),
None => analytic_polyline(t)
.iter()
.map(|p| ([p.0, p.1], [0.0, p.2]))
.unzip(),
};
if twist() == 0.0 {
return PlateSurface::uniform(z, &row, &vel);
}
assert!(
recorded().is_none(),
"RTX_E3_FLAG_TWIST: the analytic mode only"
);
let ns = N + 1;
let (mut xy, mut vv) = (Vec::new(), Vec::new());
for &zk in &z {
let f = span_factor(zk);
let mut pts: Vec<(f64, f64, f64, f64)> = (0..ns)
.map(|m| {
let s = m as f64 / N as f64;
let (d, v) = deflection(s, t);
(FLAG_X0 + s * FLAG_LEN, body_cy() + d * f, 0.0, v * f)
})
.collect();
inset_last(&mut pts, tip_inset());
xy.extend(pts.iter().map(|p| [p.0, p.1]));
vv.extend(pts.iter().map(|p| [p.2, p.3]));
}
PlateSurface { z, ns, xy, vel: vv }
}
/// The deformed flag's mid-surface point `y = w(x, z)` and its 3D unit
/// normal at arc fraction `s` and span `z` (the analytic kinematics with the
/// span factor; the structure's placement for the load transfer: the
/// thickness along the mid-surface's normal, as a solid plate carries it).
fn mid_point(s: f64, z: f64, t: f64) -> ([f64; 3], [f64; 3]) {
let f = span_factor(z);
let (d, _) = deflection(s, t);
let ds = 1e-6;
let (s0, s1) = ((s - ds).max(0.0), (s + ds).min(1.0));
let wx = (deflection(s1, t).0 - deflection(s0, t).0) / (s1 - s0) / FLAG_LEN * f;
// The span factor's rate: κ / (span/2) inside the span.
let (zc, span) = (0.5 * duct_depth(), flag_span());
let wz = if ((z - zc) / (0.5 * span)).abs() < 1.0 {
d * twist() / (0.5 * span)
} else {
0.0
};
let r = (1.0 + wx * wx + wz * wz).sqrt();
(
[FLAG_X0 + s * FLAG_LEN, body_cy() + d * f, z],
[-wx / r, 1.0 / r, -wz / r],
)
}
fn inflow(y: f64, z: f64) -> f64 {
let (hd, d) = (duct_height(), duct_depth());
16.0 * U_M * y * z * (hd - y) * (d - z) / (hd * hd * d * d)
}
#[test]
#[ignore = "S2-3: the flag wake on the device (about an hour at ny 62)"]
fn flag_wake_on_the_device() {
let ny = env_f("RTX_E3_FLAG_NY", 62.0) as usize;
let periods = env_f("RTX_E3_FLAG_PERIODS", 2.0);
let h = H / ny as f64;
let nx = (L / h).round() as usize;
// The grid's rows: the benchmark's `ny` unless the duct is taller.
let ny_grid = (duct_height() / h).round() as usize;
// `RTX_E3_FLAG_NZ=4`: a thin slab periodic in z with the 2D inflow (Ū = 1) — the
// flag as a 2D problem, minutes per rung: the instrument for the load routes' parts.
let slab_nz = env_f("RTX_E3_FLAG_NZ", 0.0) as usize;
let nz = if slab_nz > 0 {
slab_nz
} else {
(duct_depth() / h).round() as usize
};
// S2-9b: the 3D run with the slab's 2D inflow (uniform in z) and/or slip
// side walls — the decomposition of the 3D-over-slab drag.
let inflow_2d = std::env::var("RTX_E3_FLAG_INFLOW").is_ok_and(|v| v == "2d");
let z_slip = std::env::var("RTX_E3_FLAG_ZSIDES").is_ok_and(|v| v == "slip");
let r_edge = h;
// S2-9: with a recorded kinematics the period and the speed bound are the record's.
let rec_period = env_f("RTX_E3_FLAG_KIN_PERIOD", 0.5225);
let rec_speed = recorded().map(Recorded::max_speed);
let dt_cfl = 0.3 * h / (U_M.max(rec_speed.unwrap_or(2.0 * std::f64::consts::PI * FREQ * AMP)));
// `RTX_E3_FLAG_DT_SCALE` scales the step (the dt ladder of the loads).
let dt = dt_cfl.min(0.5 * h * h / (6.0 * NU)) * env_f("RTX_E3_FLAG_DT_SCALE", 1.0);
let period = if recorded().is_some() { rec_period } else { 1.0 / FREQ };
let t_end = periods * period;
let mut solver = Solver::new(
Fluid {
density: RHO,
viscosity: RHO * NU,
reference_velocity: 1.0,
reference_length: 2.0 * R_CYL,
},
Parameters {
// Three correctors at a 1e-3 inner stop hold the moving cut
// wall's mass residual under 1e-8 (the moving circle: 7.6e-9
// against 1.5e-6 with two at 1e-2); `RTX_E3_FLAG_CORRECTORS`
// overrides for the comparison runs.
corrector_steps: env_f("RTX_E3_FLAG_CORRECTORS", 3.0) as usize,
inner_stop_factor: env_f("RTX_E3_FLAG_INNER", 1e-3),
tolerance: 1e-8,
convection_scheme: ConvectionScheme::TvdVanAlbada,
wall_scheme: WallScheme::CutCell,
boundaries: if slab_nz > 0 {
Boundaries {
x1: Side::PressureOutlet,
z0: Side::Periodic,
z1: Side::Periodic,
..Boundaries::default()
}
} else if z_slip {
// S2-9b: slip side walls (the 3D solver on a spanwise-uniform problem).
Boundaries {
x1: Side::PressureOutlet,
z0: Side::SlipWall,
z1: Side::SlipWall,
..Boundaries::default()
}
} else {
Boundaries {
x1: Side::PressureOutlet,
..Boundaries::default()
}
},
// The narrow band: the flag's tip speed bounds the surface motion.
max_surface_speed: Some(
(rec_speed.unwrap_or(2.0 * std::f64::consts::PI * FREQ * amplitude()) * 1.05).max(1e-3),
),
..Parameters::default()
},
);
let inflow_at = move |y: f64, z: f64| {
if slab_nz > 0 || inflow_2d {
let hd = duct_height();
6.0 * y * (hd - y) / (hd * hd)
} else {
inflow(y, z)
}
};
solver.set_boundary_velocity(move |x, y, z, _t| {
if x <= 0.0 {
(inflow_at(y, z), 0.0, 0.0)
} else {
(0.0, 0.0, 0.0)
}
});
let cy = body_cy();
let cyl = move |x: f64, y: f64| ((x - CX).powi(2) + (y - cy).powi(2)).sqrt() - R_CYL;
let r_fillet = root_fillet();
// The device form of φ: the polyline capsule (R6-1), or the plate (R8-c).
let device_sdf = move |t: f64| {
let plate = plate_body().then(|| plate_at(t));
DeviceSdf {
cyl: [CX, cy, R_CYL],
cyl_cut: !(flag_span() >= duct_depth()
|| !std::env::var("RTX_E3_FLAG_CYL_SPAN").is_ok_and(|v| v == "flag")),
flag_cut: flag_span() < duct_depth(),
zc: 0.5 * duct_depth(),
span: flag_span(),
r_edge,
half: FLAG_HALF,
fillet: r_fillet,
tip_corner: flat_tip(),
poly: match (&plate, recorded()) {
(Some(_), _) => Vec::new(),
(None, Some(rec)) => recorded_polyline(rec, t)
.iter()
.map(|p| [p.0, p.1])
.collect(),
(None, None) => analytic_polyline(t).iter().map(|p| [p.0, p.1]).collect(),
},
// R6-2 step 2: the centreline's velocity per point (the analytic mode is transverse).
vel: match (&plate, recorded()) {
(Some(_), _) => Vec::new(),
(None, Some(rec)) => recorded_polyline(rec, t)
.iter()
.map(|p| [p.2, p.3])
.collect(),
(None, None) => analytic_polyline(t).iter().map(|p| [0.0, p.2]).collect(),
},
plate,
}
};
// The device form at `t`, once per thread and time (the host closures
// of the plate body evaluate it ~10⁶ times per step).
let sdf_at = move |t: f64| -> std::sync::Arc {
thread_local! {
static SDF: std::cell::RefCell<(u64, Option>)> =
const { std::cell::RefCell::new((u64::MAX, None)) };
}
SDF.with(|cell| {
let mut c = cell.borrow_mut();
if c.0 != t.to_bits() || c.1.is_none() {
c.1 = Some(std::sync::Arc::new(device_sdf(t)));
c.0 = t.to_bits();
}
c.1.clone().expect("sdf")
})
};
let body = if plate_body() || flat_tip().is_some() {
// R8-c: the host φ and surface velocity ARE the device form's (the
// kernel's arithmetic on the host); R8-h: the flat tip too.
Body::from_sdf(move |x, y, z, t| sdf_at(t).phi_host(x, y, z))
.with_surface_velocity(move |x, y, z, t| sdf_at(t).velocity_host(x, y, z))
} else {
Body::from_sdf(move |x, y, z, t| {
fillet_union(
cylinder_3d(cyl(x, y), z, r_edge),
flag_3d(x, y, z, t, r_edge).0,
r_fillet,
)
})
.with_surface_velocity(move |x, y, z, t| {
let (df, (vx, vy)) = flag_3d(x, y, z, t, r_edge);
if df <= cylinder_3d(cyl(x, y), z, r_edge) {
(vx, vy, 0.0)
} else {
(0.0, 0.0, 0.0)
}
})
};
assert!(
twist() == 0.0 || plate_body(),
"RTX_E3_FLAG_TWIST needs RTX_E3_FLAG_BODY=plate"
);
// R6-1: the same φ in the device's form (the device geometry, default ON): the
// circle, the capsule around the step's centreline (or the plate), the span cuts.
let body = body.with_device_sdf(device_sdf);
solver.set_moving_body(body);
let g = Grid::cubic(nx, ny_grid, nz, h);
let mut field = Field::new(g);
for k in 0..nz {
for j in 0..ny_grid {
let u0 = inflow_at((j as f64 + 0.5) * h, (k as f64 + 0.5) * h);
for i in 0..=nx {
field.u[g.uface(k, j, i)] = u0;
}
}
}
solver.initialize(&mut field);
println!(
" flag wake ny {ny} (span {}, cylinder {}; duct {:.2} × {:.2} m, inflow {}, z sides {}; root fillet {:.4} m, tip inset {:.4} m): {nx}×{ny_grid}×{nz} = {} cells, h {h:.4e}, dt {dt:.3e}, {periods} periods = {t_end:.3} s, {} steps",
flag_span(),
if std::env::var("RTX_E3_FLAG_CYL_SPAN").is_ok_and(|v| v == "flag") { "cut to the span" } else { "wall to wall" },
duct_height(),
duct_depth(),
if slab_nz > 0 || inflow_2d { "2d" } else { "3d" },
if slab_nz > 0 {
"periodic"
} else if z_slip {
"slip"
} else {
"wall"
},
root_fillet(),
tip_inset(),
g.cells(),
(t_end / dt).ceil() as usize
);
if let Some(rc) = flat_tip() {
println!(" R8-h: FLAT tip through the centreline's last point, corner radius {rc:.5} m");
}
unsafe { std::env::set_var("RTX_PROFILE", "1") };
let mut device = DeviceStep::new(solver, g);
device.upload(&field);
let steps = (t_end / dt).ceil() as usize;
let mut csv = std::env::var("RTX_E3_FLAG_CSV").ok().map(|p| {
let mut f = std::fs::File::create(p).expect("csv");
writeln!(
f,
"t,tip,drag_span,lift_span,drag_total,lift_total,residual,cg,fresh,drag_rec,lift_rec"
)
.unwrap();
f
});
let vtk_dir = std::env::var("RTX_E3_FLAG_VTK").ok();
// `RTX_E3_FLAG_PHASES` phases per period (32); `RTX_E3_FLAG_VTK_FROM` = the period
// index the export starts at (the last period by default; 0 = the whole onset from
// rest, the viewer's sequence mode of 2026-09-22).
let phases = env_f("RTX_E3_FLAG_PHASES", 32.0) as usize;
let vtk_from = env_f("RTX_E3_FLAG_VTK_FROM", (periods - 1.0).max(0.0)) as f64;
let last_period_start = vtk_from * period;
let total_phases = ((periods - vtk_from) * phases as f64).round() as usize;
let mut next_phase = 0;
let mid = nz / 2;
let slab = if slab_nz > 0 {
(0, nz)
} else {
(mid - 2, mid + 2)
};
let width = nz as f64 * h;
// R8-c: the load transfer onto the structure's Hex20 plate (35 × 2 × n
// with n = `RTX_E3_FLAG_TRANSFER`; off when unset) every
// `RTX_E3_FLAG_TRANSFER_EVERY`-th sample (default 1), the budget to
// `RTX_E3_FLAG_TRANSFER_CSV`, the last transfer's nodal forces to
// `RTX_E3_FLAG_TRANSFER_NODAL`. The plate is placed on the prescribed
// kinematics (analytic mode): the centreline with the span factor, the
// thickness along its in-plane normal, the flag's span.
let transfer_nz = env_f("RTX_E3_FLAG_TRANSFER", 0.0) as usize;
let transfer_every = (env_f("RTX_E3_FLAG_TRANSFER_EVERY", 1.0) as usize).max(1);
let hex = (transfer_nz > 0).then(|| {
assert!(
recorded().is_none(),
"RTX_E3_FLAG_TRANSFER: the analytic mode only"
);
HexPlate::new(35, 2, transfer_nz)
});
let mut transfer_csv = std::env::var("RTX_E3_FLAG_TRANSFER_CSV").ok().map(|p| {
let mut f = std::fs::File::create(p).expect("transfer csv");
writeln!(
f,
"t,loads,flag_loads,route_x,route_y,route_z,sum_dx,sum_dy,sum_dz,fin_x,fin_y,fin_z,dfx,dfy,dfz,min_x,min_y,min_z,dmx,dmy,dmz,rel_force,rel_moment,max_newton,max_outside,extrapolated,extrap_share,interior_share,lever_x,lever_y,lever_z,ms"
)
.unwrap();
f
});
let mut samples_seen = 0usize;
let start = std::time::Instant::now();
let mut drag_rec_sum = 0.0;
// The routes' PARTS over the whole body (x, per unit width): operator
// pressure / shear / exchange, reconstructed pressure / shear.
let mut parts = [[0.0_f64; 6]; 3];
let (mut drag_sum, mut lift_min, mut lift_max, mut samples) =
(0.0, f64::INFINITY, f64::NEG_INFINITY, 0usize);
let mut worst_residual = 0.0_f64;
for step in 0..steps {
let r = device.advance(dt);
worst_residual = worst_residual.max(r.final_residual);
assert!(r.final_residual.is_finite(), "death at step {step}");
let t = device.solver.time();
let sample = (step + 1) % 10 == 0 || step + 1 == steps;
let phase_due = vtk_dir.is_some()
&& t >= last_period_start + next_phase as f64 * period / phases as f64
&& next_phase < total_phases;
if sample || phase_due {
device.download(&mut field);
let solver = &device.solver;
let mask = solver.mask().expect("mask");
let body = solver.body().expect("body");
let fs = mask
.cut_wall_force_per_span(body, &field, RHO * NU, t, slab)
.expect("wall");
// The reconstructed wall route on the same slab, per span.
let fr = mask
.cut_wall_force_reconstructed(body, &field, RHO * NU, t, Some(slab))
.map(|v| {
let lz = (slab.1 - slab.0) as f64 * h;
[v[0] / lz, v[1] / lz, v[2] / lz]
})
.expect("reconstructed");
let ft = mask
.cut_wall_force(body, &field, RHO * NU, t)
.expect("wall");
if sample {
samples_seen += 1;
}
if let (Some(hex), true) = (hex.as_ref(), sample && samples_seen % transfer_every == 0)
{
let lap = std::time::Instant::now();
let (loads, route) = mask
.cut_wall_loads(body, &field, RHO * NU, t)
.expect("loads");
// The route's total is cut_wall_force's, to the bit (same loops).
assert_eq!(route.map(f64::to_bits), ft.map(f64::to_bits), "route total");
let mut sum = [0.0f64; 3];
for l in &loads {
for c in 0..3 {
sum[c] += l.f[c];
}
}
let sdf = body.device_sdf(t).expect("device form");
let flag: Vec<_> = loads
.iter()
.filter(|l| sdf.is_flag_host(l.foot[0], l.foot[1], l.foot[2]))
.collect();
let (zc, span) = (0.5 * duct_depth(), flag_span());
let pos = hex.place(|s, eta, zeta| {
let z = zc - 0.5 * span + span * zeta;
let (c, n) = mid_point(s, z, t);
[
c[0] + FLAG_HALF * eta * n[0],
c[1] + FLAG_HALF * eta * n[1],
c[2] + FLAG_HALF * eta * n[2],
]
});
let pairs: Vec<([f64; 3], [f64; 3])> = flag.iter().map(|l| (l.foot, l.f)).collect();
let origin = [FLAG_X0, body_cy(), zc];
let tr = hex.transfer(&pos, &pairs, origin);
// The lever the foot adds over the operator point: Σ (foot − x) × F.
let mut lever = [0.0f64; 3];
for l in &flag {
let d = [l.foot[0] - l.x[0], l.foot[1] - l.x[1], l.foot[2] - l.x[2]];
let m = [
d[1] * l.f[2] - d[2] * l.f[1],
d[2] * l.f[0] - d[0] * l.f[2],
d[0] * l.f[1] - d[1] * l.f[0],
];
for c in 0..3 {
lever[c] += m[c];
}
}
let nrm = |a: [f64; 3]| (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
let df = [0, 1, 2].map(|c| tr.force_out[c] - tr.force_in[c]);
let dm = [0, 1, 2].map(|c| tr.moment_out[c] - tr.moment_in[c]);
let abs_f: f64 = flag.iter().map(|l| nrm(l.f)).sum();
let rel_f = nrm(df) / abs_f.max(1e-300);
let rel_m = nrm(dm) / (abs_f * FLAG_LEN).max(1e-300);
let extrap_share = tr.extrapolated_load / abs_f.max(1e-300);
let ms = lap.elapsed().as_secs_f64() * 1e3;
println!(
" transfer t {t:.4}: {} loads ({} flag); route − Σ {:.1e} N; flag F {:+.4} {:+.4} {:+.4} N, M {:+.5} {:+.5} {:+.5} N m; |ΔF|/Σ|F| {rel_f:.1e}, |ΔM|/(Σ|F| L) {rel_m:.1e}; Newton ≤ {:.1e} m, outside ≤ {:.2e} at ({:.4}, {:.4}, {:.4}) ({} beyond {FAR_OUTSIDE}, {:.1e} of Σ|F|), interior share {:.3}; {ms:.0} ms",
loads.len(),
flag.len(),
nrm([route[0] - sum[0], route[1] - sum[1], route[2] - sum[2]]),
tr.force_in[0],
tr.force_in[1],
tr.force_in[2],
tr.moment_in[0],
tr.moment_in[1],
tr.moment_in[2],
tr.max_residual,
tr.max_outside,
tr.worst_point[0],
tr.worst_point[1],
tr.worst_point[2],
tr.extrapolated,
extrap_share,
tr.interior_share
);
if let Some(f) = transfer_csv.as_mut() {
writeln!(
f,
"{t:.6},{},{},{:.9e},{:.9e},{:.9e},{:.3e},{:.3e},{:.3e},{:.9e},{:.9e},{:.9e},{:.3e},{:.3e},{:.3e},{:.9e},{:.9e},{:.9e},{:.3e},{:.3e},{:.3e},{rel_f:.3e},{rel_m:.3e},{:.3e},{:.3e},{},{extrap_share:.3e},{:.4e},{:.4e},{:.4e},{:.4e},{ms:.1}",
loads.len(),
flag.len(),
route[0],
route[1],
route[2],
route[0] - sum[0],
route[1] - sum[1],
route[2] - sum[2],
tr.force_in[0],
tr.force_in[1],
tr.force_in[2],
df[0],
df[1],
df[2],
tr.moment_in[0],
tr.moment_in[1],
tr.moment_in[2],
dm[0],
dm[1],
dm[2],
tr.max_residual,
tr.max_outside,
tr.extrapolated,
tr.interior_share,
lever[0],
lever[1],
lever[2]
)
.unwrap();
}
if let Ok(path) = std::env::var("RTX_E3_FLAG_TRANSFER_NODAL") {
let mut f = std::fs::File::create(path).expect("nodal csv");
writeln!(f, "node,i,j,k,x,y,z,fx,fy,fz").unwrap();
for (n, fv) in tr.nodal.iter().enumerate() {
let [i, j, k] = hex.lattice_of(n);
let x = pos[n];
writeln!(
f,
"{n},{i},{j},{k},{:.9e},{:.9e},{:.9e},{:.9e},{:.9e},{:.9e}",
x[0], x[1], x[2], fv[0], fv[1], fv[2]
)
.unwrap();
}
}
}
// The tip's transverse deflection: the record's last station in
// recorded mode (until 2026-09-21 this column held the analytic
// first mode even then — R2's fits use the record directly).
let tip = match recorded() {
Some(rec) => rec.at(t, body_cy()).last().map_or(0.0, |p| p.1 - body_cy()),
None => deflection(1.0, t).0,
};
if sample {
println!(
" t {t:7.4} (tip {tip:+.4}): drag/span {:.1} lift/span {:+.1} N/m (reconstructed {:.1} {:+.1}); total {:.3} {:+.3} N; residual {:.1e} CG {} fresh {}; [{:.0} s]",
fs[0],
fs[1],
fr[0],
fr[1],
ft[0],
ft[1],
r.final_residual,
r.poisson_iterations,
r.fresh_cells,
start.elapsed().as_secs_f64()
);
if let Some(f) = csv.as_mut() {
writeln!(
f,
"{t:.5},{tip:.5},{:.4},{:.4},{:.5},{:.5},{:.3e},{},{},{:.4},{:.4}",
fs[0],
fs[1],
ft[0],
ft[1],
r.final_residual,
r.poisson_iterations,
r.fresh_cells,
fr[0],
fr[1]
)
.unwrap();
}
if t >= last_period_start {
use rtx_cfd::solvers::incompressible::embedded3::exchange::set_load_window;
// whole body, the cylinder (x < 0.252), the flag
for (w, window) in [None, Some((0.0, 0.252)), Some((0.252, 10.0))]
.into_iter()
.enumerate()
{
set_load_window(window);
let (po, so) = mask
.cut_wall_force_parts(body, &field, RHO * NU, t)
.expect("parts");
let (xd, xc) = mask
.cut_wall_exchange_parts(body, &field, RHO * NU, RHO, t, None)
.expect("exchange");
let (pr, sr) = mask
.cut_wall_force_reconstructed_parts(body, &field, RHO * NU, t, None)
.expect("reconstructed parts");
for (acc, v) in parts[w]
.iter_mut()
.zip([po[0], so[0], xd[0], xc[0], pr[0], sr[0]])
{
*acc += v / width;
}
}
set_load_window(None);
drag_sum += fs[0];
drag_rec_sum += fr[0];
lift_min = lift_min.min(fs[1]);
lift_max = lift_max.max(fs[1]);
samples += 1;
}
}
if phase_due {
let tag = if flag_span() >= duct_depth() { "full" } else { "free" };
let path = std::path::Path::new(vtk_dir.as_ref().unwrap())
.join(if total_phases > 100 {
format!("flag_{tag}_ny{ny}_phase{next_phase:03}.vtk")
} else {
format!("flag_{tag}_ny{ny}_phase{next_phase:02}.vtk")
});
write_vtk(&path, &field, Some(mask)).expect("vtk");
next_phase += 1;
}
}
}
let drag_mean = drag_sum / samples.max(1) as f64;
let drag_rec = drag_rec_sum / samples.max(1) as f64;
println!(
" FINAL ny {ny}: last period drag/span mean {drag_mean:.1} N/m (reconstructed {drag_rec:.1}; 2D FSI2 224.6, the 2D reference of this kinematics 208.3), lift/span {lift_min:+.1} … {lift_max:+.1} (2D ±215 flat tip, ±256 semicircle); worst residual {worst_residual:.1e}; {} phases written; {:.0} s",
next_phase,
start.elapsed().as_secs_f64()
);
let n = samples.max(1) as f64;
for (name, q) in ["whole body", "cylinder", "flag"].iter().zip(parts) {
println!(
" PARTS ny {ny} amp {:.3} {name} (x, N/m of width): operator pressure {:.2} + shear {:.2} + exchange diffusive {:.2} + convective {:.2} = {:.2}; reconstructed pressure {:.2} + shear {:.2} = {:.2}",
amplitude(),
q[0] / n,
q[1] / n,
q[2] / n,
q[3] / n,
(q[0] + q[1] + q[2] + q[3]) / n,
q[4] / n,
q[5] / n,
(q[4] + q[5]) / n
);
}
if let Some(t) = device.timers() {
println!(" timers: {t:?}");
}
}