Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/overset.rs
T
Omar SobhandClaude Fable 5.1 c3dbd5040a
CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build (ubuntu-latest) (push) Failing after 7s
CI / Format Check (push) Failing after 17s
Documentation / Build User Guide (push) Successful in 19s
Documentation / Build API Documentation (push) Failing after 1m51s
CI / Build CPU-Only (Explicit) (push) Failing after 1m58s
CI / Clippy Check (push) Failing after 2m13s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m54s
P6-b design 2: the Robin wall on the patch's Inner side — RobinWall { alpha, datum } with the wall velocity u_s + (t_f − datum)/α (explicit, damped by the wall's own viscous gain μ/(α d)) and the compliant-wall pressure term |S| p'/α implicit in the projection (the added-mass operator in the fluid's own response); Dirichlet bit for bit when off; MMS pin on the skewed annulus (orders 2.46/2.13 at α = 10 and 100 μ/h, the Dirichlet values; a 1e12 wall reproduces Dirichlet to 2e-6); OversetPisoSolver::patch_mut; harness knob RTX_FSI2O_ROBIN_ALPHA with the previous pass's tractions as the datum, printed in the header
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 10:46:35 -05:00

1132 lines
42 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::PatchSide;
use rtx_cfd::mesh::patch_gen::cylinder_flag_patch_deformed_tip;
use rtx_cfd::solvers::incompressible::{
AleBoundaries, ConvectionScheme, CurvilinearParameters, CurvilinearPisoSolver,
EmbeddedParameters, EmbeddedPisoSolver, FlowField, MgPrecision, NormalDiffusion, OversetField,
OversetParameters, OversetPisoSolver, OversetResult, OversetSolverState, PatchConvection,
PatchField, PoissonSolverKind, RobinWall, SideBoundary,
};
use rtx_cfd::{CfdConfig, CfdResult};
use rtx_fea::mesh::{Mesh, NodeId};
use rtx_fsi::{FluidFace, WettedSurface};
use super::{BenchmarkCase, FLAG_X1, H, Interface, L, NU_F, RHO_F, flag_mesh, inflow_for};
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 tip's corner radius (`RTX_FSI2O_TIP_CORNER`, metres; the recorded
/// outline is the full semicircle, corner = t = 0.01): P5-3 option B — the
/// benchmark's flat tip with rounded corners (2.5 mm) against the
/// semicircle, gated on the lift phase and the per-period amplitude.
pub fn tip_corner() -> f64 {
std::env::var("RTX_FSI2O_TIP_CORNER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(FLAG_T)
}
/// The outline's fillet radius (`RTX_FSI2O_FILLET`, metres; 5 mm = t/2 in
/// every recorded run, so the tip is a full half-round and the root
/// carries 5 mm fillets — the reference's flag is a sharp rectangle).
/// P5-3's problem-definition probe: the tip's rounding sets how the flag
/// sheds, i.e. the wake's strength, which the h-ladder says is the
/// excitation that keeps growing.
pub fn fillet() -> f64 {
std::env::var("RTX_FSI2O_FILLET")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(FILLET)
}
/// The patch's across stretch (`RTX_FSI2O_PATCH_STRETCH`, the geometric
/// ratio of the outer to the wall cell, 4.0 in every recorded run): with
/// 12 rows over 6 h the wall cell is 6 h (r 1)/(r¹² 1), r¹¹ = stretch —
/// 0.227 h / 0.144 h / 0.088 h at 4 / 8 / 16 — P5-3's wall-normal
/// resolution knob at fixed h (the rows ladder does not build: the overlap
/// needs outer cells ≈ h/2 or larger).
pub fn patch_stretch() -> f64 {
std::env::var("RTX_FSI2O_PATCH_STRETCH")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(PATCH_STRETCH)
}
/// The background's convection scheme (`RTX_FSI2O_BG_CONVECTION`: `tvd`
/// (default, van Albada), `upwind`) — P5-3's wake-dissipation knob on the
/// fixed-motion replay (the patch's scheme was excluded as the h-term's
/// carrier: both ladders move +1.51.7 W/m from ny 41 to 62).
pub fn bg_convection() -> ConvectionScheme {
match std::env::var("RTX_FSI2O_BG_CONVECTION").as_deref() {
Ok("upwind") => ConvectionScheme::Upwind,
_ => ConvectionScheme::TvdVanAlbada,
}
}
/// The patch's convection scheme (`RTX_FSI2O_PATCH_CONVECTION`: `tvd`
/// (default, van Albada), `upwind`, `none`) — P5-3's near-wake dispersion
/// knob on the fixed-motion replay.
pub fn patch_convection() -> PatchConvection {
match std::env::var("RTX_FSI2O_PATCH_CONVECTION").as_deref() {
Ok("upwind") => PatchConvection::Upwind,
Ok("none") => PatchConvection::None,
_ => PatchConvection::TvdVanAlbada,
}
}
/// 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: bg_convection(),
},
)?;
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_tip(
CYL_CENTRE,
CYL_R,
FLAG_T,
&interface.edges(&zero_d),
FLAG_X1,
h,
fillet(),
patch_offset_h() * h,
patch_rows(),
patch_stretch(),
sweeps,
tip_corner(),
)?;
// 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_tip(
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,
tip_corner(),
)?;
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: patch_convection(),
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_tip(
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
},
tip_corner(),
)?;
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(())
}
/// The fluid's traction on every Inner (wall) face, in the patch's
/// Inner-face order — the Robin wall's datum for the next pass.
pub fn inner_tractions(&self) -> Vec<[f64; 2]> {
self.solver
.patch()
.wall_tractions(&self.field.patch, PatchSide::Inner, self.solver.time())
.into_iter()
.map(|(_, _, _, t)| t)
.collect()
}
/// Put the Robin wall (impedance `alpha`, datum per Inner face) on the
/// patch, or remove it.
pub fn set_robin(&mut self, alpha: f64, datum: Option<Vec<[f64; 2]>>) {
self.solver
.patch_mut()
.set_robin_wall(datum.map(|d| RobinWall { alpha, datum: d }));
}
/// 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()
}
}