Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/march.rs
T
Omar SobhandClaude Fable 5 c0666bf22a
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
rtx-fsi: C3 — FSI3 opened: the added-mass regime, its impulse artefact, and the coupler hygiene it demanded
The march is extracted from the FSI2 test into a shared
tests/fsi2_harness/march.rs (MarchConfig from env / MarchResult /
window statistics), the harness parameterised by BenchmarkCase (FSI2,
FSI3: inflow, solid density, modulus, rigid-flag drag reference), and
turek_hron_fsi3.rs written on top (Re 200, density ratio 1, E 5.6e6).
The committed FSI2 default is bit-identical through all of it —
every new knob defaults to FSI2's behaviour; re-verified twice to every
printed digit (uy 3.7732 / 3.7920 mm).

FSI3's first contact, traced pass by pass (RTX_FSI3_TRACE), exposed
the added-mass instability in its purest form and, one measured
mechanism at a time, what a partitioned coupling at unit density ratio
needs:

- C^1 interface motion (MarchConfig::c1_interface): a constant
  per-step interface velocity is a velocity JUMP at each step start,
  and the incompressible fluid answers with an impulsive added-mass
  load ~ rho L dv / dt_fluid (8x the physical reaction under
  subcycling): 1,600 N at release, 48,000 N and a 59 mm response one
  step later, the flag's Newton dead the pass after. Constant
  acceleration from the previous end velocity to 2 dd/dt - v_n removes
  the impulse (loads 1,700-2,400 N).
- IQN first-pass relaxation as a knob (initial_relaxation, FSI3 0.05):
  |1 - omega (1 + g)| must contract; 0.5 diverges past gain 3.
- The divergence verdict waits for the secant (IqnIls): the exploratory
  second pass on a high-gain map legitimately overshoots 10x before the
  first secant column exists. Pinned by a gain-40 model test.
- Kinematic predictor (predictor: "kinematic", velocity only): the
  structure-alone predictor ignores an added mass comparable to the
  flag's and overshoots 2-5x, drawing 5-6x loads every first pass; and
  NOT with the acceleration — Newmark average acceleration carries an
  inconsistent initial acceleration as a sign-alternating mode
  (d + dt v + dt^2 a / 2 predicted 22 mm at release; converged 0.14).
- Quiescent release (quiescent_release): the structure-alone
  "consistent" initial acceleration M^-1 F ignores the added mass.
- Coupler hygiene (IqnIls): a stalled or diverged step's secant columns
  are no longer retained (a bistable mask flip's columns extrapolated a
  30 mm interface jump on the next step); two-window stagnation
  detection reports a plateau early instead of bouncing to the budget
  (a single-window test misjudged a slowly converging step and is
  recorded as such); trust region tightened to 10x the residual. A
  noise-column filter at the tolerance was measured to HURT (stalled a
  converging step at 5.5e-4) and is disabled (threshold 0).
