CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build CPU-Only (Explicit) (push) Failing after 5s
Documentation / Build API Documentation (push) Failing after 5s
Documentation / Build User Guide (push) Successful in 5s
CI / Format Check (push) Failing after 16s
CI / Build (ubuntu-latest) (push) Failing after 2m18s
CI / Clippy Check (push) Failing after 2m38s
Performance Benchmarks / Run Benchmarks (push) Successful in 4m13s
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
1361 lines
62 KiB
Rust
1361 lines
62 KiB
Rust
//! 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 10–70× the march's own
|
||
/// p95 for 5–37 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 5–37 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 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,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)");
|
||
}
|
||
// PERF-2: `RTX_FSI2_MG_RB=1` — the red-black smoother on the embedded
|
||
// solver (the noise probe's regime gate).
|
||
if std::env::var("RTX_FSI2_MG_RB").is_ok_and(|v| v == "1") {
|
||
solver.set_poisson_smoother(rtx_cfd::solvers::incompressible::MgSmoother::RedBlack);
|
||
println!(
|
||
" multigrid smoother: RED-BLACK symmetric Gauss–Seidel (PERF-2 regime, RTX_FSI2_MG_RB)"
|
||
);
|
||
}
|
||
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();
|
||
}
|
||
}
|
||
}
|