//! 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__*` 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, /// 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, 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, /// 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=`, 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, /// Rung A′ (`RTX_{prefix}_CRESCUE_INC=`, 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=`, 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::().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, 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, pub ux: Vec, pub uy: Vec, pub force_times: Vec, pub drag: Vec, pub lift: Vec, 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, 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 { 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 { 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 = Vec::new(); let mut drag_series: Vec = Vec::new(); let mut lift_series: Vec = 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 = Vec::new(); let mut interval_lift: Vec = 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 = Vec::new(); let mut rescue_steps: Vec = Vec::new(); let mut jump_window: std::collections::VecDeque = std::collections::VecDeque::new(); let mut running_p95: Option = 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 = Vec::new(); // Rung A′: the trailing window of predictor increments and its median. let mut inc_window: std::collections::VecDeque = std::collections::VecDeque::new(); let mut running_inc_median: Option = 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> = 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, usize, ); let latest: RefCell> = RefCell::new(None); let pass = |d_candidate: &[f64]| -> Vec { // 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::().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::() .sqrt(); println!( " step {step} pass: |d_new - d_candidate| = {residual:.3e}, |d_new| = {:.3e}", d_new.iter().map(|v| v * v).sum::().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::() .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 = 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 { 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 = 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 = 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, t: f64, state: &DynamicState, nodal: &[(NodeId, Vector3)], ) { 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(); } } }