//! 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:?}"); } }