Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/march.rs
T
Omar SobhandClaude Fable 5.1 c63d79c300
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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
rtx-cfd/rtx-fsi: overset A-P0 GATED + M1 precision probe — curvilinear collocated PISO: relative-reduction pressure stop (the absolute stop floored |du/dt| at 2e-4 on 64²), line-implicit-n sign fix, adjustPhi; gates: Cartesian reduction 1.37–1.40x the staggered error at orders 0.83/0.90; skewed stretched periodic annulus Stokes orders 2.30/2.06 (explicit and line-implicit), upwind 1.08/0.80; Poiseuille exact to 1e-9 on Cartesian and affine-sheared periodic channels (both diffusion variants), varying-skew channel order 2.02 (v 1.9), cell mass 1e-14; divergence ≤ 1e-11 relative every step; snapshot/restore bit-identical. M1: poisson.rs multigrid hierarchy generic over MgScalar (f32/f64), f64 CG keeps its own fine level; MgPrecision on MultigridParameters/EmbeddedParameters/PisoParameters, set_poisson_precision, harness RTX_FSI2_POISSON_F32 (march + noise probe, printed marker); f64 arm bit-identical in vivo (FSI2 default line-for-line with 08-31), f32 arm holds the noise floor and stall pins and the FSI2 band; poisson_equivalence f32 arm
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-09-04 12:40:43 -07:00