- The floor measured, not borrowed (fsi2_interface_noise.rs gains
  RTX_NOISE_CASE=fsi3): flip jumps 3.6e-4 (12x FSI2's), the subcycle-8
  release map stalling near 1e-6, the subcycle-2 map converging to
  9e-10; and through the release transient (flag at ~0.3 m/s) the
  subcycle-8 floor rides up to ~1e-3 with the motion — which moved the
  FSI3 default to subcycle 2 at a 3e-5 floor.

FSI3 committed default (ny 62, release t = 4, t_end 4.2, subcycle 2):
581 steps, 3.8 subit/step (max 8), 0 stalls, 0 retries, conservation
4.3e-13; uy 10.1 +- 27.5 mm and ux -3.1 +- 3.2 mm over the first 0.2 s
(reference cycle 1.45 +- 34.90, -2.86 +- 2.70). Machinery invariants
pinned; physics bands await the study record. Rigid-flag drag 426.9 vs
CFD3's 439.45 (-2.9%).

The FSI2 mode-2 study pin (IQN / subcycle 2, t_end >= 16) was measured
with the pre-hygiene coupler; re-verify on the next s = 2 study run.

48 lib tests green, clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lnyrw33Lu6rUhW42E9KHwq
2026-08-23 19:43:02 -05:00

556 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The coupled time march shared by the FSI2 and FSI3 tests: rigid-flag
//! phase, release, and the per-step subiterated (fluid ↔ flag Newmark)
//! coupling with its measured robustness machinery. Code motion from
//! the FSI2 test; the case parameters (inflow, solid density, modulus)
//! come from the [`BenchmarkCase`].
use std::cell::RefCell;
use std::io::Write as _;
use nalgebra::Vector3;
use rtx_cfd::solvers::incompressible::FlowField;
use rtx_fea::analysis::{
AnalysisConfig, ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis,
};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{MaterialId, NodeId};
use rtx_fsi::{IqnIls, Subiterated};
use super::{BenchmarkCase, Fsi2Harness, clamp_left, crossing_frequency, env_or, mid_amp};
/// Everything a march run is parameterised by. `from_env` reads the
/// `RTX_<PREFIX>_*` knobs over a set of defaults.
#[derive(Debug, Clone)]
pub struct MarchConfig {
pub ny: usize,
pub flag_nx: usize,
pub t_release: f64,
pub t_end: f64,
/// Fluid substeps per coupled step.
pub subcycle: usize,
/// Per-step interface tolerance: max(`tol_floor`, `rtol` x that
/// step's own interface increment). The floor is MEASURED per
/// configuration (`fsi2_interface_noise.rs`), not wished: the
/// step-to-step scatter of the accepted interface feeds the no-slip
/// closure a wall-velocity noise of tolerance / dt_c, so a tighter
/// coupling needs a proportionally tighter (and, as measured,
/// reachable) floor.
pub tol_floor: f64,
pub rtol: f64,
pub max_subiterations: usize,
/// `"aitken"` (per-step scalar Aitken) or `"iqn"` (a persistent
/// IQN-ILS whose secant history carries across steps).
pub coupler: String,
/// IQN secant history retained across steps.
pub reuse: usize,
/// Traction smoothing radius in multiples of the cell size (0 = off;
/// measured to change nothing that matters — see the probe).
pub smooth_in_h: f64,
pub csv_path: Option<String>,
/// IQN's relaxation on the very first pass, before any secant
/// information exists. Must CONTRACT a repulsive added-mass map: for
/// a per-pass gain `-g` the first update multiplies the residual by
/// `|1 - omega (1 + g)|`, so 0.5 diverges past g = 3 while 0.2 holds
/// to g = 9 (FSI3, density ratio 1, needed this — its first release
/// pass at 0.5 drove the flag's Newton to failure).
pub initial_relaxation: f64,
/// Print every pass's interface residual for the first `trace_steps`
/// coupled steps (diagnostics; 0 = off).
pub trace_steps: usize,
/// C^1 interface motion (constant acceleration across the step from
/// the previous end velocity) instead of a constant velocity with a
/// jump at the step boundary. See `Fsi2Harness::advance_subcycled`.
pub c1_interface: bool,
/// The per-step predictor: `"structure"` steps the flag alone under
/// the committed load (FSI2's, exact for a heavy flag), `"kinematic"`
/// extrapolates the CONVERGED interface velocity, d + dt v (velocity
/// only — see the predictor code for why not acceleration). At unit
/// density ratio the structure-alone predictor ignores an added mass
/// comparable to the flag's own and overshoots the motion 25x
/// (measured: 6.4e-4 predicted vs 1.4e-4 converged at FSI3's
/// release), and the C^1 ramp toward that excess draws a 56x load
/// (8,300 N vs ~1,300 N) — a violent first pass every step, one of
/// which pushed the flag's Newton onto a wrong branch.
pub predictor: String,
/// Release with the acceleration zeroed instead of the
/// structure-alone consistent initial acceleration M⁻¹F. That
/// acceleration ignores the added mass — at unit density ratio it
/// is wildly wrong (light tip nodes under 529 N of lift) and Newmark
/// average acceleration then carries it as a sign-alternating mode.
pub quiescent_release: bool,
}
impl MarchConfig {
/// Read `RTX_{prefix}_{NY,T_RELEASE,T_END,SUBCYCLE,TOL,RTOL,MAXSUB,
/// FLAG_NX,SMOOTH,COUPLER,REUSE,CSV}` over `defaults`.
pub fn from_env(prefix: &str, defaults: MarchConfig) -> MarchConfig {
let key = |name: &str| format!("RTX_{prefix}_{name}");
let num = |name: &str, default: f64| env_or(&key(name), default);
MarchConfig {
ny: num("NY", defaults.ny as f64) as usize,
flag_nx: num("FLAG_NX", defaults.flag_nx as f64) as usize,
t_release: num("T_RELEASE", defaults.t_release),
t_end: num("T_END", defaults.t_end),
subcycle: num("SUBCYCLE", defaults.subcycle as f64) as usize,
tol_floor: num("TOL", defaults.tol_floor),
rtol: num("RTOL", defaults.rtol),
max_subiterations: num("MAXSUB", defaults.max_subiterations as f64) as usize,
coupler: std::env::var(key("COUPLER")).unwrap_or(defaults.coupler),
reuse: num("REUSE", defaults.reuse as f64) as usize,
smooth_in_h: num("SMOOTH", defaults.smooth_in_h),
csv_path: std::env::var(key("CSV")).ok().or(defaults.csv_path),
initial_relaxation: num("OMEGA0", defaults.initial_relaxation),
trace_steps: num("TRACE", defaults.trace_steps as f64) as usize,
c1_interface: num("C1", f64::from(u8::from(defaults.c1_interface))) != 0.0,
predictor: std::env::var(key("PREDICTOR")).unwrap_or(defaults.predictor),
quiescent_release: num("QUIESCENT", f64::from(u8::from(defaults.quiescent_release)))
!= 0.0,
}
}
}
/// Statistics over a trailing window of a march.
#[derive(Debug, Clone, Copy)]
pub struct WindowStats {
pub t_start: f64,
pub uy_mid: f64,
pub uy_amp: f64,
pub ux_mid: f64,
pub ux_amp: f64,
pub frequency: Option<f64>,
pub drag_mid: f64,
pub drag_amp: f64,
pub lift_mid: f64,
pub lift_amp: f64,
/// uy amplitude over the first / last quarter of the coupled march.
pub amp_early: f64,
pub amp_late: f64,
}
/// What a march produced: the trajectories and the coupling bookkeeping.
#[derive(Debug, Clone)]
pub struct MarchResult {
pub dt: f64,
pub coupled_steps: usize,
pub times: Vec<f64>,
pub ux: Vec<f64>,
pub uy: Vec<f64>,
pub force_times: Vec<f64>,
pub drag: Vec<f64>,
pub lift: Vec<f64>,
pub rigid_drag: f64,
pub rigid_lift: f64,
pub mean_subiterations: f64,
pub max_subiterations: usize,
pub stalled_steps: usize,
pub retried_steps: usize,
pub worst_stall: f64,
pub worst_conservation: f64,
pub skipped: usize,
pub spiked: usize,
pub final_state_finite: bool,
pub elapsed: f64,
}
impl MarchResult {
/// Measure over the last `seconds` of the march (or the last half,
/// if the march is shorter).
pub fn window(&self, seconds: f64) -> WindowStats {
let t_end = *self.times.last().unwrap_or(&0.0);
let start = self
.times
.iter()
.position(|&t| t >= t_end - seconds)
.unwrap_or(self.times.len() / 2);
let (uy_mid, uy_amp) = mid_amp(&self.uy[start..]);
let (ux_mid, ux_amp) = mid_amp(&self.ux[start..]);
let frequency = crossing_frequency(&self.times[start..], &self.uy[start..]);
let force_start = self
.force_times
.iter()
.position(|&t| t >= t_end - seconds)
.unwrap_or(self.force_times.len() / 2);
let (drag_mid, drag_amp) = mid_amp(&self.drag[force_start..]);
let (lift_mid, lift_amp) = mid_amp(&self.lift[force_start..]);
let quarter = self.uy.len() / 4;
let (_, amp_early) = mid_amp(&self.uy[..quarter.max(1)]);
let (_, amp_late) = mid_amp(&self.uy[self.uy.len() - quarter.max(1)..]);
WindowStats {
t_start: self.times.get(start).copied().unwrap_or(0.0),
uy_mid,
uy_amp,
ux_mid,
ux_amp,
frequency,
drag_mid,
drag_amp,
lift_mid,
lift_amp,
amp_early,
amp_late,
}
}
}
/// Run the coupled march for a benchmark case.
///
/// Phase 1 marches the rigid flag to `t_release` (the fluid harness is
/// checked against the case's rigid-flag drag). Phase 2 releases the
/// flag at rest under the sampled load and marches to `t_end`: per step,
/// a structure-alone predictor, then the coupler drives the pass
/// (subcycled fluid on the candidate interface → sampled load → one flag
/// Newmark step from the committed state) to a fixed point. Robustness
/// machinery, each piece measured before it was written (see the FSI2
/// test's module docs): stall acceptance at the noise floor, IQN
/// history reset + one retry from the predictor, increment-scaled
/// acceptance for the rare violent step; genuine runaway still panics.
#[allow(clippy::too_many_lines)]
pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
let MarchConfig {
ny,
flag_nx,
t_release,
t_end,
subcycle,
tol_floor,
rtol,
max_subiterations: max_subiterations_budget,
ref coupler,
reuse,
smooth_in_h,
ref csv_path,
initial_relaxation,
trace_steps,
c1_interface,
ref predictor,
quiescent_release,
} = *config;
let (harness, mut solver, mut field) = Fsi2Harness::build_case(case, ny, flag_nx, smooth_in_h);
let dt_fluid = harness.dt_fluid;
let dt = dt_fluid * subcycle as f64;
let interface = &harness.interface;
let zero_d = vec![0.0; 2 * interface.wetted.len()];
// Phase 1: rigid flag to t_release.
let start = std::time::Instant::now();
let rigid_steps = (t_release / dt_fluid).round() as usize;
for _ in 0..rigid_steps {
futures::executor::block_on(solver.advance(&mut field, dt_fluid)).unwrap();
}
// The fluid harness check: surface drag on cylinder + flag against
// the rigid-flag CFD value on this geometry.
let (rigid_drag, rigid_lift) = harness.measure_force(&solver, &field);
println!(
" {} rigid phase: {rigid_steps} steps to t = {t_release:.1} s in {:.0} s wall; \
surface drag {rigid_drag:.1} (rigid-flag reference {:.1}), lift {rigid_lift:.1}",
case.name,
start.elapsed().as_secs_f64(),
case.rigid_drag_reference
);
// The flag: nonlinear Newmark stepper at the coupled dt.
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(case.e_s, case.nu_s).with_density(case.rho_s),
None,
);
// A deep Newton budget: a mid-swing subiteration can hand the flag a
// large sudden load change (the coupled lift swings hundreds of N
// within a period); typical steps converge in 1-2 iterations, and a
// t = 25.8 s failure at the default budget of 25 is what set this.
let analysis = NonlinearDynamicAnalysis::new(
harness.mesh.clone(),
db,
clamp_left(&harness.mesh),
dt,
1,
AnalysisConfig::default(),
)
.with_total_lagrangian()
.with_convergence_criteria(ConvergenceCriteria {
max_iterations: 60,
..ConvergenceCriteria::default()
});
let flag = RefCell::new(analysis.stepper().unwrap());
let wetted_dofs: Vec<[usize; 2]> = interface
.wetted
.iter()
.map(|&id| {
let dofs = flag.borrow().node_dofs(id);
[dofs[0], dofs[1]]
})
.collect();
let a_dofs = flag.borrow().node_dofs(harness.a_node);
let extract = |state: &DynamicState| -> Vec<f64> {
let mut d = vec![0.0; 2 * wetted_dofs.len()];
for (k, dofs) in wetted_dofs.iter().enumerate() {
d[2 * k] = state.displacement[dofs[0]];
d[2 * k + 1] = state.displacement[dofs[1]];
}
d
};
let extract_velocity = |state: &DynamicState| -> Vec<f64> {
let mut v = vec![0.0; 2 * wetted_dofs.len()];
for (k, dofs) in wetted_dofs.iter().enumerate() {
v[2 * k] = state.velocity[dofs[0]];
v[2 * k + 1] = state.velocity[dofs[1]];
}
v
};
// Phase 2: release. The flag starts at rest under the current fluid
// load (consistent initial acceleration — the step response about the
// steady deflection is the seed perturbation for the instability).
let (nodal0, conservation0, _) = harness.sample_load(&solver, &field, &zero_d);
flag.borrow_mut().set_nodal_forces(&nodal0);
let mut flag_state = flag.borrow_mut().rest_state().unwrap();
if quiescent_release {
flag_state.acceleration.fill(0.0);
}
let mut committed_nodal = nodal0;
let mut worst_conservation = conservation0;
let solver = RefCell::new(solver);
let field = RefCell::new(field);
// The interface driver: per-step Aitken, or a persistent IQN-ILS
// whose secant history carries across steps.
let mut iqn = (coupler == "iqn").then(|| {
IqnIls::new(max_subiterations_budget, 1.0)
.unwrap()
.with_reuse(reuse)
.with_initial_relaxation(initial_relaxation)
.unwrap()
});
let coupled_steps = ((t_end - t_release) / dt).round() as usize;
let mut times = Vec::with_capacity(coupled_steps);
let mut ux_series = Vec::with_capacity(coupled_steps);
let mut uy_series = Vec::with_capacity(coupled_steps);
let mut total_subiterations = 0usize;
let mut max_subiterations = 0usize;
let mut total_skipped = 0usize;
let mut stalled_steps = 0usize;
let mut retried_steps = 0usize;
let mut worst_stall = 0.0f64;
let mut force_times: Vec<f64> = Vec::new();
let mut drag_series: Vec<f64> = Vec::new();
let mut lift_series: Vec<f64> = Vec::new();
let mut csv = csv_path
.as_ref()
.map(|p| std::fs::File::create(p).expect("csv path"));
let phase_start = std::time::Instant::now();
for step in 0..coupled_steps {
let d_n = extract(&flag_state);
let v_n: Option<Vec<f64>> = c1_interface.then(|| extract_velocity(&flag_state));
// Predictor (see `MarchConfig::predictor`).
let d_predicted = if predictor == "kinematic" {
// Velocity only. The converged velocity is clean (Newmark's
// trapezoidal update sums consecutive accelerations), but the
// ACCELERATION is not: average acceleration carries an
// inconsistent initial acceleration as a sign-alternating
// mode step after step, and extrapolating it predicted 22 mm
// at FSI3's release (converged: 0.14 mm).
let v = extract_velocity(&flag_state);
d_n.iter().zip(&v).map(|(d, v)| d + dt * v).collect()
} else {
// The structure alone under the committed load.
flag.borrow_mut().set_nodal_forces(&committed_nodal);
let (predicted, _) = flag.borrow_mut().step(&flag_state).unwrap();
extract(&predicted)
};
let fluid_saved = solver.borrow().snapshot();
let field_saved = field.borrow().clone();
type PassResult = (
FlowField,
DynamicState,
Vec<(NodeId, Vector3<f64>)>,
f64,
usize,
);
let latest: RefCell<Option<PassResult>> = RefCell::new(None);
let pass = |d_candidate: &[f64]| -> Vec<f64> {
// Subcycled fluid steps from the SAME start-of-step state,
// geometry interpolated to each substep's end time, interface
// velocity of THIS candidate constant over the step.
let mut solver_ref = solver.borrow_mut();
solver_ref.restore(&fluid_saved);
let mut trial_field = field_saved.clone();
harness.advance_subcycled(
&mut solver_ref,
&mut trial_field,
&d_n,
d_candidate,
subcycle,
v_n.as_deref(),
);
// Load on the candidate geometry, flag answers from the
// committed state.
let (nodal, conservation, skipped) =
harness.sample_load(&solver_ref, &trial_field, d_candidate);
if step < trace_steps {
let load: f64 = nodal.iter().map(|(_, f)| f.norm()).sum();
let peak = nodal.iter().map(|(_, f)| f.norm()).fold(0.0, f64::max);
println!(
" step {step} pass: candidate |d| = {:.3e}, sampled load: total nodal |F| \
= {load:.2}, peak nodal |F| = {peak:.2}",
d_candidate.iter().map(|v| v * v).sum::<f64>().sqrt()
);
}
let mut flag_ref = flag.borrow_mut();
flag_ref.set_nodal_forces(&nodal);
let (candidate_state, _) = flag_ref.step(&flag_state).unwrap();
let d_new = extract(&candidate_state);
if step < trace_steps {
let residual: f64 = d_new
.iter()
.zip(d_candidate)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
println!(
" step {step} pass: |d_new - d_candidate| = {residual:.3e}, |d_new| = {:.3e}",
d_new.iter().map(|v| v * v).sum::<f64>().sqrt()
);
}
*latest.borrow_mut() =
Some((trial_field, candidate_state, nodal, conservation, skipped));
d_new
};
let increment: f64 = d_predicted
.iter()
.zip(&d_n)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let tol_step = tol_floor.max(rtol * increment);
// Acceptance beyond the tolerance: 5x the tolerance (noise
// bouncing over a well-predicted step) or an order below the
// step's own increment (the rare violent step near peak motion —
// the s = 1 FSI2 run died at residual = 9% of its increment).
// Counted as stalls and bounded by the caller.
let acceptable = (5.0 * tol_step).max(0.1 * increment);
let mut outcome = if let Some(iqn) = iqn.as_mut() {
iqn.set_tolerance(tol_step).unwrap();
iqn.solve(&d_predicted, pass)
} else {
Subiterated::aitken(max_subiterations_budget, tol_step)
.unwrap()
.solve(&d_predicted, pass)
};
// Stale-history recovery: cross-step secant columns assume the
// interface Jacobian drifts slowly; during a rapid resonant
// growth they can steer the quasi-Newton update into an
// overshoot the divergence guard reads as added mass (measured:
// a first residual of 1e-4 driven to 1e-3 by the first update at
// 2.7x the previously seen amplitude). The map itself converges
// deeply from a clean start, so: reset the history and retry the
// step ONCE from the predictor. Aitken carries no history — a
// retry would repeat the identical iteration — so IQN-only.
if let (Err(e), Some(iqn_ref)) = (&outcome, iqn.as_mut()) {
let recoverable = matches!(
e,
rtx_fsi::FsiError::CouplingNotConverged { residual, .. }
| rtx_fsi::FsiError::CouplingDiverged { residual, .. }
if *residual >= acceptable
);
if recoverable {
iqn_ref.reset_history();
retried_steps += 1;
outcome = iqn_ref.solve(&d_predicted, pass);
}
}
match outcome {
Ok(converged) => {
total_subiterations += converged.iterations;
max_subiterations = max_subiterations.max(converged.iterations);
}
Err(
rtx_fsi::FsiError::CouplingNotConverged {
iterations,
residual,
..
}
| rtx_fsi::FsiError::CouplingDiverged {
iterations,
residual,
},
) if residual < acceptable => {
stalled_steps += 1;
worst_stall = worst_stall.max(residual);
total_subiterations += iterations;
max_subiterations = max_subiterations.max(iterations);
}
Err(e) => panic!("{} coupling failed at step {step}: {e:?}", case.name),
}
// `latest` holds the response to the accepted interface (the last
// pass) — commit it directly; the fluid, mask and flag are
// consistent with that interface without an extra pass.
let (new_field, new_flag_state, nodal, conservation, skipped) =
latest.borrow_mut().take().expect("pass ran");
*field.borrow_mut() = new_field;
flag_state = new_flag_state;
committed_nodal = nodal;
worst_conservation = worst_conservation.max(conservation);
total_skipped += skipped;
let t = t_release + (step + 1) as f64 * dt;
let ux = flag_state.displacement[a_dofs[0]];
let uy = flag_state.displacement[a_dofs[1]];
times.push(t);
ux_series.push(ux);
uy_series.push(uy);
if (step + 1) % 10 == 0 {
let (drag, lift) = harness.measure_force(&solver.borrow(), &field.borrow());
force_times.push(t);
drag_series.push(drag);
lift_series.push(lift);
if let Some(file) = csv.as_mut() {
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},{drag:.6e},{lift:.6e}").unwrap();
}
} else if let Some(file) = csv.as_mut() {
writeln!(file, "{t:.6},{ux:.6e},{uy:.6e},,").unwrap();
}
if (step + 1) % 1000 == 0 {
let window = &uy_series[uy_series.len().saturating_sub(1000)..];
let (w_mid, w_amp) = mid_amp(window);
println!(
" t = {t:.3} s ({step} steps): uy(A) = {uy:.3e} (window mid {w_mid:.3e} \
amp {w_amp:.3e}), {:.1} subit/step, {:.0} s wall",
total_subiterations as f64 / (step + 1) as f64,
phase_start.elapsed().as_secs_f64()
);
}
}
MarchResult {
dt,
coupled_steps,
times,
ux: ux_series,
uy: uy_series,
force_times,
drag: drag_series,
lift: lift_series,
rigid_drag,
rigid_lift,
mean_subiterations: total_subiterations as f64 / coupled_steps.max(1) as f64,
max_subiterations,
stalled_steps,
retried_steps,
worst_stall,
worst_conservation,
skipped: total_skipped,
spiked: harness.spiked_total.get(),
final_state_finite: flag_state.displacement.iter().all(|v| v.is_finite()),
elapsed: start.elapsed().as_secs_f64(),
}
}