Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs
T
Omar SobhandClaude Fable 5.1 7e13d14e96
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
FSI2 overset harness: RTX_FSI2O_DT_SCALE scales the CFL-derived fluid step — P5-3's dt-convergence probe of the first-order Euler fluid steps (the band's placement is excluded: 94.0 mm at a 60 mm patch vs 94.8 at 40 mm; the s = 8 pair moved the amplitude 95 → 60 mm)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0116sg1Qz1gMv9hdcKP1XUam
2026-09-09 06:41:08 -07:00

1052 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! P5 (`docs/overset_metal_campaign.md` §5.12): the FSI2 harness's FLUID
//! SIDE on the overset — the background without a body, the cylinderflag
//! O-grid regenerated around the deformed flag every time the interface
//! moves (`set_patch_mesh`: the overlap rebuilt, fresh cells refilled, the
//! fringe re-stamped, the flux balance on), the patch's wall velocity from
//! the interface velocities (nearest wetted segment, linear along it), and
//! the load from the patch's wall faces (pressure + full-stress traction
//! per face into `WettedSurface::transfer_load`) — no probes, no spike
//! clamp, no smoothing. The structure side is the harness's, unchanged.
use std::cell::Cell;
use std::sync::{Arc, RwLock};
use nalgebra::Vector3;
use rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed;
use rtx_cfd::mesh::PatchSide;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, CurvilinearParameters, CurvilinearPisoSolver,
EmbeddedParameters, EmbeddedPisoSolver, FlowField, MgPrecision, NormalDiffusion, OversetField,
OversetParameters, OversetPisoSolver, OversetResult, OversetSolverState, PatchConvection,
PatchField, PoissonSolverKind, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
use rtx_fea::mesh::{Mesh, NodeId};
use rtx_fsi::{FluidFace, WettedSurface};
use super::{flag_mesh, inflow_for, BenchmarkCase, Interface, FLAG_X1, H, L, NU_F, RHO_F};
const CYL_CENTRE: [f64; 2] = [0.2, 0.2];
const CYL_R: f64 = 0.05;
const FLAG_T: f64 = 0.01;
/// The junction fillet: a fixed 5 mm at every resolution (§5.11).
const FILLET: f64 = 0.5 * 0.41 / 41.0;
const PATCH_ROWS: usize = 12;
const PATCH_STRETCH: f64 = 4.0;
/// The patch's thickness in units of h (`RTX_FSI2O_PATCH_OFFSET`, 6).
/// At the default the overlap band sits a fixed NUMBER of cells from the
/// wall and moves inward in metres with refinement (ny 41 / 62 / 82 →
/// 60 / 40 / 30 mm); P5-3 holds it in metres across the ladder instead.
fn patch_offset_h() -> f64 {
std::env::var("RTX_FSI2O_PATCH_OFFSET")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(6.0)
}
/// The patch's across-rows (`RTX_FSI2O_PATCH_ROWS`, 12; scale it with the
/// offset to keep the wall spacing).
fn patch_rows() -> usize {
std::env::var("RTX_FSI2O_PATCH_ROWS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(PATCH_ROWS)
}
/// The save tag: `fsi2o_ny{ny}`, plus `_off{offset}` off the default
/// patch thickness so a thicker patch never loads the default's state.
fn save_tag(ny: usize) -> String {
let offset = patch_offset_h();
if (offset - 6.0).abs() < 1e-12 {
format!("fsi2o_ny{ny}")
} else {
format!("fsi2o_ny{ny}_off{offset}")
}
}
/// The deforming wall as the patch sees it: the wetted polygon (the
/// `Interface` walk, anchors included) and the velocity at each vertex.
#[derive(Debug, Clone, Default)]
pub struct WallMotion {
pub polygon: Vec<[f64; 2]>,
pub velocity: Vec<[f64; 2]>,
/// The net normal velocity removed from every segment (the imposed
/// velocity's volume flux over the wall length) when the wall is
/// made volume-preserving; zero otherwise.
pub q: f64,
}
impl WallMotion {
/// The imposed velocity's net volume flux INTO the fluid over the
/// polygon (counter-clockwise around the body: outward normal
/// `(dy, dx)/L`), and the polygon's length.
pub fn net_flux(&self) -> (f64, f64) {
let (mut flux, mut len) = (0.0, 0.0);
for i in 0..self.polygon.len().saturating_sub(1) {
let (a, b) = (self.polygon[i], self.polygon[i + 1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let (va, vb) = (self.velocity[i], self.velocity[i + 1]);
let (vx, vy) = (0.5 * (va[0] + vb[0]), 0.5 * (va[1] + vb[1]));
flux += vx * dy - vy * dx;
len += (dx * dx + dy * dy).sqrt();
}
(flux, len)
}
/// The polygon's enclosed area (shoelace; the walk is closed across
/// the cylinder by its anchors, so this is the flag's area up to a
/// constant).
pub fn area(&self) -> f64 {
let n = self.polygon.len();
let mut a = 0.0;
for i in 0..n {
let (p, q) = (self.polygon[i], self.polygon[(i + 1) % n]);
a += p[0] * q[1] - q[0] * p[1];
}
0.5 * a
}
}
impl WallMotion {
/// Velocity at `(x, y)`: linear along the nearest polygon segment.
pub fn velocity_at(&self, x: f64, y: f64) -> (f64, f64) {
let n = self.polygon.len();
if n < 2 {
return (0.0, 0.0);
}
let (mut best_d, mut best) = (f64::INFINITY, (0.0, 0.0));
for i in 0..n - 1 {
let (a, b) = (self.polygon[i], self.polygon[i + 1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 {
(((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0)
} else {
0.0
};
let (px, py) = (a[0] + t * dx, a[1] + t * dy);
let d = (px - x).powi(2) + (py - y).powi(2);
if d < best_d {
best_d = d;
let (va, vb) = (self.velocity[i], self.velocity[i + 1]);
let l = l2.sqrt().max(1e-300);
best = (
va[0] + t * (vb[0] - va[0]) - self.q * dy / l,
va[1] + t * (vb[1] - va[1]) + self.q * dx / l,
);
}
}
best
}
}
/// The overset fluid of the coupled march.
pub struct OversetFluid {
pub case: BenchmarkCase,
pub mesh: Mesh,
pub interface: Interface,
pub a_node: NodeId,
pub ny: usize,
pub nx: usize,
pub h: f64,
pub mu: f64,
pub dt_fluid: f64,
pub solver: OversetPisoSolver,
pub field: OversetField,
pub shared: Arc<RwLock<WallMotion>>,
pub sweeps: usize,
/// Patch regenerations and their wall time.
pub regen_count: Cell<usize>,
pub regen_seconds: Cell<f64>,
/// Background cells reclassified, summed over every fluid step.
pub reclassified_total: Cell<usize>,
pub fresh_total: Cell<usize>,
pub rounds_total: Cell<usize>,
pub correctors_total: Cell<usize>,
/// The interface of the last `set_geometry`.
pub last_d: Vec<f64>,
/// The committed step's patch, the warm start of every regeneration
/// in the next step (`RTX_FSI2O_WARM_SWEEPS`); `None` = cold builds.
pub warm_base: Option<rtx_cfd::mesh::PatchMesh>,
pub warm_sweeps: usize,
/// The last fluid step's mass defects (patch acceptor ring, background
/// fringe) and the patch's max divergence.
pub last_defects: Cell<(f64, f64, f64)>,
}
impl OversetFluid {
/// Build the composite at rest around the undeformed flag.
pub fn build_case(
case: BenchmarkCase,
ny: usize,
flag_nx: usize,
sweeps: usize,
max_rounds: usize,
) -> CfdResult<Self> {
Self::build_case_with(case, ny, flag_nx, sweeps, max_rounds, None)
}
/// [`Self::build_case`] with an initial patch mesh given (a saved
/// instant's deformed patch: the overlap is built around it).
pub fn build_case_with(
case: BenchmarkCase,
ny: usize,
flag_nx: usize,
sweeps: usize,
max_rounds: usize,
initial: Option<rtx_cfd::mesh::PatchMesh>,
) -> CfdResult<Self> {
let h = H / ny as f64;
let nx = (L / h).round() as usize;
let mu = RHO_F * NU_F;
let u_mean = case.u_mean;
let u_peak = 1.5 * 1.5 * u_mean;
let mesh = flag_mesh(flag_nx, 2);
let interface = Interface::build(&mesh);
let a_node = mesh
.nodes
.iter()
.find(|(_, n)| {
(n.position().x - 0.6).abs() < 1e-9 && (n.position().y - 0.2).abs() < 1e-9
})
.map(|(&id, _)| id)
.expect("point A");
let zero_d = vec![0.0; 2 * interface.wetted.len()];
let config = CfdConfig::new()
.with_density(RHO_F)
.with_viscosity(mu)
.with_reference_velocity(u_mean)
.with_reference_length(0.1);
let mut background = EmbeddedPisoSolver::new(
config.clone(),
EmbeddedParameters {
corrector_steps: 2,
tolerance: 1e-7,
boundaries: AleBoundaries {
left: SideBoundary::Velocity,
right: SideBoundary::PressureOutlet,
bottom: SideBoundary::Velocity,
top: SideBoundary::Velocity,
},
poisson_solver: PoissonSolverKind::Multigrid,
poisson_precision: MgPrecision::F64,
convection_scheme: ConvectionScheme::TvdVanAlbada,
},
)?;
background.set_boundary_velocity(move |x, y, t| {
if x <= 0.0 {
(inflow_for(u_mean, y, t), 0.0)
} else {
(0.0, 0.0)
}
});
let (cold_mesh, _) = cylinder_flag_patch_deformed(
CYL_CENTRE,
CYL_R,
FLAG_T,
&interface.edges(&zero_d),
FLAG_X1,
h,
FILLET,
patch_offset_h() * h,
patch_rows(),
PATCH_STRETCH,
sweeps,
)?;
// With the warm-started regeneration (`RTX_FSI2O_WARM_SWEEPS`), the
// march starts on the converged fixed point of the warm build's
// sweep + re-spacing map, so the chain starts where it stays.
let warm_sweeps: usize = std::env::var("RTX_FSI2O_WARM_SWEEPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let start_mesh = if warm_sweeps > 0 {
let t0 = std::time::Instant::now();
let (m, (it, last)) = rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed_from(
Some(&cold_mesh),
CYL_CENTRE,
CYL_R,
FLAG_T,
&interface.edges(&zero_d),
FLAG_X1,
h,
FILLET,
patch_offset_h() * h,
patch_rows(),
PATCH_STRETCH,
20_000,
)?;
println!(
" warm base: {it} sweep+respace iterations (last move {last:.1e}) in {:.1} s; {warm_sweeps} per regeneration",
t0.elapsed().as_secs_f64()
);
m
} else {
cold_mesh
};
// The fluid step is the march's: from the mesh the march started
// on, whatever patch this composite is built around (a saved
// deformed instant) — 0.2 % of dt read as 0.35 N/m of solved-face
// residual in the audit before this.
let mut hs = f64::INFINITY;
for c in 0..start_mesh.cell_count() {
for (f, _) in start_mesh.cell_faces(c) {
if start_mesh.is_sface(f) {
let d = start_mesh.faces()[f].d;
hs = hs.min((d[0] * d[0] + d[1] * d[1]).sqrt());
}
}
}
let dt_bg = 0.25 / (2.0 * u_peak / h + 4.0 * NU_F / (h * h));
let dt_patch = 0.4 * (hs * hs / (4.0 * NU_F)).min(hs / u_peak);
// `RTX_FSI2O_DT_SCALE` (1): the fluid step scaled off its CFL-
// derived value — P5-3's dt-convergence probe of the first-order
// Euler steps (the s = 8 pair moved the amplitude 95 → 60 mm).
let dt_scale: f64 = std::env::var("RTX_FSI2O_DT_SCALE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
let dt_fluid = dt_bg.min(dt_patch) * dt_scale;
let patch_mesh = initial.unwrap_or(start_mesh);
let shared = Arc::new(RwLock::new(WallMotion {
polygon: interface
.polygon(&zero_d)
.iter()
.map(|&(x, y)| [x, y])
.collect(),
velocity: vec![[0.0, 0.0]; interface.walk.len()],
q: 0.0,
}));
let wall = shared.clone();
let mut patch = CurvilinearPisoSolver::new(
config,
CurvilinearParameters {
tolerance: 1e-5,
convection: PatchConvection::TvdVanAlbada,
normal_diffusion: NormalDiffusion::LineImplicit,
..CurvilinearParameters::default()
},
patch_mesh,
)?;
// Discriminators (P5-1 matrix): `RTX_FSI2O_NO_WALL_VEL` keeps the
// wall at rest in the fluid while the geometry moves.
let no_wall_vel = std::env::var("RTX_FSI2O_NO_WALL_VEL").is_ok();
patch.set_side_velocity(PatchSide::Inner, move |x, y, _| {
if no_wall_vel {
(0.0, 0.0)
} else {
wall.read().unwrap().velocity_at(x, y)
}
});
let mut patch_field = PatchField::new(patch.mesh());
patch.initialize(&mut patch_field, |_, _| (0.0, 0.0));
let params = OversetParameters {
stall_rounds: 2,
max_rounds,
// `RTX_FSI2O_ROWS`: the overlap depth (patch rows kept non-hole
// below the acceptor row; default 4). A deeper overlap widens
// the band the acceptors' donors need on a bent patch.
overlap_rows: std::env::var("RTX_FSI2O_ROWS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(OversetParameters::default().overlap_rows),
..OversetParameters::default()
};
let mut solver = OversetPisoSolver::new(background, patch, (nx, ny, h, h), params)?;
let mut field = OversetField {
background: FlowField::new(nx, ny, h, h)?,
patch: patch_field,
};
solver.initialize(&mut field)?;
Ok(Self {
case,
mesh,
interface,
a_node,
ny,
nx,
h,
mu,
dt_fluid,
solver,
field,
shared,
sweeps,
regen_count: Cell::new(0),
regen_seconds: Cell::new(0.0),
reclassified_total: Cell::new(0),
fresh_total: Cell::new(0),
rounds_total: Cell::new(0),
correctors_total: Cell::new(0),
last_d: zero_d,
last_defects: Cell::new((0.0, 0.0, 0.0)),
warm_base: None,
warm_sweeps,
})
}
/// Save the fluid state (`RTX_FSI2O_SAVE=dir`, tag `fsi2o_ny{ny}`):
/// the background as `FlowField::save`, the patch vectors raw, the
/// time.
pub fn save(&self, dir: &str) -> CfdResult<()> {
let dir = std::path::Path::new(dir);
std::fs::create_dir_all(dir).expect("save dir");
let tag = save_tag(self.ny);
self.field
.background
.save(&dir.join(format!("bg_{tag}.bin")))?;
for (name, vals) in [
("u", &self.field.patch.u),
("v", &self.field.patch.v),
("p", &self.field.patch.p),
("flux", &self.field.patch.flux),
] {
let bytes: Vec<u8> = vals.iter().flat_map(|x| x.to_le_bytes()).collect();
std::fs::write(dir.join(format!("patch_{tag}_{name}.bin")), bytes).expect("save patch");
}
std::fs::write(
dir.join(format!("time_{tag}.txt")),
format!("{:.17e}", self.solver.time()),
)
.expect("save time");
println!(
" saved the fluid state {tag} at t = {:.4} to {}",
self.solver.time(),
dir.display()
);
Ok(())
}
/// Save the composite at an instant of the coupled march for the
/// offline chain audit: the background, the patch's node coordinates
/// (the deformed mesh), its vectors, the time and the interface `d`
/// (`dir/inst_<tag>_<step>/`).
pub fn save_instant(&self, dir: &str, step: usize, d: &[f64], ddot: &[f64]) -> CfdResult<()> {
let tag = save_tag(self.ny);
let dir = std::path::Path::new(dir).join(format!("inst_{tag}_{step:06}"));
std::fs::create_dir_all(&dir).expect("instant dir");
self.field.background.save(&dir.join("bg.bin"))?;
let mesh = self.solver.patch().mesh();
let (ns, nn) = (mesh.ns(), mesh.nn());
let mut nodes: Vec<f64> = Vec::with_capacity(2 * (ns + 1) * (nn + 1));
for k in 0..=nn {
for i in 0..=ns {
let p = mesh.node_xy(mesh.node(k, i));
nodes.push(p[0]);
nodes.push(p[1]);
}
}
for (name, vals) in [
("nodes", &nodes),
("u", &self.field.patch.u),
("v", &self.field.patch.v),
("p", &self.field.patch.p),
("flux", &self.field.patch.flux),
("d", &d.to_vec()),
("dd", &ddot.to_vec()),
] {
let bytes: Vec<u8> = vals.iter().flat_map(|x| x.to_le_bytes()).collect();
std::fs::write(dir.join(format!("patch_{name}.bin")), bytes).expect("save");
}
std::fs::write(
dir.join("meta.txt"),
format!(
"t {:.17e}\nns {ns}\nnn {nn}\ndt {:.17e}\n",
self.solver.time(),
self.dt_fluid
),
)
.expect("meta");
Ok(())
}
/// Rebuild the composite on a saved instant (`save_instant`) — the
/// deformed patch from its node coordinates, the fields, the wall
/// motion — and return it with the interface and the time; the chain
/// then runs on it as on any settled state.
pub fn from_instant(
case: BenchmarkCase,
ny: usize,
flag_nx: usize,
dir: &std::path::Path,
) -> CfdResult<(Self, Vec<f64>, f64)> {
let read = |name: &str| -> Vec<f64> {
let bytes = std::fs::read(dir.join(format!("patch_{name}.bin")))
.unwrap_or_else(|e| panic!("instant {name}: {e}"));
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect()
};
let meta = std::fs::read_to_string(dir.join("meta.txt")).expect("meta");
let mut t = 0.0;
let (mut ns, mut nn) = (0usize, 0usize);
let mut dt_saved: Option<f64> = None;
for line in meta.lines() {
let mut it = line.split_whitespace();
match (it.next(), it.next()) {
(Some("t"), Some(v)) => t = v.parse().expect("t"),
(Some("ns"), Some(v)) => ns = v.parse().expect("ns"),
(Some("nn"), Some(v)) => nn = v.parse().expect("nn"),
(Some("dt"), Some(v)) => dt_saved = v.parse().ok(),
_ => {}
}
}
let nodes = read("nodes");
let (xs, ys): (Vec<f64>, Vec<f64>) = nodes.chunks_exact(2).map(|c| (c[0], c[1])).unzip();
let mesh = rtx_cfd::mesh::PatchMesh::from_nodes(ns, nn, xs, ys, Some([0.0, 0.0]))?;
// The composite around the deformed patch: built from scratch so
// the overlap is the instant's.
let mut fluid = Self::build_case_with(case, ny, flag_nx, 100, 3, Some(mesh))?;
let d = read("d");
let dd = read("dd");
{
let mut w = fluid.shared.write().unwrap();
w.polygon = fluid
.interface
.polygon(&d)
.iter()
.map(|&(x, y)| [x, y])
.collect();
w.velocity = fluid
.interface
.walk_velocities(&dd)
.iter()
.map(|&(u, v)| [u, v])
.collect();
}
fluid.field.background = FlowField::load(&dir.join("bg.bin"))?;
fluid.field.patch.u = read("u");
fluid.field.patch.v = read("v");
fluid.field.patch.p = read("p");
fluid.field.patch.flux = read("flux");
fluid.solver.set_time(t);
fluid.last_d = d.clone();
if let Some(dt) = dt_saved {
fluid.dt_fluid = dt;
}
Ok((fluid, d, t))
}
/// The wall load by REGION at the current state (P5-3): for the top
/// face, the bottom face, the tip arc, and the fillets + cylinder —
/// `(name, faces, length, drag, lift, mean t_n, min t_n, max t_n)`
/// with `t_n` the normal traction (≈ p at the wall), and the patch's
/// pressure level.
pub fn wall_regions(
&self,
d: &[f64],
) -> (
Vec<(&'static str, usize, f64, f64, f64, f64, f64, f64)>,
f64,
) {
let e = self.interface.edges(d);
let tip_mid = [
0.5 * (e.tip[0][0] + e.tip[e.tip.len() - 1][0]),
0.5 * (e.tip[0][1] + e.tip[e.tip.len() - 1][1]),
];
let nearest = |pts: &[[f64; 2]], q: [f64; 2]| -> f64 {
pts.iter()
.map(|p| (p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2))
.fold(f64::INFINITY, f64::min)
};
let mut acc: Vec<(&'static str, usize, f64, f64, f64, f64, f64, f64)> = vec![
(
"top",
0,
0.0,
0.0,
0.0,
0.0,
f64::INFINITY,
f64::NEG_INFINITY,
),
(
"bottom",
0,
0.0,
0.0,
0.0,
0.0,
f64::INFINITY,
f64::NEG_INFINITY,
),
(
"tip arc",
0,
0.0,
0.0,
0.0,
0.0,
f64::INFINITY,
f64::NEG_INFINITY,
),
(
"fillets+cyl",
0,
0.0,
0.0,
0.0,
0.0,
f64::INFINITY,
f64::NEG_INFINITY,
),
];
for (centre, normal, len, traction) in self.solver.patch().wall_tractions(
&self.field.patch,
PatchSide::Inner,
self.solver.time(),
) {
let on_cyl =
((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)).sqrt()
< CYL_R + 1.5 * FILLET;
let near_tip = ((centre[0] - tip_mid[0]).powi(2) + (centre[1] - tip_mid[1]).powi(2))
.sqrt()
< 2.5 * FLAG_T;
let k = if on_cyl {
3
} else if near_tip {
2
} else if nearest(&e.top, centre) <= nearest(&e.bottom, centre) {
0
} else {
1
};
let tn = traction[0] * normal[0] + traction[1] * normal[1];
let r = &mut acc[k];
r.1 += 1;
r.2 += len;
r.3 += traction[0] * len;
r.4 += traction[1] * len;
r.5 += tn * len;
r.6 = r.6.min(tn);
r.7 = r.7.max(tn);
}
for r in &mut acc {
if r.2 > 0.0 {
r.5 /= r.2;
}
}
let p = &self.field.patch.p;
let level = p.iter().sum::<f64>() / p.len().max(1) as f64;
(acc, level)
}
/// The solver-metric momentum chain at the current state (§5.11's
/// instrument on the moving patch): the solved-face pin, the ring, the
/// box in the solver's flux form, the patch's balance, the wall.
pub fn chain_line(&self) -> String {
let dt = self.dt_fluid;
let h = self.h;
let (nx, ny) = (self.nx, self.ny);
let cv = (
(0.10 / h).round() as usize,
(0.75 / h).round() as usize,
(0.05 / h).round() as usize,
(0.36 / h).round() as usize,
);
let _ = (nx, ny);
let wall = self.measure_force();
let mr = self.solver.momentum_residual(&self.field, dt);
let ring = mr.fringe_fringe.fx + mr.fringe_hole.fx;
let bx = self.solver.solver_metric_force(&self.field, dt, cv).0;
let bx2 = self
.solver
.solver_metric_force(
&self.field,
dt,
(
(0.09 / h).round() as usize,
(0.70 / h).round() as usize,
(0.07 / h).round() as usize,
(0.34 / h).round() as usize,
),
)
.0;
let pb = self
.solver
.patch()
.momentum_balance(&self.field.patch, self.solver.time());
let ff = pb.flux_force()[0];
let fw = pb.wall_force()[0];
format!(
"solved far Σ|r| ({:.2e}, {:.2e}) {}/{} | near ring Σr ({:+.2e}, {:+.2e}) | box (solver flux form) {:.3} [2 boxes spread {:.1e}] → ring Σr {:+.3} ({} + {} faces of {} + {}) → hole flux {:.3} → band {:+.3} → patch interface {:.3} → interior {:+.3} (δP {:+.3}, balance residual {:+.3}) → wall, scheme fluxes {:.3} → wall formula {:+.3} → wall ({:.3}, {:.3}); total wall box {:+.3} ({:+.2} %); reclassified so far {}",
mr.solved_far.abs_x,
mr.solved_far.abs_y,
mr.solved_far.evaluated,
mr.solved_far.total,
mr.solved_near.fx,
mr.solved_near.fy,
bx,
(bx - bx2).abs(),
ring,
mr.fringe_fringe.evaluated,
mr.fringe_hole.evaluated,
mr.fringe_fringe.total,
mr.fringe_hole.total,
bx + ring,
ff - (bx + ring),
ff,
fw - ff,
pb.pressure_defect()[0],
pb.balance()[0],
fw,
wall.0 - fw,
wall.0,
wall.1,
wall.0 - bx,
100.0 * (wall.0 - bx) / wall.0,
self.reclassified_total.get()
)
}
/// Load a state saved by [`Self::save`] (same ny, undeformed patch);
/// returns its time.
pub fn load(&mut self, dir: &str) -> CfdResult<f64> {
let dir = std::path::Path::new(dir);
let tag = save_tag(self.ny);
self.field.background = FlowField::load(&dir.join(format!("bg_{tag}.bin")))?;
let read = |name: &str| -> Vec<f64> {
let bytes = std::fs::read(dir.join(format!("patch_{tag}_{name}.bin")))
.unwrap_or_else(|e| panic!("load patch {name}: {e}"));
bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect()
};
self.field.patch.u = read("u");
self.field.patch.v = read("v");
self.field.patch.p = read("p");
self.field.patch.flux = read("flux");
let t: f64 = std::fs::read_to_string(dir.join(format!("time_{tag}.txt")))
.expect("time")
.trim()
.parse()
.expect("time value");
self.solver.set_time(t);
println!(
" loaded the fluid state {tag} at t = {t:.4} from {}",
dir.display()
);
Ok(t)
}
/// The patch around the interface `d`.
pub fn patch_for(&self, d: &[f64]) -> CfdResult<rtx_cfd::mesh::PatchMesh> {
let start = std::time::Instant::now();
let (mesh, _) = rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed_from(
if self.warm_sweeps > 0 {
self.warm_base.as_ref()
} else {
None
},
CYL_CENTRE,
CYL_R,
FLAG_T,
&self.interface.edges(d),
FLAG_X1,
self.h,
FILLET,
patch_offset_h() * self.h,
patch_rows(),
PATCH_STRETCH,
if self.warm_sweeps > 0 && self.warm_base.is_some() {
self.warm_sweeps
} else {
self.sweeps
},
)?;
self.regen_count.set(self.regen_count.get() + 1);
self.regen_seconds
.set(self.regen_seconds.get() + start.elapsed().as_secs_f64());
Ok(mesh)
}
/// The wall for the next fluid step: geometry `d`, velocity `ddot`.
pub fn set_geometry(&mut self, d: &[f64], ddot: &[f64]) -> CfdResult<()> {
{
let mut w = self.shared.write().unwrap();
w.polygon = self
.interface
.polygon(d)
.iter()
.map(|&(x, y)| [x, y])
.collect();
w.velocity = self
.interface
.walk_velocities(ddot)
.iter()
.map(|&(u, v)| [u, v])
.collect();
// `RTX_FSI2O_VOLUME_PRESERVE`: the fluid sees a volume-preserving
// wall — the imposed velocity's net flux (the flag's thickness
// breathing under the pressure step, a ~300 Hz mode of the
// ν = 0.4 solid) is removed uniformly along the wall.
w.q = 0.0;
if std::env::var("RTX_FSI2O_VOLUME_PRESERVE").is_ok() {
let (flux, len) = w.net_flux();
w.q = flux / len.max(1e-300);
}
}
// `RTX_FSI2O_FREEZE_PATCH`: the undeformed patch throughout (the
// wall velocity still moves) — is the regeneration the driver?
if std::env::var("RTX_FSI2O_FREEZE_PATCH").is_ok() {
self.last_d = d.to_vec();
return Ok(());
}
let mesh = match self.patch_for(d) {
Ok(m) => m,
Err(e) => {
self.dump_edges(d, &format!("generator refused ({e:?})"));
return Err(e);
}
};
self.last_d = d.to_vec();
self.solver.set_patch_mesh(mesh)
}
/// The last geometry, for the offline reproduction
/// (`patch_cylinder_flag_deformed.rs`, `RTX_CF_EDGES_FILE`): one edge
/// per block, `x y` per line, when `RTX_FSI2O_DUMP_DIR` is set.
fn dump_edges(&self, d: &[f64], why: &str) {
let Ok(dir) = std::env::var("RTX_FSI2O_DUMP_DIR") else {
return;
};
let e = self.interface.edges(d);
let mut out = String::new();
for (name, pts) in [("bottom", &e.bottom), ("tip", &e.tip), ("top", &e.top)] {
out.push_str(&format!("# {name} {}\n", pts.len()));
for p in pts {
out.push_str(&format!("{:.17e} {:.17e}\n", p[0], p[1]));
}
}
let path = std::path::Path::new(&dir).join("p5_death_edges.txt");
std::fs::write(&path, out).expect("dump edges");
println!(" {why}; edges dumped to {}", path.display());
}
/// One fluid step at the current wall.
pub fn step(&mut self) -> CfdResult<OversetResult> {
let r = match futures::executor::block_on(
self.solver.advance(&mut self.field, self.dt_fluid),
) {
Ok(r) => r,
Err(e) => {
// The overlap is rebuilt inside `advance`: a refused patch
// surfaces here, with the last geometry.
let d = self.last_d.clone();
self.dump_edges(&d, &format!("advance refused ({e:?})"));
return Err(e);
}
};
self.last_defects.set((
r.patch_mass_defect,
r.background_mass_defect,
r.patch_max_divergence,
));
self.reclassified_total
.set(self.reclassified_total.get() + r.reclassified_cells);
self.fresh_total.set(self.fresh_total.get() + r.fresh_cells);
self.rounds_total
.set(self.rounds_total.get() + r.rounds.iter().sum::<usize>());
self.correctors_total
.set(self.correctors_total.get() + r.rounds.len());
Ok(r)
}
/// `subcycle` fluid substeps from the current state with the interface
/// interpolated from `d_n` to `d_candidate` (the harness's
/// `advance_subcycled`, digit for digit in the kinematics).
pub fn advance_subcycled(
&mut self,
d_n: &[f64],
d_candidate: &[f64],
subcycle: usize,
v_n: Option<&[f64]>,
) -> CfdResult<()> {
let dt = self.dt_fluid * subcycle as f64;
let mean_velocity: Vec<f64> = d_candidate
.iter()
.zip(d_n)
.map(|(new, old)| (new - old) / dt)
.collect();
for m in 1..=subcycle {
let fraction = m as f64 / subcycle as f64;
let (d_sub, ddot_sub): (Vec<f64>, Vec<f64>) = match v_n {
None => (
d_n.iter()
.zip(d_candidate)
.map(|(old, new)| old + fraction * (new - old))
.collect(),
mean_velocity.clone(),
),
Some(v_start) => {
let mut d_sub = Vec::with_capacity(d_n.len());
let mut ddot_sub = Vec::with_capacity(d_n.len());
for k in 0..d_n.len() {
let v_end = 2.0 * mean_velocity[k] - v_start[k];
let accel = (v_end - v_start[k]) / dt;
let tau = fraction * dt;
d_sub.push(d_n[k] + v_start[k] * tau + 0.5 * accel * tau * tau);
ddot_sub.push(v_start[k] + accel * tau);
}
(d_sub, ddot_sub)
}
};
self.set_geometry(&d_sub, &ddot_sub)?;
self.step()?;
}
Ok(())
}
/// Drag and lift on cylinder + flag from the patch's wall stress.
pub fn measure_force(&self) -> (f64, f64) {
let f = self
.solver
.patch()
.surface_force(&self.field.patch, PatchSide::Inner, self.solver.time())
.total();
(f[0], f[1])
}
/// The wall faces' tractions transferred to the flag's wetted nodes
/// at geometry `d`: `(nodal forces, conservation defect, faces used)`.
/// Faces on the cylinder proper are skipped; the fillets' load goes
/// to the nearest (clamped) root nodes.
pub fn sample_load(&self, d: &[f64]) -> (Vec<(NodeId, Vector3<f64>)>, f64, usize) {
let mut faces = Vec::new();
let mut tractions: Vec<Vector3<f64>> = Vec::new();
for (centre, normal, len, traction) in self.solver.patch().wall_tractions(
&self.field.patch,
PatchSide::Inner,
self.solver.time(),
) {
let on_cylinder =
((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)).sqrt()
< CYL_R + 1e-9;
if on_cylinder {
continue;
}
faces.push(FluidFace {
centroid: Vector3::new(centre[0], centre[1], 0.0),
normal: Vector3::new(normal[0], normal[1], 0.0),
area: len,
});
tractions.push(Vector3::new(traction[0], traction[1], 0.0));
}
let nodes_now = self.interface.deformed_nodes(d);
let surface = WettedSurface::build(&faces, &nodes_now).expect("transfer build");
let nodal = surface.transfer_load(&faces, &tractions).unwrap();
let total_sampled: Vector3<f64> =
faces.iter().zip(&tractions).map(|(f, t)| t * f.area).sum();
let total_nodal: Vector3<f64> = nodal.iter().sum();
let conservation = (total_nodal - total_sampled).norm() / total_sampled.norm().max(1e-30);
(
self.interface
.wetted
.iter()
.zip(nodal)
.map(|(&id, f)| (id, f))
.collect(),
conservation,
faces.len(),
)
}
/// The wall pressure's roughness along the flag: over the flag's
/// wall faces in order, `Σ|t_n(f+1) t_n(f)| / Σ|t_n(f)|` with `t_n`
/// the normal traction, and the count of sign changes of the
/// face-to-face difference — a checkerboard reads ≫ 1 with a sign
/// change at every face.
pub fn wall_roughness(&self) -> (f64, usize, f64, f64) {
let mut tn: Vec<f64> = Vec::new();
for (centre, normal, _, traction) in self.solver.patch().wall_tractions(
&self.field.patch,
PatchSide::Inner,
self.solver.time(),
) {
let on_cylinder =
((centre[0] - CYL_CENTRE[0]).powi(2) + (centre[1] - CYL_CENTRE[1]).powi(2)).sqrt()
< CYL_R + 1e-9;
if !on_cylinder {
tn.push(traction[0] * normal[0] + traction[1] * normal[1]);
}
}
let total: f64 = tn.iter().map(|v| v.abs()).sum();
let mut jumps = 0.0;
let mut flips = 0usize;
let mut prev_diff = 0.0;
for w in tn.windows(2) {
let d = w[1] - w[0];
jumps += d.abs();
if prev_diff * d < 0.0 {
flips += 1;
}
prev_diff = d;
}
let max = tn.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
(
jumps / total.max(1e-300),
flips,
max,
total / tn.len().max(1) as f64,
)
}
/// The patch's pressure level (mean over its cells) and the wall's
/// net volume flux from the imposed velocity (`Σ u_wall · S_out` over
/// the Inner faces, positive = fluid leaving the patch through the
/// wall), against the inner ring's enclosed-area rate would be the
/// mesh's own; both must match for a consistent moving wall.
pub fn level_and_wall_flux(&self) -> (f64, f64, f64, f64, f64) {
let mesh = self.solver.patch().mesh();
let p = &self.field.patch.p;
let level = p.iter().sum::<f64>() / p.len().max(1) as f64;
let wall = self.shared.read().unwrap();
let (poly_flux, _) = wall.net_flux();
let poly_area = wall.area();
let (mut flux, mut area) = (0.0, 0.0);
for (f, face) in mesh.faces().iter().enumerate() {
if mesh.side(f) != Some(PatchSide::Inner) {
continue;
}
// Inner side: S points from the body into the fluid (the
// neighbour is the cell), so S is out of the fluid.
let sign = if face.neigh.is_some() { 1.0 } else { -1.0 };
let s_in = [sign * face.s[0], sign * face.s[1]];
let (u, v) = wall.velocity_at(face.centre[0], face.centre[1]);
// Volume flux INTO the fluid = u_wall · S_into_fluid.
flux += u * s_in[0] + v * s_in[1];
area += (s_in[0] * s_in[0] + s_in[1] * s_in[1]).sqrt();
}
(level, flux, area, poly_flux, poly_area)
}
/// The current patch becomes the warm start of the next step's
/// regenerations (called after a committed step).
pub fn commit_base(&mut self) {
if self.warm_sweeps > 0 {
self.warm_base = Some(self.solver.patch().mesh().clone());
}
}
pub fn snapshot(&self) -> (OversetSolverState, OversetField) {
(self.solver.snapshot(), self.field.clone())
}
pub fn restore(&mut self, saved: &(OversetSolverState, OversetField)) {
self.solver.restore(&saved.0);
self.field = saved.1.clone();
}
pub fn time(&self) -> f64 {
self.solver.time()
}
}