R8-c: the 3D flag's interface — deformed-plate body (host + device φ/ub) and the conservative load transfer onto a Hex20 plate

- embedded3/plate.rs: PlateSurface (mid-surface on span stations) and the
  host evaluation of DeviceSdf (phi_host / velocity_host / is_flag_host),
  expression for expression e3_geom.cu; a span-uniform plate is the
  polyline capsule to the bit; first-order spanwise-slope correction.
- e3_geom.cu / device/geom.rs: plate branch of geom_phi_at and
  body_velocity (nst = 0 keeps the polyline path unchanged).
- cutwall.rs / exchange.rs: the load loops observed through a sink (sums
  unchanged); interface.rs: Mask::cut_wall_loads (every summand of
  cut_wall_force with its foot), HexPlate (R8-b's Hex20 lattice numbering),
  consistent point-force transfer conserving force and moment to round-off,
  locate() for the transpose, mid_surface() for the fluid body.
- flag test: RTX_E3_FLAG_BODY=plate, _STATIONS, _TWIST, _TRANSFER(_EVERY,
  _CSV, _NODAL); all default off.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-25 18:03:26 -05:00
co-authored by Claude Opus 5.5
parent d63806c0e6
commit 9c2ae32061
9 changed files with 1639 additions and 56 deletions
@@ -26,8 +26,8 @@ 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, Field, Fluid, Grid, Parameters, Side, Solver, WallScheme,
write_vtk,
Body, Boundaries, DeviceSdf, Field, Fluid, Grid, HexPlate, Parameters, PlateSurface, Side,
Solver, WallScheme, write_vtk,
};
use std::io::Write as _;
@@ -287,6 +287,99 @@ fn cylinder_3d(d2: f64, z: f64, r: f64) -> f64 {
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<f64> {
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)
@@ -385,9 +478,68 @@ fn flag_wake_on_the_device() {
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();
let body = 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)
})
// 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,
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<DeviceSdf> {
thread_local! {
static SDF: std::cell::RefCell<(u64, Option<std::sync::Arc<DeviceSdf>>)> =
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() {
// R8-c: the host φ and surface velocity ARE the device form's (the
// kernel's arithmetic on the host).
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) {
@@ -395,35 +547,15 @@ fn flag_wake_on_the_device() {
} 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, the span cuts.
let body = body.with_device_sdf(move |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,
poly: match recorded() {
Some(rec) => recorded_polyline(rec, t)
.iter()
.map(|p| [p.0, p.1])
.collect(),
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 recorded() {
Some(rec) => recorded_polyline(rec, t)
.iter()
.map(|p| [p.2, p.3])
.collect(),
None => analytic_polyline(t).iter().map(|p| [0.0, p.2]).collect(),
},
});
// 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);
@@ -484,6 +616,32 @@ fn flag_wake_on_the_device() {
(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
@@ -520,6 +678,128 @@ fn flag_wake_on_the_device() {
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} ({} extrapolated, {:.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.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).