rtx-fsi: C3 — FSI3 opened: the added-mass regime, its impulse artefact, and the coupler hygiene it demanded
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (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
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (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
Documentation / Build User Guide (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 / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (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
Documentation / Build User Guide (push) Canceled after 0s
The march is extracted from the FSI2 test into a shared tests/fsi2_harness/march.rs (MarchConfig from env / MarchResult / window statistics), the harness parameterised by BenchmarkCase (FSI2, FSI3: inflow, solid density, modulus, rigid-flag drag reference), and turek_hron_fsi3.rs written on top (Re 200, density ratio 1, E 5.6e6). The committed FSI2 default is bit-identical through all of it — every new knob defaults to FSI2's behaviour; re-verified twice to every printed digit (uy 3.7732 / 3.7920 mm). FSI3's first contact, traced pass by pass (RTX_FSI3_TRACE), exposed the added-mass instability in its purest form and, one measured mechanism at a time, what a partitioned coupling at unit density ratio needs: - C^1 interface motion (MarchConfig::c1_interface): a constant per-step interface velocity is a velocity JUMP at each step start, and the incompressible fluid answers with an impulsive added-mass load ~ rho L dv / dt_fluid (8x the physical reaction under subcycling): 1,600 N at release, 48,000 N and a 59 mm response one step later, the flag's Newton dead the pass after. Constant acceleration from the previous end velocity to 2 dd/dt - v_n removes the impulse (loads 1,700-2,400 N). - IQN first-pass relaxation as a knob (initial_relaxation, FSI3 0.05): |1 - omega (1 + g)| must contract; 0.5 diverges past gain 3. - The divergence verdict waits for the secant (IqnIls): the exploratory second pass on a high-gain map legitimately overshoots 10x before the first secant column exists. Pinned by a gain-40 model test. - Kinematic predictor (predictor: "kinematic", velocity only): the structure-alone predictor ignores an added mass comparable to the flag's and overshoots 2-5x, drawing 5-6x loads every first pass; and NOT with the acceleration — Newmark average acceleration carries an inconsistent initial acceleration as a sign-alternating mode (d + dt v + dt^2 a / 2 predicted 22 mm at release; converged 0.14). - Quiescent release (quiescent_release): the structure-alone "consistent" initial acceleration M^-1 F ignores the added mass. - Coupler hygiene (IqnIls): a stalled or diverged step's secant columns are no longer retained (a bistable mask flip's columns extrapolated a 30 mm interface jump on the next step); two-window stagnation detection reports a plateau early instead of bouncing to the budget (a single-window test misjudged a slowly converging step and is recorded as such); trust region tightened to 10x the residual. A noise-column filter at the tolerance was measured to HURT (stalled a converging step at 5.5e-4) and is disabled (threshold 0). - The floor measured, not borrowed (fsi2_interface_noise.rs gains RTX_NOISE_CASE=fsi3): flip jumps 3.6e-4 (12x FSI2's), the subcycle-8 release map stalling near 1e-6, the subcycle-2 map converging to 9e-10; and through the release transient (flag at ~0.3 m/s) the subcycle-8 floor rides up to ~1e-3 with the motion — which moved the FSI3 default to subcycle 2 at a 3e-5 floor. FSI3 committed default (ny 62, release t = 4, t_end 4.2, subcycle 2): 581 steps, 3.8 subit/step (max 8), 0 stalls, 0 retries, conservation 4.3e-13; uy 10.1 +- 27.5 mm and ux -3.1 +- 3.2 mm over the first 0.2 s (reference cycle 1.45 +- 34.90, -2.86 +- 2.70). Machinery invariants pinned; physics bands await the study record. Rigid-flag drag 426.9 vs CFD3's 439.45 (-2.9%). The FSI2 mode-2 study pin (IQN / subcycle 2, t_end >= 16) was measured with the pre-hygiene coupler; re-verify on the next s = 2 study run. 48 lib tests green, clippy clean. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
This commit is contained in:
co-authored by
Claude Fable 5
parent
be04e0e233
commit
c0666bf22a
@@ -0,0 +1,555 @@
|
||||
//! The coupled time march shared by the FSI2 and FSI3 tests: rigid-flag
|
||||
//! phase, release, and the per-step subiterated (fluid ↔ flag Newmark)
|
||||
//! coupling with its measured robustness machinery. Code motion from
|
||||
//! the FSI2 test; the case parameters (inflow, solid density, modulus)
|
||||
//! come from the [`BenchmarkCase`].
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::Write as _;
|
||||
|
||||
use nalgebra::Vector3;
|
||||
use rtx_cfd::solvers::incompressible::FlowField;
|
||||
use rtx_fea::analysis::{
|
||||
AnalysisConfig, ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis,
|
||||
};
|
||||
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
|
||||
use rtx_fea::mesh::{MaterialId, NodeId};
|
||||
use rtx_fsi::{IqnIls, Subiterated};
|
||||
|
||||
use super::{BenchmarkCase, Fsi2Harness, clamp_left, crossing_frequency, env_or, mid_amp};
|
||||
|
||||
/// Everything a march run is parameterised by. `from_env` reads the
|
||||
/// `RTX_<PREFIX>_*` knobs over a set of defaults.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarchConfig {
|
||||
pub ny: usize,
|
||||
pub flag_nx: usize,
|
||||
pub t_release: f64,
|
||||
pub t_end: f64,
|
||||
/// Fluid substeps per coupled step.
|
||||
pub subcycle: usize,
|
||||
/// Per-step interface tolerance: max(`tol_floor`, `rtol` x that
|
||||
/// step's own interface increment). The floor is MEASURED per
|
||||
/// configuration (`fsi2_interface_noise.rs`), not wished: the
|
||||
/// step-to-step scatter of the accepted interface feeds the no-slip
|
||||
/// closure a wall-velocity noise of tolerance / dt_c, so a tighter
|
||||
/// coupling needs a proportionally tighter (and, as measured,
|
||||
/// reachable) floor.
|
||||
pub tol_floor: f64,
|
||||
pub rtol: f64,
|
||||
pub max_subiterations: usize,
|
||||
/// `"aitken"` (per-step scalar Aitken) or `"iqn"` (a persistent
|
||||
/// IQN-ILS whose secant history carries across steps).
|
||||
pub coupler: String,
|
||||
/// IQN secant history retained across steps.
|
||||
pub reuse: usize,
|
||||
/// Traction smoothing radius in multiples of the cell size (0 = off;
|
||||
/// measured to change nothing that matters — see the probe).
|
||||
pub smooth_in_h: f64,
|
||||
pub csv_path: Option<String>,
|
||||
/// IQN's relaxation on the very first pass, before any secant
|
||||
/// information exists. Must CONTRACT a repulsive added-mass map: for
|
||||
/// a per-pass gain `-g` the first update multiplies the residual by
|
||||
/// `|1 - omega (1 + g)|`, so 0.5 diverges past g = 3 while 0.2 holds
|
||||
/// to g = 9 (FSI3, density ratio 1, needed this — its first release
|
||||
/// pass at 0.5 drove the flag's Newton to failure).
|
||||
pub initial_relaxation: f64,
|
||||
/// Print every pass's interface residual for the first `trace_steps`
|
||||
/// coupled steps (diagnostics; 0 = off).
|
||||
pub trace_steps: usize,
|
||||
/// C^1 interface motion (constant acceleration across the step from
|
||||
/// the previous end velocity) instead of a constant velocity with a
|
||||
/// jump at the step boundary. See `Fsi2Harness::advance_subcycled`.
|
||||
pub c1_interface: bool,
|
||||
/// The per-step predictor: `"structure"` steps the flag alone under
|
||||
/// the committed load (FSI2's, exact for a heavy flag), `"kinematic"`
|
||||
/// extrapolates the CONVERGED interface velocity, d + dt v (velocity
|
||||
/// only — see the predictor code for why not acceleration). At unit
|
||||
/// density ratio the structure-alone predictor ignores an added mass
|
||||
/// comparable to the flag's own and overshoots the motion 2–5x
|
||||
/// (measured: 6.4e-4 predicted vs 1.4e-4 converged at FSI3's
|
||||
/// release), and the C^1 ramp toward that excess draws a 5–6x load
|
||||
/// (8,300 N vs ~1,300 N) — a violent first pass every step, one of
|
||||
/// which pushed the flag's Newton onto a wrong branch.
|
||||
pub predictor: String,
|
||||
/// Release with the acceleration zeroed instead of the
|
||||
/// structure-alone consistent initial acceleration M⁻¹F. That
|
||||
/// acceleration ignores the added mass — at unit density ratio it
|
||||
/// is wildly wrong (light tip nodes under −529 N of lift) and Newmark
|
||||
/// average acceleration then carries it as a sign-alternating mode.
|
||||
pub quiescent_release: bool,
|
||||
}
|
||||
|
||||
impl MarchConfig {
|
||||
/// Read `RTX_{prefix}_{NY,T_RELEASE,T_END,SUBCYCLE,TOL,RTOL,MAXSUB,
|
||||
/// FLAG_NX,SMOOTH,COUPLER,REUSE,CSV}` over `defaults`.
|
||||
pub fn from_env(prefix: &str, defaults: MarchConfig) -> MarchConfig {
|
||||
let key = |name: &str| format!("RTX_{prefix}_{name}");
|
||||
let num = |name: &str, default: f64| env_or(&key(name), default);
|
||||
MarchConfig {
|
||||
ny: num("NY", defaults.ny as f64) as usize,
|
||||
flag_nx: num("FLAG_NX", defaults.flag_nx as f64) as usize,
|
||||
t_release: num("T_RELEASE", defaults.t_release),
|
||||
t_end: num("T_END", defaults.t_end),
|
||||
subcycle: num("SUBCYCLE", defaults.subcycle as f64) as usize,
|
||||
tol_floor: num("TOL", defaults.tol_floor),
|
||||
rtol: num("RTOL", defaults.rtol),
|
||||
max_subiterations: num("MAXSUB", defaults.max_subiterations as f64) as usize,
|
||||
coupler: std::env::var(key("COUPLER")).unwrap_or(defaults.coupler),
|
||||
reuse: num("REUSE", defaults.reuse as f64) as usize,
|
||||
smooth_in_h: num("SMOOTH", defaults.smooth_in_h),
|
||||
csv_path: std::env::var(key("CSV")).ok().or(defaults.csv_path),
|
||||
initial_relaxation: num("OMEGA0", defaults.initial_relaxation),
|
||||
trace_steps: num("TRACE", defaults.trace_steps as f64) as usize,
|
||||
c1_interface: num("C1", f64::from(u8::from(defaults.c1_interface))) != 0.0,
|
||||
predictor: std::env::var(key("PREDICTOR")).unwrap_or(defaults.predictor),
|
||||
quiescent_release: num("QUIESCENT", f64::from(u8::from(defaults.quiescent_release)))
|
||||
!= 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics over a trailing window of a march.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WindowStats {
|
||||
pub t_start: f64,
|
||||
pub uy_mid: f64,
|
||||
pub uy_amp: f64,
|
||||
pub ux_mid: f64,
|
||||
pub ux_amp: f64,
|
||||
pub frequency: Option<f64>,
|
||||
pub drag_mid: f64,
|
||||
pub drag_amp: f64,
|
||||
pub lift_mid: f64,
|
||||
pub lift_amp: f64,
|
||||
/// uy amplitude over the first / last quarter of the coupled march.
|
||||
pub amp_early: f64,
|
||||
pub amp_late: f64,
|
||||
}
|
||||
|
||||
/// What a march produced: the trajectories and the coupling bookkeeping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarchResult {
|
||||
pub dt: f64,
|
||||
pub coupled_steps: usize,
|
||||
pub times: Vec<f64>,
|
||||
pub ux: Vec<f64>,
|
||||
pub uy: Vec<f64>,
|
||||
pub force_times: Vec<f64>,
|
||||
pub drag: Vec<f64>,
|
||||
pub lift: Vec<f64>,
|
||||
pub rigid_drag: f64,
|
||||
pub rigid_lift: f64,
|
||||
pub mean_subiterations: f64,
|
||||
pub max_subiterations: usize,
|
||||
pub stalled_steps: usize,
|
||||
pub retried_steps: usize,
|
||||
pub worst_stall: f64,
|
||||
pub worst_conservation: f64,
|
||||
pub skipped: usize,
|
||||
pub spiked: usize,
|
||||
pub final_state_finite: bool,
|
||||
pub elapsed: f64,
|
||||
}
|
||||
|
||||
impl MarchResult {
|
||||
/// Measure over the last `seconds` of the march (or the last half,
|
||||
/// if the march is shorter).
|
||||
pub fn window(&self, seconds: f64) -> WindowStats {
|
||||
let t_end = *self.times.last().unwrap_or(&0.0);
|
||||
let start = self
|
||||
.times
|
||||
.iter()
|
||||
.position(|&t| t >= t_end - seconds)
|
||||
.unwrap_or(self.times.len() / 2);
|
||||
let (uy_mid, uy_amp) = mid_amp(&self.uy[start..]);
|
||||
let (ux_mid, ux_amp) = mid_amp(&self.ux[start..]);
|
||||
let frequency = crossing_frequency(&self.times[start..], &self.uy[start..]);
|
||||
let force_start = self
|
||||
.force_times
|
||||
.iter()
|
||||
.position(|&t| t >= t_end - seconds)
|
||||
.unwrap_or(self.force_times.len() / 2);
|
||||
let (drag_mid, drag_amp) = mid_amp(&self.drag[force_start..]);
|
||||
let (lift_mid, lift_amp) = mid_amp(&self.lift[force_start..]);
|
||||
let quarter = self.uy.len() / 4;
|
||||
let (_, amp_early) = mid_amp(&self.uy[..quarter.max(1)]);
|
||||
let (_, amp_late) = mid_amp(&self.uy[self.uy.len() - quarter.max(1)..]);
|
||||
WindowStats {
|
||||
t_start: self.times.get(start).copied().unwrap_or(0.0),
|
||||
uy_mid,
|
||||
uy_amp,
|
||||
ux_mid,
|
||||
ux_amp,
|
||||
frequency,
|
||||
drag_mid,
|
||||
drag_amp,
|
||||
lift_mid,
|
||||
lift_amp,
|
||||
amp_early,
|
||||
amp_late,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the coupled march for a benchmark case.
|
||||
///
|
||||
/// Phase 1 marches the rigid flag to `t_release` (the fluid harness is
|
||||
/// checked against the case's rigid-flag drag). Phase 2 releases the
|
||||
/// flag at rest under the sampled load and marches to `t_end`: per step,
|
||||
/// a structure-alone predictor, then the coupler drives the pass
|
||||
/// (subcycled fluid on the candidate interface → sampled load → one flag
|
||||
/// Newmark step from the committed state) to a fixed point. Robustness
|
||||
/// machinery, each piece measured before it was written (see the FSI2
|
||||
/// test's module docs): stall acceptance at the noise floor, IQN
|
||||
/// history reset + one retry from the predictor, increment-scaled
|
||||
/// acceptance for the rare violent step; genuine runaway still panics.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
|
||||
let MarchConfig {
|
||||
ny,
|
||||
flag_nx,
|
||||
t_release,
|
||||
t_end,
|
||||
subcycle,
|
||||
tol_floor,
|
||||
rtol,
|
||||
max_subiterations: max_subiterations_budget,
|
||||
ref coupler,
|
||||
reuse,
|
||||
smooth_in_h,
|
||||
ref csv_path,
|
||||
initial_relaxation,
|
||||
trace_steps,
|
||||
c1_interface,
|
||||
ref predictor,
|
||||
quiescent_release,
|
||||
} = *config;
|
||||
|
||||
let (harness, mut solver, mut field) = Fsi2Harness::build_case(case, ny, flag_nx, smooth_in_h);
|
||||
let dt_fluid = harness.dt_fluid;
|
||||
let dt = dt_fluid * subcycle as f64;
|
||||
let interface = &harness.interface;
|
||||
let zero_d = vec![0.0; 2 * interface.wetted.len()];
|
||||
|
||||
// Phase 1: rigid flag to t_release.
|
||||
let start = std::time::Instant::now();
|
||||
let rigid_steps = (t_release / dt_fluid).round() as usize;
|
||||
for _ in 0..rigid_steps {
|
||||
futures::executor::block_on(solver.advance(&mut field, dt_fluid)).unwrap();
|
||||
}
|
||||
// The fluid harness check: surface drag on cylinder + flag against
|
||||
// the rigid-flag CFD value on this geometry.
|
||||
let (rigid_drag, rigid_lift) = harness.measure_force(&solver, &field);
|
||||
println!(
|
||||
" {} rigid phase: {rigid_steps} steps to t = {t_release:.1} s in {:.0} s wall; \
|
||||
surface drag {rigid_drag:.1} (rigid-flag reference {:.1}), lift {rigid_lift:.1}",
|
||||
case.name,
|
||||
start.elapsed().as_secs_f64(),
|
||||
case.rigid_drag_reference
|
||||
);
|
||||
|
||||
// The flag: nonlinear Newmark stepper at the coupled dt.
|
||||
let mut db = MaterialDatabase::new();
|
||||
db.add_material(
|
||||
MaterialId(0),
|
||||
LinearElastic::new(case.e_s, case.nu_s).with_density(case.rho_s),
|
||||
None,
|
||||
);
|
||||
// A deep Newton budget: a mid-swing subiteration can hand the flag a
|
||||
// large sudden load change (the coupled lift swings hundreds of N
|
||||
// within a period); typical steps converge in 1-2 iterations, and a
|
||||
// t = 25.8 s failure at the default budget of 25 is what set this.
|
||||
let analysis = NonlinearDynamicAnalysis::new(
|
||||
harness.mesh.clone(),
|
||||
db,
|
||||
clamp_left(&harness.mesh),
|
||||
dt,
|
||||
1,
|
||||
AnalysisConfig::default(),
|
||||
)
|
||||
.with_total_lagrangian()
|
||||
.with_convergence_criteria(ConvergenceCriteria {
|
||||
max_iterations: 60,
|
||||
..ConvergenceCriteria::default()
|
||||
});
|
||||
let flag = RefCell::new(analysis.stepper().unwrap());
|
||||
let wetted_dofs: Vec<[usize; 2]> = interface
|
||||
.wetted
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
let dofs = flag.borrow().node_dofs(id);
|
||||
[dofs[0], dofs[1]]
|
||||
})
|
||||
.collect();
|
||||
let a_dofs = flag.borrow().node_dofs(harness.a_node);
|
||||
let extract = |state: &DynamicState| -> Vec<f64> {
|
||||
let mut d = vec![0.0; 2 * wetted_dofs.len()];
|
||||
for (k, dofs) in wetted_dofs.iter().enumerate() {
|
||||
d[2 * k] = state.displacement[dofs[0]];
|
||||
d[2 * k + 1] = state.displacement[dofs[1]];
|
||||
}
|
||||
d
|
||||
};
|
||||
let extract_velocity = |state: &DynamicState| -> Vec<f64> {
|
||||
let mut v = vec![0.0; 2 * wetted_dofs.len()];
|
||||
for (k, dofs) in wetted_dofs.iter().enumerate() {
|
||||
v[2 * k] = state.velocity[dofs[0]];
|
||||
v[2 * k + 1] = state.velocity[dofs[1]];
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
// Phase 2: release. The flag starts at rest under the current fluid
|
||||
// load (consistent initial acceleration — the step response about the
|
||||
// steady deflection is the seed perturbation for the instability).
|
||||
let (nodal0, conservation0, _) = harness.sample_load(&solver, &field, &zero_d);
|
||||
flag.borrow_mut().set_nodal_forces(&nodal0);
|
||||
let mut flag_state = flag.borrow_mut().rest_state().unwrap();
|
||||
if quiescent_release {
|
||||
flag_state.acceleration.fill(0.0);
|
||||
}
|
||||
let mut committed_nodal = nodal0;
|
||||
let mut worst_conservation = conservation0;
|
||||
|
||||
let solver = RefCell::new(solver);
|
||||
let field = RefCell::new(field);
|
||||
|
||||
// The interface driver: per-step Aitken, or a persistent IQN-ILS
|
||||
// whose secant history carries across steps.
|
||||
let mut iqn = (coupler == "iqn").then(|| {
|
||||
IqnIls::new(max_subiterations_budget, 1.0)
|
||||
.unwrap()
|
||||
.with_reuse(reuse)
|
||||
.with_initial_relaxation(initial_relaxation)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let coupled_steps = ((t_end - t_release) / dt).round() as usize;
|
||||
let mut times = Vec::with_capacity(coupled_steps);
|
||||
let mut ux_series = Vec::with_capacity(coupled_steps);
|
||||
let mut uy_series = Vec::with_capacity(coupled_steps);
|
||||
let mut total_subiterations = 0usize;
|
||||
let mut max_subiterations = 0usize;
|
||||
let mut total_skipped = 0usize;
|
||||
let mut stalled_steps = 0usize;
|
||||
let mut retried_steps = 0usize;
|
||||
let mut worst_stall = 0.0f64;
|
||||
let mut force_times: Vec<f64> = Vec::new();
|
||||
let mut drag_series: Vec<f64> = Vec::new();
|
||||
let mut lift_series: Vec<f64> = Vec::new();
|
||||
let mut csv = csv_path
|
||||
.as_ref()
|
||||
.map(|p| std::fs::File::create(p).expect("csv path"));
|
||||
|
||||
let phase_start = std::time::Instant::now();
|
||||
for step in 0..coupled_steps {
|
||||
let d_n = extract(&flag_state);
|
||||
let v_n: Option<Vec<f64>> = c1_interface.then(|| extract_velocity(&flag_state));
|
||||
|
||||
// Predictor (see `MarchConfig::predictor`).
|
||||
let d_predicted = if predictor == "kinematic" {
|
||||
// Velocity only. The converged velocity is clean (Newmark's
|
||||
// trapezoidal update sums consecutive accelerations), but the
|
||||
// ACCELERATION is not: average acceleration carries an
|
||||
// inconsistent initial acceleration as a sign-alternating
|
||||
// mode step after step, and extrapolating it predicted 22 mm
|
||||
// at FSI3's release (converged: 0.14 mm).
|
||||
let v = extract_velocity(&flag_state);
|
||||
d_n.iter().zip(&v).map(|(d, v)| d + dt * v).collect()
|
||||
} else {
|
||||
// The structure alone under the committed load.
|
||||
flag.borrow_mut().set_nodal_forces(&committed_nodal);
|
||||
let (predicted, _) = flag.borrow_mut().step(&flag_state).unwrap();
|
||||
extract(&predicted)
|
||||
};
|
||||
|
||||
let fluid_saved = solver.borrow().snapshot();
|
||||
let field_saved = field.borrow().clone();
|
||||
type PassResult = (
|
||||
FlowField,
|
||||
DynamicState,
|
||||
Vec<(NodeId, Vector3<f64>)>,
|
||||
f64,
|
||||
usize,
|
||||
);
|
||||
let latest: RefCell<Option<PassResult>> = RefCell::new(None);
|
||||
|
||||
let pass = |d_candidate: &[f64]| -> Vec<f64> {
|
||||
// Subcycled fluid steps from the SAME start-of-step state,
|
||||
// geometry interpolated to each substep's end time, interface
|
||||
// velocity of THIS candidate constant over the step.
|
||||
let mut solver_ref = solver.borrow_mut();
|
||||
solver_ref.restore(&fluid_saved);
|
||||
let mut trial_field = field_saved.clone();
|
||||
harness.advance_subcycled(
|
||||
&mut solver_ref,
|
||||
&mut trial_field,
|
||||
&d_n,
|
||||
d_candidate,
|
||||
subcycle,
|
||||
v_n.as_deref(),
|
||||
);
|
||||
// Load on the candidate geometry, flag answers from the
|
||||
// committed state.
|
||||
let (nodal, conservation, skipped) =
|
||||
harness.sample_load(&solver_ref, &trial_field, d_candidate);
|
||||
if step < trace_steps {
|
||||
let load: f64 = nodal.iter().map(|(_, f)| f.norm()).sum();
|
||||
let peak = nodal.iter().map(|(_, f)| f.norm()).fold(0.0, f64::max);
|
||||
println!(
|
||||
" step {step} pass: candidate |d| = {:.3e}, sampled load: total nodal |F| \
|
||||
= {load:.2}, peak nodal |F| = {peak:.2}",
|
||||
d_candidate.iter().map(|v| v * v).sum::<f64>().sqrt()
|
||||
);
|
||||
}
|
||||
let mut flag_ref = flag.borrow_mut();
|
||||
flag_ref.set_nodal_forces(&nodal);
|
||||
let (candidate_state, _) = flag_ref.step(&flag_state).unwrap();
|
||||
let d_new = extract(&candidate_state);
|
||||
if step < trace_steps {
|
||||
let residual: f64 = d_new
|
||||
.iter()
|
||||
.zip(d_candidate)
|
||||
.map(|(a, b)| (a - b) * (a - b))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
println!(
|
||||
" step {step} pass: |d_new - d_candidate| = {residual:.3e}, |d_new| = {:.3e}",
|
||||
d_new.iter().map(|v| v * v).sum::<f64>().sqrt()
|
||||
);
|
||||
}
|
||||
*latest.borrow_mut() =
|
||||
Some((trial_field, candidate_state, nodal, conservation, skipped));
|
||||
d_new
|
||||
};
|
||||
|
||||
let increment: f64 = d_predicted
|
||||
.iter()
|
||||
.zip(&d_n)
|
||||
.map(|(a, b)| (a - b) * (a - b))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
let tol_step = tol_floor.max(rtol * increment);
|
||||
// Acceptance beyond the tolerance: 5x the tolerance (noise
|
||||
// bouncing over a well-predicted step) or an order below the
|
||||
// step's own increment (the rare violent step near peak motion —
|
||||
// the s = 1 FSI2 run died at residual = 9% of its increment).
|
||||
// Counted as stalls and bounded by the caller.
|
||||
let acceptable = (5.0 * tol_step).max(0.1 * increment);
|
||||
let mut outcome = if let Some(iqn) = iqn.as_mut() {
|
||||
iqn.set_tolerance(tol_step).unwrap();
|
||||
iqn.solve(&d_predicted, pass)
|
||||
} else {
|
||||
Subiterated::aitken(max_subiterations_budget, tol_step)
|
||||
.unwrap()
|
||||
.solve(&d_predicted, pass)
|
||||
};
|
||||
// Stale-history recovery: cross-step secant columns assume the
|
||||
// interface Jacobian drifts slowly; during a rapid resonant
|
||||
// growth they can steer the quasi-Newton update into an
|
||||
// overshoot the divergence guard reads as added mass (measured:
|
||||
// a first residual of 1e-4 driven to 1e-3 by the first update at
|
||||
// 2.7x the previously seen amplitude). The map itself converges
|
||||
// deeply from a clean start, so: reset the history and retry the
|
||||
// step ONCE from the predictor. Aitken carries no history — a
|
||||
// retry would repeat the identical iteration — so IQN-only.
|
||||
if let (Err(e), Some(iqn_ref)) = (&outcome, iqn.as_mut()) {
|
||||
let recoverable = matches!(
|
||||
e,
|
||||
rtx_fsi::FsiError::CouplingNotConverged { residual, .. }
|
||||
| rtx_fsi::FsiError::CouplingDiverged { residual, .. }
|
||||
if *residual >= acceptable
|
||||
);
|
||||
if recoverable {
|
||||
iqn_ref.reset_history();
|
||||
retried_steps += 1;
|
||||
outcome = iqn_ref.solve(&d_predicted, pass);
|
||||
}
|
||||
}
|
||||
match outcome {
|
||||
Ok(converged) => {
|
||||
total_subiterations += converged.iterations;
|
||||
max_subiterations = max_subiterations.max(converged.iterations);
|
||||
}
|
||||
Err(
|
||||
rtx_fsi::FsiError::CouplingNotConverged {
|
||||
iterations,
|
||||
residual,
|
||||
..
|
||||
}
|
||||
| rtx_fsi::FsiError::CouplingDiverged {
|
||||
iterations,
|
||||
residual,
|
||||
},
|
||||
) if residual < acceptable => {
|
||||
stalled_steps += 1;
|
||||
worst_stall = worst_stall.max(residual);
|
||||
total_subiterations += iterations;
|
||||
max_subiterations = max_subiterations.max(iterations);
|
||||
}
|
||||
Err(e) => panic!("{} coupling failed at step {step}: {e:?}", case.name),
|
||||
}
|
||||
// `latest` holds the response to the accepted interface (the last
|
||||
// pass) — commit it directly; the fluid, mask and flag are
|
||||
// consistent with that interface without an extra pass.
|
||||
let (new_field, new_flag_state, nodal, conservation, skipped) =
|
||||
latest.borrow_mut().take().expect("pass ran");
|
||||
*field.borrow_mut() = new_field;
|
||||
flag_state = new_flag_state;
|
||||
committed_nodal = nodal;
|
||||
worst_conservation = worst_conservation.max(conservation);
|
||||
total_skipped += skipped;
|
||||
|
||||
let t = t_release + (step + 1) as f64 * dt;
|
||||
let ux = flag_state.displacement[a_dofs[0]];
|
||||
let uy = flag_state.displacement[a_dofs[1]];
|
||||
times.push(t);
|
||||
ux_series.push(ux);
|
||||
uy_series.push(uy);
|
||||
if (step + 1) % 10 == 0 {
|
||||
let (drag, lift) = harness.measure_force(&solver.borrow(), &field.borrow());
|
||||
force_times.push(t);
|
||||
drag_series.push(drag);
|
||||
lift_series.push(lift);
|
||||
if let Some(file) = csv.as_mut() {
|
||||
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},{drag:.6e},{lift:.6e}").unwrap();
|
||||
}
|
||||
} else if let Some(file) = csv.as_mut() {
|
||||
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},,").unwrap();
|
||||
}
|
||||
if (step + 1) % 1000 == 0 {
|
||||
let window = &uy_series[uy_series.len().saturating_sub(1000)..];
|
||||
let (w_mid, w_amp) = mid_amp(window);
|
||||
println!(
|
||||
" t = {t:.3} s ({step} steps): uy(A) = {uy:.3e} (window mid {w_mid:.3e} \
|
||||
amp {w_amp:.3e}), {:.1} subit/step, {:.0} s wall",
|
||||
total_subiterations as f64 / (step + 1) as f64,
|
||||
phase_start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MarchResult {
|
||||
dt,
|
||||
coupled_steps,
|
||||
times,
|
||||
ux: ux_series,
|
||||
uy: uy_series,
|
||||
force_times,
|
||||
drag: drag_series,
|
||||
lift: lift_series,
|
||||
rigid_drag,
|
||||
rigid_lift,
|
||||
mean_subiterations: total_subiterations as f64 / coupled_steps.max(1) as f64,
|
||||
max_subiterations,
|
||||
stalled_steps,
|
||||
retried_steps,
|
||||
worst_stall,
|
||||
worst_conservation,
|
||||
skipped: total_skipped,
|
||||
spiked: harness.spiked_total.get(),
|
||||
final_state_finite: flag_state.displacement.iter().all(|v| v.is_finite()),
|
||||
elapsed: start.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user