Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
731 lines
27 KiB
Rust
731 lines
27 KiB
Rust
//! R8-c: the fluid–structure interface's LOAD side for a 3D flag — the
|
||
//! cut-cell wall's force as the list of its summands (each located), and
|
||
//! their consistent, conservative distribution onto a structured Hex20
|
||
//! plate (the R8-b structure's node layout).
|
||
//!
|
||
//! # The loads
|
||
//!
|
||
//! [`Mask::cut_wall_loads`] returns every summand of the operator load
|
||
//! route `cut_wall_force` — the per-cell pressure `p_c W_c` (at the cell
|
||
//! centre), the per-face implicit wall shear and the per-face wall exchange
|
||
//! (diffusive and convective; at the fluid face's position) — each with
|
||
//! its FOOT on the body's surface (`x − φ n`, two projection steps on the
|
||
//! body's φ). The sums are the route's own: the same loops, observed.
|
||
//! The route's total is returned with them; the summands' sum differs from
|
||
//! it by the summation order only (round-off).
|
||
//!
|
||
//! # The transfer
|
||
//!
|
||
//! [`HexPlate::transfer`] hands each load `(point, F)` to the Hex20 element
|
||
//! of the deformed plate that contains `point` (the isoparametric map
|
||
//! inverted by Newton to round-off; the nearest element's extrapolation
|
||
//! when no element contains it) and distributes it with the element's
|
||
//! shape functions, `f_a = N_a(ξ) F`: the consistent nodal load of a point
|
||
//! force (its virtual work). The serendipity functions are a partition of
|
||
//! unity and reproduce the coordinates (`Σ N_a x_a = x(ξ) = point` once
|
||
//! Newton has converged), so the total force AND the total moment about
|
||
//! any point are conserved to round-off, whatever the element. A foot on
|
||
//! the plate's faces loads that face's nodes only; a foot inside the solid
|
||
//! (the capsule's rounded span edges and tip lie inside the Hex box) also
|
||
//! loads the element's other nodes — reported as the interior share.
|
||
//! The motion side is the transpose: [`HexPlate::locate`] gives the same
|
||
//! weights for the velocity `Σ N_a v_a` at a fluid point (work-conjugate).
|
||
//!
|
||
//! Host only (phase 1): the loads are read from the host mirror of the
|
||
//! device state at the sample steps, as the load routes are today.
|
||
|
||
use super::body::Body;
|
||
use super::field::Field;
|
||
use super::plate::PlateSurface;
|
||
use super::wall::Mask;
|
||
|
||
/// Which summand of the operator load route a [`WallLoad`] is.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum LoadKind {
|
||
/// `p_c W_c` of a cut cell.
|
||
Pressure,
|
||
/// The implicit wall shear of a fluid face.
|
||
Shear,
|
||
/// The diffusive exchange with a prescribed neighbour face.
|
||
ExchangeDiffusive,
|
||
/// The convective exchange with a prescribed neighbour face.
|
||
ExchangeConvective,
|
||
}
|
||
|
||
/// One summand of the cut-cell wall force (a force ON the body).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct WallLoad {
|
||
pub kind: LoadKind,
|
||
/// Where the operator evaluates it: the cell centre or the face position.
|
||
pub x: [f64; 3],
|
||
/// Its foot on the body's surface (the application point).
|
||
pub foot: [f64; 3],
|
||
/// The force on the body.
|
||
pub f: [f64; 3],
|
||
}
|
||
|
||
impl Mask {
|
||
/// The operator load route's summands (see the module doc) and the
|
||
/// route's total as `cut_wall_force` computes it. `None` without a cut
|
||
/// geometry, or with the S2-5 gradient weights on (their force is not
|
||
/// decomposed).
|
||
pub fn cut_wall_loads(
|
||
&self,
|
||
body: &Body,
|
||
f: &Field,
|
||
mu: f64,
|
||
t: f64,
|
||
) -> Option<(Vec<WallLoad>, [f64; 3])> {
|
||
if self.grad_weights.is_some() {
|
||
return None;
|
||
}
|
||
// The loops visit every fluid cell and face; only the non-zero
|
||
// summands are loads (the zero ones change no sum).
|
||
let mut raw: Vec<(LoadKind, [f64; 3], [f64; 3])> = Vec::new();
|
||
let mut keep = |k: LoadKind, x: [f64; 3], v: [f64; 3]| {
|
||
if v != [0.0; 3] {
|
||
raw.push((k, x, v));
|
||
}
|
||
};
|
||
let (p, s) = self.cut_wall_force_parts_sink(body, f, mu, t, None, &mut keep)?;
|
||
let (d, c) =
|
||
self.cut_wall_exchange_parts_sink(body, f, mu, self.density, t, None, &mut keep)?;
|
||
let xch = [d[0] + c[0], d[1] + c[1], d[2] + c[2]];
|
||
let total = [
|
||
p[0] + s[0] + xch[0],
|
||
p[1] + s[1] + xch[1],
|
||
p[2] + s[2] + xch[2],
|
||
];
|
||
let g = self.grid;
|
||
let eps = 1e-6 * g.dx.min(g.dy).min(g.dz);
|
||
let foot = |x: [f64; 3]| {
|
||
let mut q = x;
|
||
for _ in 0..2 {
|
||
let s = body.phi(q[0], q[1], q[2], t);
|
||
let n = body.normal(q[0], q[1], q[2], t, eps);
|
||
q = [q[0] - s * n.0, q[1] - s * n.1, q[2] - s * n.2];
|
||
}
|
||
q
|
||
};
|
||
use rayon::prelude::*;
|
||
let loads = raw
|
||
.par_iter()
|
||
.map(|&(kind, x, f)| WallLoad {
|
||
kind,
|
||
x,
|
||
foot: foot(x),
|
||
f,
|
||
})
|
||
.collect();
|
||
Some((loads, total))
|
||
}
|
||
}
|
||
|
||
/// The Hex20 node order of R8-b's `Flag3d` (rtx-fea `analysis/flag3d.rs`):
|
||
/// the natural coordinates `(ξ, η, ζ)` ↔ lattice `(i, j, k)` = (length,
|
||
/// thickness, span); corners of `ζ = −1` then `ζ = +1` counter-clockwise
|
||
/// from `(−1, −1)`, the mid-edges of `ζ = −1`, of `ζ = +1`, then the four
|
||
/// span edges.
|
||
const HEX20: [[i8; 3]; 20] = [
|
||
[-1, -1, -1],
|
||
[1, -1, -1],
|
||
[1, 1, -1],
|
||
[-1, 1, -1],
|
||
[-1, -1, 1],
|
||
[1, -1, 1],
|
||
[1, 1, 1],
|
||
[-1, 1, 1],
|
||
[0, -1, -1],
|
||
[1, 0, -1],
|
||
[0, 1, -1],
|
||
[-1, 0, -1],
|
||
[0, -1, 1],
|
||
[1, 0, 1],
|
||
[0, 1, 1],
|
||
[-1, 0, 1],
|
||
[-1, -1, 0],
|
||
[1, -1, 0],
|
||
[1, 1, 0],
|
||
[-1, 1, 0],
|
||
];
|
||
|
||
/// The Hex20 serendipity shape functions and their natural derivatives.
|
||
#[must_use]
|
||
pub fn hex20(xi: [f64; 3]) -> ([f64; 20], [[f64; 3]; 20]) {
|
||
let mut n = [0.0; 20];
|
||
let mut d = [[0.0; 3]; 20];
|
||
for (a, c) in HEX20.iter().enumerate() {
|
||
let ca = [f64::from(c[0]), f64::from(c[1]), f64::from(c[2])];
|
||
let lin = |m: usize| 1.0 + xi[m] * ca[m];
|
||
if c.iter().all(|&v| v != 0) {
|
||
let (p, q, r) = (lin(0), lin(1), lin(2));
|
||
let s = xi[0] * ca[0] + xi[1] * ca[1] + xi[2] * ca[2];
|
||
n[a] = 0.125 * p * q * r * (s - 2.0);
|
||
d[a][0] = 0.125 * q * r * ca[0] * (s - 2.0 + p);
|
||
d[a][1] = 0.125 * p * r * ca[1] * (s - 2.0 + q);
|
||
d[a][2] = 0.125 * p * q * ca[2] * (s - 2.0 + r);
|
||
} else {
|
||
// The zero direction `m0`; the other two linear.
|
||
let m0 = c.iter().position(|&v| v == 0).expect("mid-edge");
|
||
let (m1, m2) = ((m0 + 1) % 3, (m0 + 2) % 3);
|
||
let bub = 1.0 - xi[m0] * xi[m0];
|
||
n[a] = 0.25 * bub * lin(m1) * lin(m2);
|
||
d[a][m0] = 0.25 * (-2.0 * xi[m0]) * lin(m1) * lin(m2);
|
||
d[a][m1] = 0.25 * bub * ca[m1] * lin(m2);
|
||
d[a][m2] = 0.25 * bub * lin(m1) * ca[m2];
|
||
}
|
||
}
|
||
(n, d)
|
||
}
|
||
|
||
/// A structured Hex20 plate on the `(2nx+1) × (2ny+1) × (2nz+1)`
|
||
/// serendipity lattice — `nx` elements along the length, `ny` through the
|
||
/// thickness, `nz` along the span — numbered exactly as R8-b's `Flag3d`:
|
||
/// lattice points with at most one odd index, scanned `i` (length)
|
||
/// outermost, then `j` (thickness), then `k` (span); node `n` is the
|
||
/// `n`-th such point. The elements scan `(ex, ey, ez)` the same way.
|
||
#[derive(Debug, Clone)]
|
||
pub struct HexPlate {
|
||
pub nx: usize,
|
||
pub ny: usize,
|
||
pub nz: usize,
|
||
dims: [usize; 3],
|
||
lattice: Vec<Option<usize>>,
|
||
points: Vec<[usize; 3]>,
|
||
elements: Vec<[usize; 20]>,
|
||
}
|
||
|
||
/// A point located in the plate: its element, natural coordinates, the
|
||
/// nodes and weights `N_a(ξ)`, the Newton residual `|x(ξ) − p|` and how
|
||
/// far outside the element it is (`max |ξ| − 1`, ≤ 0 inside).
|
||
#[derive(Debug, Clone)]
|
||
pub struct Location {
|
||
pub element: usize,
|
||
pub xi: [f64; 3],
|
||
pub nodes: [usize; 20],
|
||
pub weights: [f64; 20],
|
||
pub residual: f64,
|
||
pub outside: f64,
|
||
}
|
||
|
||
/// The natural-coordinate distance outside an element from which a load
|
||
/// counts as extrapolated in [`PlateTransfer`] (0.02 of the half-element).
|
||
pub const FAR_OUTSIDE: f64 = 0.02;
|
||
|
||
/// The result of one load transfer (see [`HexPlate::transfer`]).
|
||
#[derive(Debug, Clone)]
|
||
pub struct PlateTransfer {
|
||
/// The nodal forces, in the node numbering.
|
||
pub nodal: Vec<[f64; 3]>,
|
||
/// Σ F of the loads and Σ f_a of the nodes.
|
||
pub force_in: [f64; 3],
|
||
pub force_out: [f64; 3],
|
||
/// The moments about `origin`: Σ (p − o) × F and Σ (x_a − o) × f_a.
|
||
pub moment_in: [f64; 3],
|
||
pub moment_out: [f64; 3],
|
||
/// The largest Newton residual (m) and the largest outside-ness.
|
||
pub max_residual: f64,
|
||
pub max_outside: f64,
|
||
/// Loads located outside every element by more than [`FAR_OUTSIDE`]
|
||
/// in natural coordinates (a foot off the plate by more than a few %
|
||
/// of an element — the surfaces' round-off-level mismatch is excluded),
|
||
/// their Σ|F|, and the worst one's point.
|
||
pub extrapolated: usize,
|
||
pub extrapolated_load: f64,
|
||
pub worst_point: [f64; 3],
|
||
/// The share of Σ|f_a| on nodes off the wetted surface (the interior
|
||
/// layers and the clamped root face).
|
||
pub interior_share: f64,
|
||
}
|
||
|
||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||
[
|
||
a[1] * b[2] - a[2] * b[1],
|
||
a[2] * b[0] - a[0] * b[2],
|
||
a[0] * b[1] - a[1] * b[0],
|
||
]
|
||
}
|
||
|
||
fn norm(a: [f64; 3]) -> f64 {
|
||
(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
|
||
}
|
||
|
||
impl HexPlate {
|
||
/// The lattice and the elements of an `nx × ny × nz` plate.
|
||
#[must_use]
|
||
pub fn new(nx: usize, ny: usize, nz: usize) -> Self {
|
||
assert!(
|
||
nx > 0 && ny > 0 && nz > 0,
|
||
"element counts must be positive"
|
||
);
|
||
let dims = [2 * nx + 1, 2 * ny + 1, 2 * nz + 1];
|
||
let mut lattice = vec![None; dims[0] * dims[1] * dims[2]];
|
||
let mut points = Vec::new();
|
||
for i in 0..dims[0] {
|
||
for j in 0..dims[1] {
|
||
for k in 0..dims[2] {
|
||
if (i % 2) + (j % 2) + (k % 2) > 1 {
|
||
continue;
|
||
}
|
||
lattice[(i * dims[1] + j) * dims[2] + k] = Some(points.len());
|
||
points.push([i, j, k]);
|
||
}
|
||
}
|
||
}
|
||
let mut plate = Self {
|
||
nx,
|
||
ny,
|
||
nz,
|
||
dims,
|
||
lattice,
|
||
points,
|
||
elements: Vec::new(),
|
||
};
|
||
for ex in 0..nx {
|
||
for ey in 0..ny {
|
||
for ez in 0..nz {
|
||
let (a, b, c) = (2 * ex, 2 * ey, 2 * ez);
|
||
let e = HEX20.map(|o| {
|
||
let at = |base: usize, v: i8| (base as i64 + 1 + i64::from(v)) as usize;
|
||
plate
|
||
.lattice_node(at(a, o[0]), at(b, o[1]), at(c, o[2]))
|
||
.expect("serendipity node")
|
||
});
|
||
plate.elements.push(e);
|
||
}
|
||
}
|
||
}
|
||
plate
|
||
}
|
||
|
||
/// The node at lattice `(i, j, k)`, if the point carries one.
|
||
#[must_use]
|
||
pub fn lattice_node(&self, i: usize, j: usize, k: usize) -> Option<usize> {
|
||
if i >= self.dims[0] || j >= self.dims[1] || k >= self.dims[2] {
|
||
return None;
|
||
}
|
||
self.lattice[(i * self.dims[1] + j) * self.dims[2] + k]
|
||
}
|
||
|
||
/// The lattice point of node `n`.
|
||
#[must_use]
|
||
pub fn lattice_of(&self, n: usize) -> [usize; 3] {
|
||
self.points[n]
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn node_count(&self) -> usize {
|
||
self.points.len()
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn elements(&self) -> &[[usize; 20]] {
|
||
&self.elements
|
||
}
|
||
|
||
/// Whether node `n` lies on the wetted surface (the two faces, the tip,
|
||
/// the span edges; not the clamped root face `i = 0` unless also on one
|
||
/// of those).
|
||
#[must_use]
|
||
pub fn is_wetted(&self, n: usize) -> bool {
|
||
let [i, j, k] = self.points[n];
|
||
j == 0 || j == 2 * self.ny || i == 2 * self.nx || k == 0 || k == 2 * self.nz
|
||
}
|
||
|
||
/// Node positions from a placement `(s, η, ζ) → x` of the lattice's
|
||
/// fractions: `s = i/(2nx) ∈ [0, 1]` along the length, `η = j/ny − 1 ∈
|
||
/// [−1, 1]` through the thickness, `ζ = k/(2nz) ∈ [0, 1]` along the span.
|
||
#[must_use]
|
||
pub fn place<F: Fn(f64, f64, f64) -> [f64; 3]>(&self, f: F) -> Vec<[f64; 3]> {
|
||
self.points
|
||
.iter()
|
||
.map(|&[i, j, k]| {
|
||
f(
|
||
i as f64 / (2 * self.nx) as f64,
|
||
j as f64 / self.ny as f64 - 1.0,
|
||
k as f64 / (2 * self.nz) as f64,
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Newton on the isoparametric map of element `e` for the point `p`.
|
||
fn invert(&self, pos: &[[f64; 3]], e: usize, p: [f64; 3]) -> Location {
|
||
let nodes = self.elements[e];
|
||
let x: Vec<[f64; 3]> = nodes.iter().map(|&n| pos[n]).collect();
|
||
let mut xi = [0.0f64; 3];
|
||
let mut residual = f64::INFINITY;
|
||
let mut weights = [0.0; 20];
|
||
for it in 0..60 {
|
||
let (n, d) = hex20(xi);
|
||
let mut r = [-p[0], -p[1], -p[2]];
|
||
let mut jac = [[0.0f64; 3]; 3];
|
||
for a in 0..20 {
|
||
for c in 0..3 {
|
||
r[c] += n[a] * x[a][c];
|
||
for m in 0..3 {
|
||
jac[c][m] += x[a][c] * d[a][m];
|
||
}
|
||
}
|
||
}
|
||
weights = n;
|
||
let rn = norm(r);
|
||
// Converged: two more iterations past the first residual at
|
||
// round-off level make the last one a no-op.
|
||
if rn <= residual && rn < 1e-15 && it > 2 {
|
||
residual = rn;
|
||
break;
|
||
}
|
||
residual = rn;
|
||
// δ = −J⁻¹ r by the adjugate.
|
||
let det = jac[0][0] * (jac[1][1] * jac[2][2] - jac[1][2] * jac[2][1])
|
||
- jac[0][1] * (jac[1][0] * jac[2][2] - jac[1][2] * jac[2][0])
|
||
+ jac[0][2] * (jac[1][0] * jac[2][1] - jac[1][1] * jac[2][0]);
|
||
if det == 0.0 || !det.is_finite() {
|
||
break;
|
||
}
|
||
let inv = [
|
||
[
|
||
jac[1][1] * jac[2][2] - jac[1][2] * jac[2][1],
|
||
jac[0][2] * jac[2][1] - jac[0][1] * jac[2][2],
|
||
jac[0][1] * jac[1][2] - jac[0][2] * jac[1][1],
|
||
],
|
||
[
|
||
jac[1][2] * jac[2][0] - jac[1][0] * jac[2][2],
|
||
jac[0][0] * jac[2][2] - jac[0][2] * jac[2][0],
|
||
jac[0][2] * jac[1][0] - jac[0][0] * jac[1][2],
|
||
],
|
||
[
|
||
jac[1][0] * jac[2][1] - jac[1][1] * jac[2][0],
|
||
jac[0][1] * jac[2][0] - jac[0][0] * jac[2][1],
|
||
jac[0][0] * jac[1][1] - jac[0][1] * jac[1][0],
|
||
],
|
||
];
|
||
for m in 0..3 {
|
||
let dm = (inv[m][0] * r[0] + inv[m][1] * r[1] + inv[m][2] * r[2]) / det;
|
||
// Keep the iterate bounded (a far point in a distorted element).
|
||
xi[m] = (xi[m] - dm).clamp(-4.0, 4.0);
|
||
}
|
||
}
|
||
let outside = xi.iter().fold(f64::NEG_INFINITY, |m, v| m.max(v.abs())) - 1.0;
|
||
Location {
|
||
element: e,
|
||
xi,
|
||
nodes,
|
||
weights,
|
||
residual,
|
||
outside,
|
||
}
|
||
}
|
||
|
||
/// Locate `p` in the deformed plate `pos`: the containing element
|
||
/// (Newton on the elements nearest by centroid), else the nearest
|
||
/// element's extrapolation (the smallest outside-ness).
|
||
#[must_use]
|
||
pub fn locate(&self, pos: &[[f64; 3]], centroids: &[[f64; 3]], p: [f64; 3]) -> Location {
|
||
let mut order: Vec<(f64, usize)> = centroids
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(e, c)| {
|
||
let d = [c[0] - p[0], c[1] - p[1], c[2] - p[2]];
|
||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2], e)
|
||
})
|
||
.collect();
|
||
let take = 8.min(order.len());
|
||
order.select_nth_unstable_by(take - 1, |a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||
order[..take].sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||
let mut best: Option<Location> = None;
|
||
for &(_, e) in &order[..take] {
|
||
let loc = self.invert(pos, e, p);
|
||
if loc.outside <= 1e-9 && loc.residual < 1e-12 {
|
||
return loc;
|
||
}
|
||
if best
|
||
.as_ref()
|
||
.is_none_or(|b| (loc.outside, loc.residual) < (b.outside, b.residual))
|
||
{
|
||
best = Some(loc);
|
||
}
|
||
}
|
||
best.expect("an element")
|
||
}
|
||
|
||
/// The elements' corner centroids in the deformed plate.
|
||
#[must_use]
|
||
pub fn centroids(&self, pos: &[[f64; 3]]) -> Vec<[f64; 3]> {
|
||
self.elements
|
||
.iter()
|
||
.map(|e| {
|
||
let mut c = [0.0; 3];
|
||
for &n in &e[..8] {
|
||
for m in 0..3 {
|
||
c[m] += 0.125 * pos[n][m];
|
||
}
|
||
}
|
||
c
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The consistent nodal load of point forces `(p, F)` on the deformed
|
||
/// plate `pos` (see the module doc), with the conservation budget about
|
||
/// `origin`.
|
||
#[must_use]
|
||
pub fn transfer(
|
||
&self,
|
||
pos: &[[f64; 3]],
|
||
loads: &[([f64; 3], [f64; 3])],
|
||
origin: [f64; 3],
|
||
) -> PlateTransfer {
|
||
use rayon::prelude::*;
|
||
assert_eq!(pos.len(), self.node_count(), "one position per node");
|
||
let centroids = self.centroids(pos);
|
||
let located: Vec<Location> = loads
|
||
.par_iter()
|
||
.map(|&(p, _)| self.locate(pos, ¢roids, p))
|
||
.collect();
|
||
let mut nodal = vec![[0.0f64; 3]; self.node_count()];
|
||
let (mut force_in, mut moment_in) = ([0.0f64; 3], [0.0f64; 3]);
|
||
let (mut max_residual, mut max_outside) = (0.0f64, f64::NEG_INFINITY);
|
||
let (mut extrapolated, mut extrapolated_load) = (0usize, 0.0f64);
|
||
let mut worst_point = [0.0f64; 3];
|
||
for (&(p, f), loc) in loads.iter().zip(&located) {
|
||
for (&n, &w) in loc.nodes.iter().zip(&loc.weights) {
|
||
for c in 0..3 {
|
||
nodal[n][c] += w * f[c];
|
||
}
|
||
}
|
||
let m = cross([p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]], f);
|
||
for c in 0..3 {
|
||
force_in[c] += f[c];
|
||
moment_in[c] += m[c];
|
||
}
|
||
max_residual = max_residual.max(loc.residual);
|
||
if loc.outside > max_outside {
|
||
max_outside = loc.outside;
|
||
worst_point = p;
|
||
}
|
||
if loc.outside > FAR_OUTSIDE {
|
||
extrapolated += 1;
|
||
extrapolated_load += norm(f);
|
||
}
|
||
}
|
||
let (mut force_out, mut moment_out) = ([0.0f64; 3], [0.0f64; 3]);
|
||
let (mut total_abs, mut interior_abs) = (0.0f64, 0.0f64);
|
||
for (n, f) in nodal.iter().enumerate() {
|
||
let x = pos[n];
|
||
let m = cross([x[0] - origin[0], x[1] - origin[1], x[2] - origin[2]], *f);
|
||
for c in 0..3 {
|
||
force_out[c] += f[c];
|
||
moment_out[c] += m[c];
|
||
}
|
||
let a = norm(*f);
|
||
total_abs += a;
|
||
if !self.is_wetted(n) || self.points[n][0] == 0 {
|
||
interior_abs += a;
|
||
}
|
||
}
|
||
PlateTransfer {
|
||
nodal,
|
||
force_in,
|
||
force_out,
|
||
moment_in,
|
||
moment_out,
|
||
max_residual,
|
||
max_outside,
|
||
extrapolated,
|
||
extrapolated_load,
|
||
worst_point,
|
||
interior_share: if total_abs > 0.0 {
|
||
interior_abs / total_abs
|
||
} else {
|
||
0.0
|
||
},
|
||
}
|
||
}
|
||
|
||
/// The mid-surface of the deformed plate for the fluid's body
|
||
/// ([`PlateSurface`]): the lattice's middle layer `j = ny` (`ny` even:
|
||
/// a node layer), the stations at the element boundaries `k` even (their
|
||
/// z the nodes' at the root, `i = 0`), the points along the length every
|
||
/// lattice step; the last point of each station pulled back along its
|
||
/// last segment by `tip_inset` (the capsule's apex on the structure's
|
||
/// tip: the flag test's `RTX_E3_FLAG_TIP_INSET`, one half-thickness).
|
||
/// With nodal velocities, the matching station velocities.
|
||
#[must_use]
|
||
pub fn mid_surface(
|
||
&self,
|
||
pos: &[[f64; 3]],
|
||
vel: Option<&[[f64; 3]]>,
|
||
tip_inset: f64,
|
||
) -> PlateSurface {
|
||
assert!(
|
||
self.ny % 2 == 0,
|
||
"the mid-surface is a node layer for an even thickness count"
|
||
);
|
||
let j = self.ny;
|
||
let ns = self.dims[0];
|
||
let mut z = Vec::new();
|
||
let mut xy = Vec::new();
|
||
let mut vv = Vec::new();
|
||
for k in (0..self.dims[2]).step_by(2) {
|
||
z.push(pos[self.lattice_node(0, j, k).expect("root node")][2]);
|
||
let row0 = xy.len();
|
||
for i in 0..ns {
|
||
let n = self.lattice_node(i, j, k).expect("mid-layer node");
|
||
xy.push([pos[n][0], pos[n][1]]);
|
||
if let Some(v) = vel {
|
||
vv.push([v[n][0], v[n][1]]);
|
||
}
|
||
}
|
||
if tip_inset > 0.0 {
|
||
let [ax, ay] = xy[row0 + ns - 2];
|
||
let [bx, by] = xy[row0 + ns - 1];
|
||
let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();
|
||
let f = (1.0 - tip_inset / len).max(0.0);
|
||
xy[row0 + ns - 1] = [ax + f * (bx - ax), ay + f * (by - ay)];
|
||
}
|
||
}
|
||
PlateSurface { z, ns, xy, vel: vv }
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn hex20_is_a_partition_of_unity_with_nodal_interpolation() {
|
||
for (a, c) in HEX20.iter().enumerate() {
|
||
let (n, _) = hex20([f64::from(c[0]), f64::from(c[1]), f64::from(c[2])]);
|
||
for (b, v) in n.iter().enumerate() {
|
||
assert!((v - if a == b { 1.0 } else { 0.0 }).abs() < 1e-15);
|
||
}
|
||
}
|
||
let (n, d) = hex20([0.3, -0.7, 0.45]);
|
||
assert!((n.iter().sum::<f64>() - 1.0).abs() < 1e-15);
|
||
for m in 0..3 {
|
||
assert!(d.iter().map(|v| v[m]).sum::<f64>().abs() < 1e-14);
|
||
}
|
||
// Derivatives against central differences.
|
||
let h = 1e-6;
|
||
for m in 0..3 {
|
||
let mut p = [0.3, -0.7, 0.45];
|
||
let mut q = p;
|
||
p[m] += h;
|
||
q[m] -= h;
|
||
let (np, _) = hex20(p);
|
||
let (nq, _) = hex20(q);
|
||
for a in 0..20 {
|
||
assert!(((np[a] - nq[a]) / (2.0 * h) - d[a][m]).abs() < 1e-8);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layout_matches_the_structure() {
|
||
let p = HexPlate::new(35, 2, 10);
|
||
// Serendipity count: (2nx+1)(2ny+1)(2nz+1) minus the points with ≥ 2 odd.
|
||
let full = 71 * 5 * 21;
|
||
let two_odd = 35 * 2 * 21 + 35 * 5 * 10 + 71 * 2 * 10 - 2 * 35 * 2 * 10;
|
||
assert_eq!(p.node_count(), full - two_odd);
|
||
assert_eq!(p.elements().len(), 35 * 2 * 10);
|
||
// Node 0 is (0,0,0), the next along the span.
|
||
assert_eq!(p.lattice_of(0), [0, 0, 0]);
|
||
assert_eq!(p.lattice_of(1), [0, 0, 1]);
|
||
assert_eq!(p.elements()[0][1], p.lattice_node(2, 0, 0).unwrap());
|
||
}
|
||
|
||
/// A bent, twisted plate: arbitrary point loads inside and just outside
|
||
/// are distributed with the total force and moment conserved to
|
||
/// round-off.
|
||
#[test]
|
||
fn transfer_conserves_force_and_moment() {
|
||
let plate = HexPlate::new(35, 2, 10);
|
||
let bend = |s: f64, zeta: f64| 0.06 * s * s * (1.0 + 0.5 * (2.0 * zeta - 1.0));
|
||
let pos = plate.place(|s, eta, zeta| {
|
||
let x = 0.25 + 0.35 * s;
|
||
let y = 0.2 + bend(s, zeta) + 0.01 * eta;
|
||
[x, y, 0.105 + 0.2 * zeta]
|
||
});
|
||
let mut loads = Vec::new();
|
||
let mut state = 12345u64;
|
||
let mut rnd = || {
|
||
state = state
|
||
.wrapping_mul(6364136223846793005)
|
||
.wrapping_add(1442695040888963407);
|
||
(state >> 11) as f64 / (1u64 << 53) as f64
|
||
};
|
||
for _ in 0..3000 {
|
||
let (s, zeta, eta) = (rnd(), rnd(), 2.4 * rnd() - 1.2);
|
||
let p = [
|
||
0.25 + 0.35 * s,
|
||
0.2 + bend(s, zeta) + 0.01 * eta,
|
||
0.105 + 0.2 * zeta,
|
||
];
|
||
loads.push((p, [rnd() - 0.5, 10.0 * (rnd() - 0.5), 0.1 * (rnd() - 0.5)]));
|
||
}
|
||
let tr = plate.transfer(&pos, &loads, [0.25, 0.2, 0.205]);
|
||
let scale_f = loads.iter().map(|l| norm(l.1)).sum::<f64>();
|
||
for c in 0..3 {
|
||
assert!((tr.force_in[c] - tr.force_out[c]).abs() < 1e-13 * scale_f);
|
||
assert!((tr.moment_in[c] - tr.moment_out[c]).abs() < 1e-13 * scale_f);
|
||
}
|
||
assert!(tr.max_residual < 1e-14);
|
||
assert!(tr.extrapolated > 0, "the ±1.2 band reaches outside");
|
||
}
|
||
|
||
/// The mid-surface of the deformed Hex plate, thickened by the half
|
||
/// thickness, passes through the plate's face nodes (φ ≈ 0 there): the
|
||
/// motion side's geometry and the structure agree.
|
||
#[test]
|
||
fn mid_surface_passes_the_face_nodes() {
|
||
use super::super::body::DeviceSdf;
|
||
let plate = HexPlate::new(35, 2, 10);
|
||
let half = 0.01;
|
||
let bend = |s: f64, zeta: f64| 0.08 * s * s * (1.0 + 0.3 * (2.0 * zeta - 1.0));
|
||
let pos = plate.place(|s, eta, zeta| {
|
||
// Offsets along the 3D normal of the mid-surface y = w(x, z).
|
||
let ds = 1e-7;
|
||
let (x, y) = (0.25 + 0.35 * s, 0.2 + bend(s, zeta));
|
||
let wx = (bend(s + ds, zeta) - bend(s - ds, zeta)) / (2.0 * ds) / 0.35;
|
||
let wz = (bend(s, zeta + ds) - bend(s, zeta - ds)) / (2.0 * ds) / 0.2;
|
||
let r = (1.0 + wx * wx + wz * wz).sqrt();
|
||
[
|
||
x - half * eta * wx / r,
|
||
y + half * eta / r,
|
||
0.105 + 0.2 * zeta - half * eta * wz / r,
|
||
]
|
||
});
|
||
let surf = plate.mid_surface(&pos, None, 0.0);
|
||
surf.validate().unwrap();
|
||
let sdf = DeviceSdf {
|
||
cyl: [-10.0, -10.0, 0.05],
|
||
cyl_cut: false,
|
||
flag_cut: false,
|
||
zc: 0.205,
|
||
span: 0.2,
|
||
r_edge: 0.0,
|
||
half,
|
||
fillet: 0.0,
|
||
poly: Vec::new(),
|
||
vel: Vec::new(),
|
||
plate: Some(surf),
|
||
};
|
||
let mut worst = 0.0f64;
|
||
for n in 0..plate.node_count() {
|
||
let [i, j, _] = plate.lattice_of(n);
|
||
if (j == 0 || j == 4) && i > 1 && i < 69 {
|
||
let p = pos[n];
|
||
worst = worst.max(sdf.phi_host(p[0], p[1], p[2]).abs());
|
||
}
|
||
}
|
||
// Measured 4.5e-7 m (the mid-surface's 5 mm chords, the first-order
|
||
// slope correction) against the 10 mm half-thickness.
|
||
assert!(
|
||
worst < 2e-6,
|
||
"face nodes off the level set by {worst:.3e} m"
|
||
);
|
||
}
|
||
}
|