Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/march.rs
T
Omar SobhandClaude Fable 5 9fe9d7f74a
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
rtx-cfd + rtx-fsi: ECSW campaign phase 1 — snapshot dump + FlowField save/load
FlowField::save/load serialize the complete field state bit-exact
(all twelve matrices including *_old, predictors and sources, so a
load is a true restart state), with a roundtrip test asserting
to_bits equality on every value and rejection of truncated/corrupt
files.

The march gains an ECSW snapshot knob (RTX_FSI{2,3}_SNAP path,
SNAPEVERY, default off): every N committed steps it appends an FSNP
record — t, full-DOF displacement/velocity/acceleration (what
rtx_fea::mor's pod_basis/train_ecsw consume, plus what the phase-4
dynamic reduction will need) and the committed sparse nodal load for
the offline full-vs-reduced replay. Reporting-only: reads committed
state after acceptance, no float ops on the solver path. Verified:
smoke run's FSNP parsed by an independent reader (570 DOFs, correct
record count, physical values); FSI2 committed default
digit-identical with the knob off.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-08-28 21:56:37 -05:00

688 lines
29 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.
//! 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, median, 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,
/// Stall acceptance in multiples of the step tolerance (window =
/// max(stall_accept x tol_step, 0.1 x increment)). Default 5. The
/// FSI3 developed cycle needs more: its rare bistable-mask stalls
/// sit at 3.5e-4 against floor 6e-5 (measured, run 3 died 16% over
/// the 5x window at a max-velocity crossing), and RAISING THE FLOOR
/// INSTEAD MAKES IT WORSE — floor 1e-4 died EARLIER (t = 6.3 vs
/// 7.7) at a HIGHER stall (1.2e-3): the accepted-step scatter is
/// the wall-velocity noise (tol/dt_c), so a looser floor feeds the
/// flip noise it is trying to pass. Keep the floor tight; widen
/// only the rare-event window (FSI2's s = 1 benchmark run accepted
/// a worst stall of 5.4e-4 the same way and measured 0.1%).
pub stall_accept: f64,
/// Mask hysteresis band in multiples of the min cell size (0 = off,
/// bit-identical). With a band, a cell within `band * h` of the
/// interface keeps the classification of the committed step-start
/// mask (the snapshot every pass restores), so candidate geometries
/// within the band all see the SAME mask — the pass map stops
/// flipping cells on sub-band candidate differences (the measured
/// FSI3 killer: bistable load branches 60 vs 120 kN at one geometry,
/// and a 20x load cliff over a 1e-3 candidate change). Cost: the
/// effective wall lags the true surface by up to the band (measured
/// on the translating-circle MMS at 0.25h: +0.5% field error).
pub mask_hysteresis: 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>,
/// ECSW snapshot dump (0 = off): every `snap_every` committed steps,
/// append the committed flag state — full-DOF displacement, velocity
/// and acceleration — plus the committed sparse nodal load to
/// `snap_path` (binary, magic `FSNP`; see `write_snapshot`). The POD
/// basis and ECSW training (`rtx_fea::mor`) consume the displacement
/// snapshots; the load records drive the offline full-vs-reduced
/// replay. Reporting-only: reads the committed state after
/// acceptance, no float ops on the solver path.
pub snap_path: Option<String>,
pub snap_every: usize,
/// 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 25x
/// (measured: 6.4e-4 predicted vs 1.4e-4 converged at FSI3's
/// release), and the C^1 ramp toward that excess draws a 56x 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,STALLX,
/// HYST,MAXSUB,FLAG_NX,SMOOTH,COUPLER,REUSE,CSV,SNAP,SNAPEVERY}`
/// 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),
stall_accept: num("STALLX", defaults.stall_accept),
mask_hysteresis: num("HYST", defaults.mask_hysteresis),
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),
snap_path: std::env::var(key("SNAP")).ok().or(defaults.snap_path),
snap_every: num("SNAPEVERY", defaults.snap_every as f64) as usize,
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,
/// Window MEDIANS of the recorded loads — the honest central values
/// (the mid ± amp above are extreme-based and noise-dominated at
/// large deformation; the medians measured within 0.8% / 2.7% of
/// the FSI3 / FSI2 reference drags where the mids read 14% off or
/// worse).
pub drag_median: f64,
pub lift_median: 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,
/// Flag-Newton rescues over the whole march: `(line_search,
/// subdivision)` (see `NonlinearDynamicStepper::rescue_counts`).
/// Zero on a healthy march — a nonzero count marks passes whose load
/// defeated the plain SVK Newton, the failure that used to kill the
/// march outright.
pub newton_rescues: (usize, 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 drag_median = median(&mut self.drag[force_start..].to_vec());
let lift_median = median(&mut self.lift[force_start..].to_vec());
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,
drag_median,
lift_median,
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,
stall_accept,
mask_hysteresis,
max_subiterations: max_subiterations_budget,
ref coupler,
reuse,
smooth_in_h,
ref csv_path,
ref snap_path,
snap_every,
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);
solver.set_mask_hysteresis(mask_hysteresis);
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();
// Per-step load samples for the current recording interval. The
// recorded value is the interval MEDIAN, not one instantaneous
// sample: the embedded-boundary surface force carries zero-mean
// sign-flipping fresh-cell pressure transients at step scale
// (measured on the FSI2 s = 1 benchmark run: +-4,000-scale
// instantaneous swings against a +-78 reference while the window
// MEDIANS sat near-physical — drag 187 vs ref 215). The median
// rejects the transient outliers; ten coupled steps span 2-5% of a
// flap period, so nothing physical is smeared. Reporting only —
// measure_force reads the committed state and cannot perturb the
// trajectory.
let mut interval_drag: Vec<f64> = Vec::new();
let mut interval_lift: Vec<f64> = Vec::new();
let mut csv = csv_path
.as_ref()
.map(|p| std::fs::File::create(p).expect("csv path"));
let mut snap = snap_path.as_ref().map(|p| {
let mut w = std::io::BufWriter::new(std::fs::File::create(p).expect("snap path"));
w.write_all(b"FSNP").unwrap();
w.write_all(&1u32.to_le_bytes()).unwrap();
w.write_all(&(flag_state.displacement.len() as u64).to_le_bytes())
.unwrap();
w
});
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);
// Two thresholds, deliberately decoupled (the first STALLX
// draft used one and silently DISABLED the history-reset retry
// for the widened band — caught by trajectory divergence at
// t = 5.7 where runs 2/3 were digit-identical):
// `retry_at` is the OLD acceptance (5x the tolerance, 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): any stall at or above it still gets the
// measured-valuable history reset + one retry from the
// predictor. `acceptable` (stall_accept x, default the same 5x
// — bit-identical) widens only the POST-RETRY acceptance, so a
// developed-cycle bistable stall the retry cannot fix is
// accepted and counted instead of fatal.
let retry_at = (5.0 * tol_step).max(0.1 * increment);
let acceptable = (stall_accept * 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 >= retry_at
);
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:?} (Newton rescues so far: {:?})",
case.name,
flag.borrow().rescue_counts()
),
}
// `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 snap_every > 0 && (step + 1) % snap_every == 0 {
if let Some(w) = snap.as_mut() {
write_snapshot(w, t, &flag_state, &committed_nodal);
}
}
let (drag_now, lift_now) = harness.measure_force(&solver.borrow(), &field.borrow());
interval_drag.push(drag_now);
interval_lift.push(lift_now);
if (step + 1) % 10 == 0 {
let drag = median(&mut interval_drag);
let lift = median(&mut interval_lift);
interval_drag.clear();
interval_lift.clear();
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(),
newton_rescues: flag.borrow().rescue_counts(),
final_state_finite: flag_state.displacement.iter().all(|v| v.is_finite()),
elapsed: start.elapsed().as_secs_f64(),
}
}
/// Append one `FSNP` record: `t`, the committed full-DOF displacement,
/// velocity and acceleration, then the committed sparse nodal load as
/// `(node id, fx, fy, fz)` tuples — everything the ECSW phase needs
/// (POD/ECSW train on the displacement snapshots; the load records
/// drive the offline full-vs-reduced replay).
fn write_snapshot(
w: &mut std::io::BufWriter<std::fs::File>,
t: f64,
state: &DynamicState,
nodal: &[(NodeId, Vector3<f64>)],
) {
w.write_all(&t.to_le_bytes()).unwrap();
for series in [&state.displacement, &state.velocity, &state.acceleration] {
for v in series.iter() {
w.write_all(&v.to_le_bytes()).unwrap();
}
}
w.write_all(&(nodal.len() as u64).to_le_bytes()).unwrap();
for (node, f) in nodal {
w.write_all(&(node.0 as u64).to_le_bytes()).unwrap();
for c in 0..3 {
w.write_all(&f[c].to_le_bytes()).unwrap();
}
}
}