1353 lines
61 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,
/// Fluid-field dump directory (`RTX_{prefix}_FFLD`, off by default):
/// every `snap_every` committed steps (10 if `snap_every` is 0),
/// write the committed `FlowField` (`f_STEP.ffld`,
/// `FlowField::save` — bit-exact), the mask's fluid-cell map
/// (`mask_STEP.txt`, `ny` rows of `nx` `0`/`1` chars, row 0 first)
/// and the interface polygon (`poly_STEP.txt`, `x y` per line), and
/// append `step,t,f_STEP.ffld` to `index.csv` in the directory.
/// Reporting-only: reads committed state after acceptance, no float
/// ops on the solver path. The `ffld_to_vtk` example in `rtx-cfd`
/// turns a dump directory into clawview-readable VTK.
pub ffld_dir: 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,
/// Trace window for a death autopsy (`RTX_{prefix}_TRACE_FROM`,
/// default `usize::MAX` = off): from this coupled step onward,
/// print every pass like `trace_steps` does PLUS one line per step
/// with the predictor increment, the step tolerance, the two
/// acceptance thresholds, the coupling outcome and the committed
/// tip jump. Reporting-only — the knob touches no float on the
/// solver path, so a traced replay is digit-identical to the
/// untraced march (the property every autopsy so far has leaned
/// on). Motivation: every recorded coupling death is the END of a
/// multi-step runaway (committed tip jumps 1070× the march's own
/// p95 for 537 steps before the panic; `scripts/fsi_burst_scan.py`
/// in omni-cortex), and `trace_steps` only sees the release.
pub trace_from: usize,
/// Coupling-level rescue (`RTX_{prefix}_CRESCUE`, default off =
/// bit-identical): a step whose coupling ends above its acceptance
/// window (today's death), or whose committed tip jump exceeds
/// 3× the march's running p95 of |Δuy| (the runaway signature
/// every recorded death carries for 537 steps before its panic),
/// is repeated as 2, 4, 8, 16, 32 coupled substeps of dt/n — see
/// `rescue.rs` and omni-cortex `docs/coupling_rescue_campaign.md`.
pub coupling_rescue: bool,
/// M1 precision probe (`RTX_{prefix}_POISSON_F32`, default off =
/// bit-identical): the pressure multigrid's V-cycle in single
/// precision inside the f64 CG (`overset_metal_campaign.md` §3.2 M1).
pub poisson_f32: bool,
/// Rung C (`RTX_{prefix}_CRESCUE_COARSE`, default 0 = off; needs
/// `coupling_rescue`): on a trigger, instead of the substep ladder,
/// reject the step and enter a coarse EPISODE of this many coupled
/// steps taken as 2dt steps with the fluid subcycled at 2× (the s = 2
/// interpolated closure, measured to march through the crossing
/// where s = 1 dies), then resume. Reporting carries a linear
/// midpoint for the skipped row.
pub coarse_episode: usize,
/// Per-step increment dump (`RTX_{prefix}_INCTRACE=<csv>`, off by
/// default): one line per coupled step — step, t, predictor
/// increment, tol_step, passes, final residual, stalled (0/1),
/// committed tip jump. Reporting-only (rung A's calibration data:
/// the healthy distribution of the increment vs a death's).
pub inc_trace: Option<String>,
/// Rung A (`RTX_{prefix}_CRESCUE_INC=<K>`, default 0 = off; needs
/// `coupling_rescue` and `coarse_episode`): a predictor increment
/// above K × its trailing-2000 median opens a coarse episode BEFORE
/// the step's first pass. Calibrated (campaign doc §11): the anchor's
/// whole march stays under 1.72×; the (1.27, 2.0) death crosses 3×
/// 145 steps before its panic and never during growth.
pub increment_factor: f64,
/// Route 1 of the closure-scheduling campaign
/// (`RTX_{prefix}_CRESCUE_SPEED=<f>`, default 0 = off; needs
/// `coupling_rescue` and `coarse_episode`): whenever the last
/// committed tip jump exceeds f × its trailing-2000 peak, open a
/// coarse episode BEFORE the step — the s = 2 closure at every
/// crossing, preventively, because the s = 1 closure incubates the
/// crossing instability inside the healthy envelope
/// (coupling_rescue_campaign.md §12). Speed episodes are expected
/// twice per period: not capped, reported as a coarse fraction.
pub speed_fraction: f64,
/// 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,
/// FFLD}` 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,
ffld_dir: std::env::var(key("FFLD")).ok().or(defaults.ffld_dir),
initial_relaxation: num("OMEGA0", defaults.initial_relaxation),
trace_steps: num("TRACE", defaults.trace_steps as f64) as usize,
trace_from: std::env::var(key("TRACE_FROM"))
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(defaults.trace_from),
coupling_rescue: num("CRESCUE", f64::from(u8::from(defaults.coupling_rescue))) != 0.0,
poisson_f32: num("POISSON_F32", f64::from(u8::from(defaults.poisson_f32))) != 0.0,
coarse_episode: num("CRESCUE_COARSE", defaults.coarse_episode as f64) as usize,
inc_trace: std::env::var(key("INCTRACE")).ok().or(defaults.inc_trace),
increment_factor: num("CRESCUE_INC", defaults.increment_factor),
speed_fraction: num("CRESCUE_SPEED", defaults.speed_fraction),
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),
/// Coupling-level rescues (steps repeated as substeps — see
/// `rescue.rs`); zero with the knob off and on every healthy march.
pub coupling_rescues: usize,
/// Kinematic-trigger rescues whose whole ladder failed (the step
/// was kept as the coupling accepted it).
pub coupling_rescue_failures: usize,
pub rescue_records: Vec<super::rescue::RescueRecord>,
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,
ref ffld_dir,
initial_relaxation,
trace_steps,
trace_from,
coupling_rescue,
poisson_f32,
coarse_episode,
ref inc_trace,
increment_factor,
speed_fraction,
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);
if poisson_f32 {
solver.set_poisson_precision(rtx_cfd::solvers::incompressible::MgPrecision::F32);
println!(" poisson V-cycle precision: F32 (M1 probe)");
}
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();
// Coupling-level rescue bookkeeping (all inert with the knob off).
let mut coupling_rescues = 0usize;
let mut coupling_rescue_failures = 0usize;
let mut rescue_records: Vec<super::rescue::RescueRecord> = Vec::new();
let mut rescue_steps: Vec<usize> = Vec::new();
let mut jump_window: std::collections::VecDeque<f64> = std::collections::VecDeque::new();
let mut running_p95: Option<f64> = None;
// Rung C: coupled steps left in the current coarse episode, and the
// step index of every episode start (for the loud cap).
let mut coarse_remaining = 0usize;
let mut coarse_episodes: Vec<usize> = Vec::new();
// Rung A: the trailing window of predictor increments and its median.
let mut inc_window: std::collections::VecDeque<f64> = std::collections::VecDeque::new();
let mut running_inc_median: Option<f64> = None;
// Route 1 bookkeeping: speed-triggered episodes and coarse steps taken.
let mut speed_episodes = 0usize;
let mut coarse_steps_taken = 0usize;
let mut inc_trace_file = inc_trace.as_ref().map(|p| {
let mut w = std::io::BufWriter::new(std::fs::File::create(p).expect("inctrace path"));
writeln!(
w,
"step,t,increment,tol_step,passes,residual,stalled,tip_jump"
)
.unwrap();
w
});
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
});
// Wall-split accumulators (reporting-only): where a coupled pass
// actually spends its time — the measurement that decides whether a
// structural ROM can matter (ECSW campaign, re-scope decision).
let t_fluid = std::cell::Cell::new(0.0f64);
let t_structure = std::cell::Cell::new(0.0f64);
let t_sample = std::cell::Cell::new(0.0f64);
let mut t_save = 0.0f64;
let phase_start = std::time::Instant::now();
let mut step = 0usize;
// Rung C's coarse step over [step, step + 2) with its two-row series
// bookkeeping (a linear midpoint, then the end state). A macro rather
// than a closure so it can mutate the march's state beside the
// closures that borrow it. Never expands with the knobs off.
macro_rules! coarse_now {
($reason:expr, $before:expr, $tip_rejected:expr, $new_episode:expr,
$fluid_saved:expr, $field_saved:expr, $start_state:expr, $start_nodal:expr) => {{
let iv = super::rescue::Interval {
harness: &harness,
solver: &solver,
field: &field,
flag: &flag,
wetted_dofs: &wetted_dofs,
fluid_saved: $fluid_saved,
field_saved: $field_saved,
start_state: $start_state,
start_nodal: $start_nodal,
config,
dt,
};
let start_ux = $start_state.displacement[a_dofs[0]];
let start_uy = $start_state.displacement[a_dofs[1]];
match super::rescue::coarse_step(&iv, 2) {
Ok(o) => {
flag_state = o.state;
committed_nodal = o.nodal;
coarse_steps_taken += 2;
worst_conservation = worst_conservation.max(o.worst_conservation);
total_skipped += o.skipped;
total_subiterations += o.passes;
stalled_steps += o.stalls;
worst_stall = worst_stall.max(o.worst_residual);
if let Some(iqn_ref) = iqn.as_mut() {
iqn_ref.reset_history();
}
let t_end_c = t_release + (step + 2) as f64 * dt;
if $new_episode {
// Speed episodes are expected every half-period;
// only runaway-triggered ones count against the cap.
if $reason != "tip speed" {
coarse_episodes.push(step);
}
coupling_rescues += 1;
let record = super::rescue::RescueRecord {
step,
t: t_end_c,
trigger: $reason,
before: $before,
n: 0,
passes: o.passes,
tip_rejected: $tip_rejected,
tip_rescued: flag_state.displacement[a_dofs[1]],
};
println!(
" COARSE EPISODE at step {step} t = {t_end_c:.4}: {} ({:.3e}); first 2dt \
step in {} passes ({} stalls, worst {:.3e}); tip {:+.4e} -> {:+.4e}; \
episode {} coupled steps",
$reason, $before, o.passes, o.stalls, o.worst_residual, $tip_rejected,
record.tip_rescued, coarse_episode
);
rescue_records.push(record);
let per_second = (1.0 / dt).round() as usize;
let recent = coarse_episodes
.iter()
.filter(|&&s| step - s < per_second)
.count();
assert!(
recent <= super::rescue::COARSE_EPISODE_CAP_PER_SECOND,
"{} coarse-episode cap: {recent} episodes within one second of march \
at step {step} — a runaway the coarsening only delays",
case.name
);
}
let end_ux = flag_state.displacement[a_dofs[0]];
let end_uy = flag_state.displacement[a_dofs[1]];
let (drag_now, lift_now) =
harness.measure_force(&solver.borrow(), &field.borrow());
for k in 0..2usize {
let s = step + k;
let t = t_release + (s + 1) as f64 * dt;
let (ux, uy) = if k == 0 {
(0.5 * (start_ux + end_ux), 0.5 * (start_uy + end_uy))
} else {
(end_ux, end_uy)
};
times.push(t);
ux_series.push(ux);
uy_series.push(uy);
interval_drag.push(drag_now);
interval_lift.push(lift_now);
if (s + 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 (s + 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 ({s} steps, coarse): uy(A) = {uy:.3e} (window mid \
{w_mid:.3e} amp {w_amp:.3e}), {:.1} subit/step, {:.0} s wall",
total_subiterations as f64 / (s + 1) as f64,
phase_start.elapsed().as_secs_f64()
);
}
}
}
Err(e) => panic!(
"{} coarse step failed at step {step} ({}): {e:?} (coarse episodes so far {})",
case.name,
$reason,
coarse_episodes.len()
),
}
}};
}
while step < coupled_steps {
// Rung C: inside a coarse episode, keep taking 2dt steps.
if coarse_remaining > 0 && step + 1 < coupled_steps {
let fs = solver.borrow().snapshot();
let ff = field.borrow().clone();
let ss = flag_state.clone();
let sn = committed_nodal.clone();
coarse_now!("episode", f64::NAN, f64::NAN, false, &fs, &ff, &ss, &sn);
coarse_remaining = coarse_remaining.saturating_sub(2);
step += 2;
continue;
}
// Route 1: the s = 2 closure whenever the tip is fast, before any
// pass — preventive, not rescuing (closure_scheduling_campaign.md).
if coupling_rescue
&& coarse_episode > 0
&& speed_fraction > 0.0
&& step + 1 < coupled_steps
&& uy_series.len() > super::rescue::TIP_JUMP_WINDOW
{
let n = uy_series.len();
let last_jump = (uy_series[n - 1] - uy_series[n - 2]).abs();
let peak = uy_series[n - super::rescue::TIP_JUMP_WINDOW - 1..]
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0f64, f64::max);
if peak > 0.0 && last_jump > speed_fraction * peak {
let fs = solver.borrow().snapshot();
let ff = field.borrow().clone();
let ss = flag_state.clone();
let sn = committed_nodal.clone();
speed_episodes += 1;
coarse_now!(
"tip speed",
last_jump / peak,
f64::NAN,
true,
&fs,
&ff,
&ss,
&sn
);
coarse_remaining = coarse_episode.saturating_sub(2);
step += 2;
continue;
}
}
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 save_start = std::time::Instant::now();
let fluid_saved = solver.borrow().snapshot();
let field_saved = field.borrow().clone();
t_save += save_start.elapsed().as_secs_f64();
// The rescue repeats the interval from the committed start state.
let step_start_state = coupling_rescue.then(|| flag_state.clone());
let step_start_nodal = coupling_rescue.then(|| committed_nodal.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 fluid_start = std::time::Instant::now();
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(),
);
t_fluid.set(t_fluid.get() + fluid_start.elapsed().as_secs_f64());
// Load on the candidate geometry, flag answers from the
// committed state.
let sample_start = std::time::Instant::now();
let (nodal, conservation, skipped) =
harness.sample_load(&solver_ref, &trial_field, d_candidate);
t_sample.set(t_sample.get() + sample_start.elapsed().as_secs_f64());
if step < trace_steps || step >= trace_from {
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 structure_start = std::time::Instant::now();
let mut flag_ref = flag.borrow_mut();
flag_ref.set_nodal_forces(&nodal);
let (candidate_state, _) = flag_ref.step(&flag_state).unwrap();
t_structure.set(t_structure.get() + structure_start.elapsed().as_secs_f64());
let d_new = extract(&candidate_state);
if step < trace_steps || step >= trace_from {
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);
// Rung A: the predictor increment against its trailing median —
// known before any pass runs, the earliest measured signature of
// the runaway (campaign doc §11).
let increment_trigger = coupling_rescue
&& coarse_episode > 0
&& increment_factor > 0.0
&& step + 1 < coupled_steps
&& running_inc_median.is_some_and(|m| increment > increment_factor * m);
if coupling_rescue && !increment_trigger {
inc_window.push_back(increment);
if inc_window.len() > super::rescue::TIP_JUMP_WINDOW {
inc_window.pop_front();
}
if inc_window.len() == super::rescue::TIP_JUMP_WINDOW && step % 10 == 0 {
let mut sorted: Vec<f64> = inc_window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
running_inc_median = Some(sorted[sorted.len() / 2]);
}
}
let mut outcome = if increment_trigger {
// No pass is spent on a step the episode will replace.
Ok(rtx_fsi::Converged {
state: Vec::new(),
residual: 0.0,
iterations: 0,
})
} else 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);
}
}
let trace_line = (step >= trace_from).then(|| {
let verdict = match &outcome {
Ok(c) => format!("converged in {} (residual {:.3e})", c.iterations, c.residual),
Err(rtx_fsi::FsiError::CouplingNotConverged {
iterations,
residual,
..
}) => format!(
"NOT converged after {iterations} (residual {residual:.3e}) -> {}",
if *residual < acceptable { "accepted stall" } else { "DEATH" }
),
Err(rtx_fsi::FsiError::CouplingDiverged {
iterations,
residual,
}) => format!(
"DIVERGED after {iterations} (residual {residual:.3e}) -> {}",
if *residual < acceptable { "accepted stall" } else { "DEATH" }
),
Err(e) => format!("error {e:?}"),
};
format!(
" TRACE step {step} t = {:.5}: increment {increment:.3e}, tol_step {tol_step:.3e}, \
retry_at {retry_at:.3e}, acceptable {acceptable:.3e}, retries so far {retried_steps}, \
stalls so far {stalled_steps}, Newton rescues {:?}; {verdict}",
t_release + (step + 1) as f64 * dt,
flag.borrow().rescue_counts(),
)
});
let outcome_ok = outcome.is_ok();
// Rung A calibration record (reporting-only); the tip jump is
// appended after the commit.
let inc_record: Option<(usize, f64, bool)> =
inc_trace_file.as_ref().map(|_| match &outcome {
Ok(c) => (c.iterations, c.residual, false),
Err(
rtx_fsi::FsiError::CouplingNotConverged {
iterations,
residual,
..
}
| rtx_fsi::FsiError::CouplingDiverged {
iterations,
residual,
},
) => (*iterations, *residual, true),
Err(_) => (0, f64::NAN, true),
});
// The coupling-level rescue's ladder over this step's interval
// (never called with the knob off; the saved start state exists
// only with it on).
let run_ladder = || -> Result<super::rescue::RescueOutcome, rtx_fsi::FsiError> {
let iv = super::rescue::Interval {
harness: &harness,
solver: &solver,
field: &field,
flag: &flag,
wetted_dofs: &wetted_dofs,
fluid_saved: &fluid_saved,
field_saved: &field_saved,
start_state: step_start_state.as_ref().expect("rescue start state"),
start_nodal: step_start_nodal.as_deref().expect("rescue start load"),
config,
dt,
};
super::rescue::substep_interval(&iv)
};
let mut pending: Option<(&'static str, f64, super::rescue::RescueOutcome)> = None;
let mut tip_rejected = f64::NAN;
// Rung C: (trigger, its magnitude, the rejected tip) when a coarse
// episode is to start on this step.
let mut do_coarse: Option<(&'static str, f64, f64)> = None;
if increment_trigger {
do_coarse = Some(("increment", increment, f64::NAN));
}
if let (Some(line), Err(_)) = (&trace_line, &outcome) {
// A stall's record prints here (accepted or fatal — a death
// panics below, before the commit); a converged step's
// record prints after the commit with its tip jump.
println!("{line}");
}
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) => {
assert!(
coupling_rescue,
"{} coupling failed at step {step}: {e:?} (Newton rescues so far: {:?})",
case.name,
flag.borrow().rescue_counts()
);
let residual = match &e {
rtx_fsi::FsiError::CouplingNotConverged { residual, .. }
| rtx_fsi::FsiError::CouplingDiverged { residual, .. } => *residual,
_ => f64::NAN,
};
if coarse_episode > 0 && step + 1 < coupled_steps {
do_coarse = Some(("fatal stall", residual, f64::NAN));
} else {
match run_ladder() {
Ok(o) => pending = Some(("fatal stall", residual, o)),
Err(e2) => panic!(
"{} coupling failed at step {step}: {e:?}; the coupling-level \
rescue's substep ladder {:?} failed too: {e2:?} (Newton rescues \
{:?})",
case.name,
super::rescue::LADDER,
flag.borrow().rescue_counts()
),
}
}
}
}
let prev_uy: Option<f64> = uy_series.last().copied();
if pending.is_none() && do_coarse.is_none() {
// `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;
// Kinematic trigger: the coupling accepted this step, but the
// committed tip moved beyond the march's own running statistic.
if let (true, Some(p95), Some(prev)) = (coupling_rescue, running_p95, prev_uy) {
let uy_new = flag_state.displacement[a_dofs[1]];
let jump = (uy_new - prev).abs();
if jump > super::rescue::TIP_JUMP_FACTOR * p95
&& coarse_episode > 0
&& step + 1 < coupled_steps
{
do_coarse = Some(("tip jump", jump, uy_new));
} else if jump > super::rescue::TIP_JUMP_FACTOR * p95 {
let accepted_solver = solver.borrow().snapshot();
let accepted_field = field.borrow().clone();
match run_ladder() {
Ok(o) => {
tip_rejected = uy_new;
pending = Some(("tip jump", jump, o));
}
Err(e2) => {
// Keep the step the coupling accepted; the
// ladder left the fluid mid-failure.
solver.borrow_mut().restore(&accepted_solver);
*field.borrow_mut() = accepted_field;
coupling_rescue_failures += 1;
println!(
" COUPLING RESCUE FAILED at step {step} t = {:.4}: tip jump \
{jump:.3e} > {} x running p95 {p95:.3e}; ladder {:?} ended \
{e2:?}; keeping the accepted step",
t_release + (step + 1) as f64 * dt,
super::rescue::TIP_JUMP_FACTOR,
super::rescue::LADDER
);
}
}
}
}
} else {
// A fatal stall never commits its last pass.
drop(latest.borrow_mut().take());
}
if let Some((reason, before, tip_rejected_c)) = do_coarse {
// Rung C: reject the step (the coarse step restores the saved
// start) and open an episode of 2dt steps.
coarse_now!(
reason,
before,
tip_rejected_c,
true,
&fluid_saved,
&field_saved,
step_start_state.as_ref().expect("rescue start state"),
step_start_nodal.as_deref().expect("rescue start load")
);
coarse_remaining = coarse_episode.saturating_sub(2);
step += 2;
continue;
}
let was_rescued = pending.is_some();
if let Some((trigger, before, o)) = pending {
flag_state = o.state;
committed_nodal = o.nodal;
worst_conservation = worst_conservation.max(o.worst_conservation);
total_skipped += o.skipped;
total_subiterations += o.passes;
stalled_steps += o.stalls;
worst_stall = worst_stall.max(o.worst_residual);
// The main coupler's secant columns predate the rejected step.
if let Some(iqn_ref) = iqn.as_mut() {
iqn_ref.reset_history();
}
coupling_rescues += 1;
rescue_steps.push(step);
let record = super::rescue::RescueRecord {
step,
t: t_release + (step + 1) as f64 * dt,
trigger,
before,
n: o.n,
passes: o.passes,
tip_rejected,
tip_rescued: flag_state.displacement[a_dofs[1]],
};
println!(
" COUPLING RESCUE at step {step} t = {:.4}: {trigger} ({before:.3e}) carried by \
n = {} substeps in {} passes ({} substep stalls, worst {:.3e}); tip {:+.4e} -> \
{:+.4e}",
record.t,
o.n,
o.passes,
o.stalls,
o.worst_residual,
tip_rejected,
record.tip_rescued
);
rescue_records.push(record);
let per_second = (1.0 / dt).round() as usize;
let recent = rescue_steps
.iter()
.filter(|&&s| step - s < per_second)
.count();
assert!(
recent <= super::rescue::RATE_CAP_PER_SECOND,
"{} coupling rescue rate cap: {recent} rescues within one second of march at \
step {step} — a runaway the substeps only delay is not to be hidden",
case.name
);
}
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]];
// Running p95 of the committed tip jump over the trailing window
// of non-rescued steps (the kinematic trigger's yardstick).
if coupling_rescue && !was_rescued {
if let Some(prev) = prev_uy {
jump_window.push_back((uy - prev).abs());
if jump_window.len() > super::rescue::TIP_JUMP_WINDOW {
jump_window.pop_front();
}
if jump_window.len() == super::rescue::TIP_JUMP_WINDOW {
let mut sorted: Vec<f64> = jump_window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
running_p95 = Some(sorted[(0.95 * sorted.len() as f64) as usize]);
}
}
}
if let Some(line) = trace_line.as_ref().filter(|_| outcome_ok) {
let jump = uy - uy_series.last().copied().unwrap_or(uy);
println!("{line}; committed tip jump {jump:+.3e} (uy {uy:+.4e})");
}
if let (Some(w), Some((passes, residual, stalled))) = (inc_trace_file.as_mut(), inc_record)
{
let jump = uy - uy_series.last().copied().unwrap_or(uy);
writeln!(
w,
"{step},{t:.6},{increment:.6e},{tol_step:.6e},{passes},{residual:.6e},{},{jump:.6e}",
u8::from(stalled)
)
.unwrap();
}
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);
}
}
if let Some(dir) = ffld_dir {
let cadence = if snap_every > 0 { snap_every } else { 10 };
if (step + 1) % cadence == 0 {
write_ffld_dump(
dir,
step + 1,
t,
&solver.borrow(),
&field.borrow(),
&harness,
);
}
}
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()
);
}
step += 1;
}
if speed_fraction > 0.0 {
println!(
" closure schedule (f = {speed_fraction}): {speed_episodes} speed episodes, \
{coarse_steps_taken} of {coupled_steps} coupled steps under the s = 2 closure ({:.1}%)",
100.0 * coarse_steps_taken as f64 / coupled_steps.max(1) as f64
);
}
let coupled_elapsed = phase_start.elapsed().as_secs_f64();
let (f, s, l) = (t_fluid.get(), t_structure.get(), t_sample.get());
let pct = |x: f64| 100.0 * x / coupled_elapsed.max(1e-9);
println!(
" wall split over the coupled phase: fluid {f:.0} s ({:.1}%), structure {s:.1} s \
({:.2}%), load sampling {l:.0} s ({:.1}%), state save {t_save:.0} s ({:.1}%), \
other {:.0} s ({:.1}%)",
pct(f),
pct(s),
pct(l),
pct(t_save),
coupled_elapsed - f - s - l - t_save,
pct(coupled_elapsed - f - s - l - t_save),
);
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(),
coupling_rescues,
coupling_rescue_failures,
rescue_records,
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).
/// One fluid-field dump (see `MarchConfig::ffld_dir`): the committed
/// `FlowField` bit-exact, the mask's fluid-cell map and the interface
/// polygon as text sidecars, and an `index.csv` line. Reporting-only.
fn write_ffld_dump(
dir: &str,
step: usize,
t: f64,
solver: &rtx_cfd::solvers::incompressible::EmbeddedPisoSolver,
field: &rtx_cfd::solvers::incompressible::FlowField,
harness: &super::Fsi2Harness,
) {
use std::io::Write as _;
let dir = std::path::Path::new(dir);
std::fs::create_dir_all(dir).expect("ffld dir");
let ffld_name = format!("f_{step:06}.ffld");
field.save(&dir.join(&ffld_name)).expect("ffld save");
let (nx, ny, _, _) = field.grid_info();
let mask = solver.mask().expect("mask");
let mut mask_text = String::with_capacity((nx + 1) * ny);
for j in 0..ny {
for i in 0..nx {
mask_text.push(if mask.is_fluid_cell(j, i) { '1' } else { '0' });
}
mask_text.push('\n');
}
std::fs::write(dir.join(format!("mask_{step:06}.txt")), mask_text).expect("mask sidecar");
let mut poly_text = String::new();
for &(x, y) in harness.shared.read().unwrap().0.vertices() {
use std::fmt::Write as _;
writeln!(poly_text, "{x:.9e} {y:.9e}").unwrap();
}
std::fs::write(dir.join(format!("poly_{step:06}.txt")), poly_text).expect("poly sidecar");
let mut index = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("index.csv"))
.expect("index.csv");
writeln!(index, "{step},{t:.9},{ffld_name}").expect("index line");
}
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();
}
}
}