Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi2_harness/overset_march.rs
T
Omar SobhandClaude Fable 5.1 a2086a59de
CI / Format Check (push) Failing after 7s
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 / Clippy Check (push) Failing after 5s
CI / Build (ubuntu-latest) (push) Failing after 5s
Performance Benchmarks / Run Benchmarks (push) Failing after 6s
Documentation / Build User Guide (push) Successful in 6s
Documentation / Build API Documentation (push) Failing after 25s
CI / Build CPU-Only (Explicit) (push) Failing after 1m15s
P6-b: the fictitious added mass for the partitioned loop — rtx-fea NonlinearDynamicStepper::set_added_lumped_mass (a lumped per-DOF mass in the Newmark inertial residual and effective tangent, never the consistent mass or the rest state; zero = the plain stepper bit for bit) with its pin (compensated step reproduces the plain step to 2.5e-9, uncompensated moves it 11 %); the FSI2 overset harness carries RTX_FSI2O_FICT_MASS=α (α × ρ_f π (c/2)² spread over the wetted nodes) and adds the compensating load M_f ü_k of the previous subiterate to every structure solve (predictor and passes), printed in the header
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
2026-09-15 08:30:55 -05:00

580 lines
25 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.
//! P5 (§5.12): the coupled march on the overset fluid — the harness's
//! rigid phase, release, and per-step subiterated coupling (predictor,
//! IQN-ILS / Aitken passes each re-marching the fluid from the step's
//! snapshot, the acceptance rule of `march.rs`), without the embedded
//! march's rescue machinery (refuted, retired). Returns the harness's
//! `MarchResult` plus the composite's own counters.
use std::cell::RefCell;
use std::io::Write as _;
use nalgebra::Vector3;
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::march::MarchResult;
use super::overset::OversetFluid;
use super::{BenchmarkCase, clamp_left, env_or, median, mid_amp};
/// The overset march's knobs (`RTX_<PREFIX>_*`).
#[derive(Debug, Clone)]
pub struct OversetMarchConfig {
pub ny: usize,
pub flag_nx: usize,
pub t_release: f64,
pub t_end: f64,
pub subcycle: usize,
pub tol_floor: f64,
pub rtol: f64,
pub stall_accept: f64,
pub max_subiterations: usize,
pub coupler: String,
pub reuse: usize,
pub initial_relaxation: f64,
pub c1_interface: bool,
pub predictor: String,
/// Winslow sweeps per patch regeneration.
pub sweeps: usize,
/// Schwarz rounds per corrector (the P5 budget: 3).
pub max_rounds: usize,
pub csv_path: Option<String>,
pub trace_steps: usize,
}
impl OversetMarchConfig {
pub fn from_env(prefix: &str, d: OversetMarchConfig) -> OversetMarchConfig {
let num = |k: &str, v: f64| env_or(&format!("RTX_{prefix}_{k}"), v);
let text =
|k: &str, v: &str| std::env::var(format!("RTX_{prefix}_{k}")).unwrap_or(v.into());
OversetMarchConfig {
ny: num("NY", d.ny as f64) as usize,
flag_nx: num("FLAG_NX", d.flag_nx as f64) as usize,
t_release: num("T_RELEASE", d.t_release),
t_end: num("T_END", d.t_end),
subcycle: num("SUBCYCLE", d.subcycle as f64) as usize,
tol_floor: num("TOL_FLOOR", d.tol_floor),
rtol: num("RTOL", d.rtol),
stall_accept: num("STALL_ACCEPT", d.stall_accept),
max_subiterations: num("MAX_SUBIT", d.max_subiterations as f64) as usize,
coupler: text("COUPLER", &d.coupler),
reuse: num("REUSE", d.reuse as f64) as usize,
initial_relaxation: num("OMEGA0", d.initial_relaxation),
c1_interface: num("C1", if d.c1_interface { 1.0 } else { 0.0 }) > 0.5,
predictor: text("PREDICTOR", &d.predictor),
sweeps: num("SWEEPS", d.sweeps as f64) as usize,
max_rounds: num("MAX_ROUNDS", d.max_rounds as f64) as usize,
csv_path: std::env::var(format!("RTX_{prefix}_CSV"))
.ok()
.or(d.csv_path),
trace_steps: num("TRACE", d.trace_steps as f64) as usize,
}
}
}
/// The march's outcome: the harness's statistics, the composite's
/// counters, and the death (if any) instead of a panic.
pub struct OversetMarchResult {
pub result: MarchResult,
pub death: Option<(usize, f64, String)>,
pub rounds_mean: f64,
pub reclassified_mean: f64,
pub fresh_mean: f64,
pub regen_count: usize,
pub regen_seconds: f64,
pub fluid_seconds: f64,
pub structure_seconds: f64,
pub faces_used: usize,
}
pub fn run_march_overset(case: BenchmarkCase, config: &OversetMarchConfig) -> OversetMarchResult {
let cfg = config.clone();
let mut fluid = OversetFluid::build_case(case, cfg.ny, cfg.flag_nx, cfg.sweeps, cfg.max_rounds)
.expect("overset fluid");
let dt_fluid = fluid.dt_fluid;
let dt = dt_fluid * cfg.subcycle as f64;
let zero_d = vec![0.0; 2 * fluid.interface.wetted.len()];
// Every setting the acceptance rule reads, printed once: P5-3 lost a
// day to a floor of 2e-4 against the overnight marches' 1e-6.
println!(
" coupling: {} (reuse {}, ω0 {}, c1 {}), floor {:.1e}, rtol {:.1e}, stall accept {:.1e}, max subit {}, predictor {}, s = {}, patch offset {} h × {} rows, patch convection {:?}, bg convection {:?}, patch stretch {}, fillet {} m, tip corner {} m, fict mass α {}",
cfg.coupler,
cfg.reuse,
cfg.initial_relaxation,
cfg.c1_interface,
cfg.tol_floor,
cfg.rtol,
cfg.stall_accept,
cfg.max_subiterations,
cfg.predictor,
cfg.subcycle,
std::env::var("RTX_FSI2O_PATCH_OFFSET").unwrap_or_else(|_| "6".into()),
std::env::var("RTX_FSI2O_PATCH_ROWS").unwrap_or_else(|_| "12".into()),
super::overset::patch_convection(),
super::overset::bg_convection(),
super::overset::patch_stretch(),
super::overset::fillet(),
super::overset::tip_corner(),
std::env::var("RTX_FSI2O_FICT_MASS").unwrap_or_else(|_| "0".into()),
);
// Phase 1: rigid flag to t_release (`RTX_FSI2O_LOAD=dir` replaces the
// march with the saved state; `RTX_FSI2O_SAVE=dir` saves it).
let start = std::time::Instant::now();
let rigid_steps = (cfg.t_release / dt_fluid).round() as usize;
if let Ok(dir) = std::env::var("RTX_FSI2O_LOAD") {
let t = fluid.load(&dir).expect("load");
assert!(
(t - cfg.t_release).abs() < dt_fluid,
"the saved state is at t = {t}, not t_release = {}",
cfg.t_release
);
// The saved state is the FIELDS; the solvers' own warm state (the
// Schwarz acceptor warm start, the fringe flux correction, the
// embedded body's old volume fractions) is not in it, and a
// release straight off the load carries a pressure-level
// transient that the live march does not (P5-3: drag 27 vs 132
// twenty steps in, lift ± 1000 vs ± 4 — enough to kick the flag's
// thickness breathing at ny = 62). `RTX_FSI2O_LOAD_SETTLE=N`
// (default 20) rigid steps rebuild that state on the steady rigid
// flow; the clock is put back so the runs stay comparable.
let settle: usize = std::env::var("RTX_FSI2O_LOAD_SETTLE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let (d0, l0) = fluid.measure_force();
for _ in 0..settle {
fluid.step().expect("settle fluid step");
}
fluid.solver.set_time(t);
let (d1, l1) = fluid.measure_force();
println!(
" settled the loaded state with {settle} rigid steps: wall drag {d0:.2}{d1:.2}, lift {l0:.2}{l1:.2}; clock back to t = {t:.4}"
);
} else {
for _ in 0..rigid_steps {
fluid.step().expect("rigid fluid step");
}
if let Ok(dir) = std::env::var("RTX_FSI2O_SAVE") {
fluid.save(&dir).expect("save");
}
}
// The release time is the fluid's clock (the rigid step count rounds).
let t_release = fluid.time();
fluid.commit_base();
let (rigid_drag, rigid_lift) = fluid.measure_force();
println!(
" {} OVERSET rigid phase: {rigid_steps} steps (dt {dt_fluid:.3e}) to t = {:.2} s in {:.0} s wall; wall drag {rigid_drag:.2} (rigid-flag reference {:.1}; the overset's own CFD2 at ny = 41: 137.8), lift {rigid_lift:.2}; rounds mean {:.2}",
case.name,
cfg.t_release,
start.elapsed().as_secs_f64(),
case.rigid_drag_reference,
fluid.rounds_total.get() as f64 / fluid.correctors_total.get().max(1) as f64
);
// The flag: nonlinear Newmark stepper at the coupled dt (as march.rs).
let mut db = MaterialDatabase::new();
db.add_material(
MaterialId(0),
LinearElastic::new(case.e_s, case.nu_s).with_density(case.rho_s),
None,
);
let analysis = NonlinearDynamicAnalysis::new(
fluid.mesh.clone(),
db,
clamp_left(&fluid.mesh),
dt,
1,
AnalysisConfig::default(),
)
.with_total_lagrangian()
.with_convergence_criteria(ConvergenceCriteria {
max_iterations: 60,
..ConvergenceCriteria::default()
});
// `RTX_FSI2O_NEWMARK_GAMMA=γ` (β = (γ + ½)²/4): numerical dissipation
// of the flag's high-frequency modes (the thickness breathing the
// body-fitted wall couples to at s = 1); the benchmark's average
// acceleration (γ = ½) has none.
let analysis = match std::env::var("RTX_FSI2O_NEWMARK_GAMMA")
.ok()
.and_then(|v| v.parse::<f64>().ok())
{
Some(g) => {
let b = (g + 0.5).powi(2) / 4.0;
println!(" Newmark γ = {g}, β = {b:.4}");
analysis.with_newmark_parameters(g, b)
}
None => analysis,
};
let flag = RefCell::new(analysis.stepper().unwrap());
let wetted_dofs: Vec<[usize; 2]> = fluid
.interface
.wetted
.iter()
.map(|&id| {
let dofs = flag.borrow().node_dofs(id);
[dofs[0], dofs[1]]
})
.collect();
let a_dofs = flag.borrow().node_dofs(fluid.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
};
let extract_accel = |state: &DynamicState| -> Vec<f64> {
let mut a = vec![0.0; 2 * wetted_dofs.len()];
for (k, dofs) in wetted_dofs.iter().enumerate() {
a[2 * k] = state.acceleration[dofs[0]];
a[2 * k + 1] = state.acceleration[dofs[1]];
}
a
};
// P6-b (`docs/overset_metal_campaign.md` §5.17): the fictitious added
// mass — `RTX_FSI2O_FICT_MASS=α` puts α × ρ_f π (c/2)² (the flag's heave
// added mass per unit depth, c = 0.35) as a lumped mass spread over the
// wetted nodes, and every structure solve carries the compensating load
// M_f ü_k of the previous subiterate, so the fixed point is unchanged
// and the loop contracts at any mass ratio. α = 0 is the plain loop.
let fict_alpha: f64 = std::env::var("RTX_FSI2O_FICT_MASS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
let fict_per_node = fict_alpha * 1000.0 * std::f64::consts::PI * (0.35_f64 / 2.0).powi(2)
/ wetted_dofs.len() as f64;
let wetted_nodes: Vec<NodeId> = fluid.interface.wetted.clone();
if fict_alpha != 0.0 {
let entries: Vec<(NodeId, f64)> =
wetted_nodes.iter().map(|&n| (n, fict_per_node)).collect();
flag.borrow_mut().set_added_lumped_mass(&entries);
println!(
" fictitious added mass: α = {fict_alpha}, {:.3} kg per wetted node ({} nodes, {:.1} kg total)",
fict_per_node,
wetted_dofs.len(),
fict_per_node * wetted_dofs.len() as f64
);
}
// The load with the compensating term for a given previous-subiterate acceleration.
let with_fict =
|nodal: &[(NodeId, Vector3<f64>)], accel: &[f64]| -> Vec<(NodeId, Vector3<f64>)> {
if fict_alpha == 0.0 {
return nodal.to_vec();
}
let mut out = nodal.to_vec();
for (k, &n) in wetted_nodes.iter().enumerate() {
out.push((
n,
Vector3::new(
fict_per_node * accel[2 * k],
fict_per_node * accel[2 * k + 1],
0.0,
),
));
}
out
};
// Phase 2: release under the current load.
let (nodal0, conservation0, faces0) = fluid.sample_load(&zero_d);
flag.borrow_mut().set_nodal_forces(&nodal0);
let mut flag_state = flag.borrow_mut().rest_state().unwrap();
let mut committed_nodal = nodal0;
let mut worst_conservation = conservation0;
println!(
" release: {faces0} wall faces transferred (conservation defect {conservation0:.2e}); initial tip acceleration |a| = {:.3e}",
(a_dofs
.iter()
.map(|&k| flag_state.acceleration[k].powi(2))
.sum::<f64>())
.sqrt()
);
let fluid = RefCell::new(fluid);
let mut iqn = (cfg.coupler == "iqn").then(|| {
IqnIls::new(cfg.max_subiterations, 1.0)
.unwrap()
.with_reuse(cfg.reuse)
.with_initial_relaxation(cfg.initial_relaxation)
.unwrap()
});
let coupled_steps = ((cfg.t_end - cfg.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 force_times = Vec::new();
let mut drag_series = Vec::new();
let mut lift_series = Vec::new();
let (mut interval_drag, mut interval_lift) = (Vec::new(), Vec::new());
let mut total_subiterations = 0usize;
let mut max_subiterations = 0usize;
let mut stalled_steps = 0usize;
let mut retried_steps = 0usize;
let mut worst_stall = 0.0_f64;
let mut faces_used = faces0;
let mut death: Option<(usize, f64, String)> = None;
let mut csv = cfg.csv_path.as_ref().map(|p| {
let mut f = std::fs::File::create(p).expect("csv");
writeln!(f, "t,ux,uy,drag,lift").unwrap();
f
});
let (t_fluid, t_structure) = (std::cell::Cell::new(0.0_f64), std::cell::Cell::new(0.0_f64));
let prev_area = std::cell::Cell::new(fluid.borrow().shared.read().unwrap().area());
let save_from: f64 = std::env::var("RTX_FSI2O_SAVE_FROM")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
let save_every: usize = std::env::var("RTX_FSI2O_SAVE_EVERY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let phase_start = std::time::Instant::now();
// The previous subiterate's interface acceleration (the fictitious mass's
// compensating load): the committed state's at each step's start.
let last_accel: RefCell<Vec<f64>> = RefCell::new(extract_accel(&flag_state));
for step in 0..coupled_steps {
last_accel.replace(extract_accel(&flag_state));
let d_n = extract(&flag_state);
let v_n: Option<Vec<f64>> = cfg.c1_interface.then(|| extract_velocity(&flag_state));
let d_predicted = if cfg.predictor == "kinematic" {
let v = extract_velocity(&flag_state);
d_n.iter().zip(&v).map(|(d, v)| d + dt * v).collect()
} else {
flag.borrow_mut()
.set_nodal_forces(&with_fict(&committed_nodal, &extract_accel(&flag_state)));
let (predicted, _) = flag.borrow_mut().step(&flag_state).unwrap();
last_accel.replace(extract_accel(&predicted));
extract(&predicted)
};
let saved = fluid.borrow().snapshot();
type PassResult = (DynamicState, Vec<(NodeId, Vector3<f64>)>, f64, usize);
let latest: RefCell<Option<PassResult>> = RefCell::new(None);
let pass = |d_candidate: &[f64]| -> Vec<f64> {
let fs = std::time::Instant::now();
let mut fl = fluid.borrow_mut();
fl.restore(&saved);
fl.advance_subcycled(&d_n, d_candidate, cfg.subcycle, v_n.as_deref())
.expect("fluid pass");
let (nodal, conservation, faces) = fl.sample_load(d_candidate);
t_fluid.set(t_fluid.get() + fs.elapsed().as_secs_f64());
let ss = std::time::Instant::now();
let mut flag_ref = flag.borrow_mut();
flag_ref.set_nodal_forces(&with_fict(&nodal, &last_accel.borrow()));
let (candidate_state, _) = flag_ref.step(&flag_state).unwrap();
last_accel.replace(extract_accel(&candidate_state));
t_structure.set(t_structure.get() + ss.elapsed().as_secs_f64());
let d_new = extract(&candidate_state);
if step < cfg.trace_steps {
let residual: f64 = d_new
.iter()
.zip(d_candidate)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let load: f64 = nodal.iter().map(|(_, f)| f.norm()).sum();
// The fluid's power on the flag at this candidate: Σ F · v
// with v the candidate's mean interface velocity. Physical
// damping is negative; a spurious velocity-proportional
// reaction shows as positive power growing with v².
let (mut power, mut fx, mut fy) = (0.0, 0.0, 0.0);
for (k, (_, f)) in nodal.iter().enumerate() {
let vx = (d_candidate[2 * k] - d_n[2 * k]) / dt;
let vy = (d_candidate[2 * k + 1] - d_n[2 * k + 1]) / dt;
power += f.x * vx + f.y * vy;
fx += f.x;
fy += f.y;
}
let (pd, bd, div) = fl.last_defects.get();
let (rough, flips, tn_max, tn_mean) = fl.wall_roughness();
let (level, wall_flux, wall_area, poly_flux, poly_area) = fl.level_and_wall_flux();
let area_rate = (poly_area - prev_area.get()) / dt;
println!(
" step {step} pass: |d_new d_candidate| = {residual:.3e}, |d_new| = {:.3e}, nodal Σ|F| {load:.2} ΣF ({fx:+.2}, {fy:+.2}), power {power:+.3e} W/m, wall t_n roughness {rough:.2} ({flips} flips, max {tn_max:.1} mean {tn_mean:.1} Pa), patch p level {level:+.1} Pa, wall net flux {wall_flux:+.3e} m²/s over {wall_area:.3} m (polygon {poly_flux:+.3e}; flag area rate {area_rate:+.3e}), patch mass defect {pd:.2e} bg {bd:.2e} div {div:.1e}, {faces} faces",
d_new.iter().map(|v| v * v).sum::<f64>().sqrt()
);
}
*latest.borrow_mut() = Some((candidate_state, nodal, conservation, faces));
d_new
};
let increment: f64 = d_predicted
.iter()
.zip(&d_n)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
let tol_step = cfg.tol_floor.max(cfg.rtol * increment);
let retry_at = (5.0 * tol_step).max(0.1 * increment);
let acceptable = (cfg.stall_accept * tol_step).max(0.1 * increment);
let mut outcome = if let Some(iqn) = iqn.as_mut() {
iqn.set_tolerance(tol_step).unwrap();
iqn.solve(&d_predicted, pass)
} else {
Subiterated::aitken(cfg.max_subiterations, tol_step)
.unwrap()
.solve(&d_predicted, pass)
};
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 t_now = t_release + (step + 1) as f64 * dt;
match outcome {
Ok(c) => {
total_subiterations += c.iterations;
max_subiterations = max_subiterations.max(c.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) => {
println!(
" {} OVERSET DEATH at step {step} t = {t_now:.4}: {e:?} (increment {increment:.3e}, tol {tol_step:.3e}, acceptable {acceptable:.3e})",
case.name
);
death = Some((step, t_now, format!("{e:?}")));
break;
}
}
let (new_state, nodal, conservation, faces) = latest.borrow_mut().take().expect("pass ran");
flag_state = new_state;
committed_nodal = nodal;
prev_area.set(fluid.borrow().shared.read().unwrap().area());
fluid.borrow_mut().commit_base();
// `RTX_FSI2O_SAVE_EVERY=N` (+ `RTX_FSI2O_SAVE`): the composite at
// every N-th committed step, for the offline chain audit.
// `RTX_FSI2O_SAVE_FROM=t` (0): instants only from t on — a dense
// last period for the viewer without a gigabyte of the onset.
if save_every > 0 && (step + 1) % save_every == 0 && t_now >= save_from {
if let Ok(dir) = std::env::var("RTX_FSI2O_SAVE") {
let d_now = extract(&flag_state);
let dd_now = extract_velocity(&flag_state);
fluid
.borrow()
.save_instant(&dir, step + 1, &d_now, &dd_now)
.expect("save instant");
}
}
worst_conservation = worst_conservation.max(conservation);
faces_used = faces;
let ux = flag_state.displacement[a_dofs[0]];
let uy = flag_state.displacement[a_dofs[1]];
times.push(t_now);
ux_series.push(ux);
uy_series.push(uy);
let (drag_now, lift_now) = fluid.borrow().measure_force();
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_now);
drag_series.push(drag);
lift_series.push(lift);
if let Some(f) = csv.as_mut() {
writeln!(f, "{t_now:.6},{ux:.6e},{uy:.6e},{drag:.6e},{lift:.6e}").unwrap();
}
} else if let Some(f) = csv.as_mut() {
writeln!(f, "{t_now:.6},{ux:.6e},{uy:.6e},,").unwrap();
}
if (step + 1) % 500 == 0 {
let window = &uy_series[uy_series.len().saturating_sub(500)..];
let (w_mid, w_amp) = mid_amp(window);
let fl = fluid.borrow();
println!(
" t = {t_now:.3} s ({} steps): uy(A) = {uy:.3e} (window mid {w_mid:.3e} amp {w_amp:.3e}), drag {drag_now:.1} lift {lift_now:.1}, {:.1} subit/step, rounds mean {:.2}, reclassified/step {:.1}, regen {:.0} s of {:.0} s fluid, {:.0} s wall",
step + 1,
total_subiterations as f64 / (step + 1) as f64,
fl.rounds_total.get() as f64 / fl.correctors_total.get().max(1) as f64,
fl.reclassified_total.get() as f64
/ (rigid_steps + (step + 1) * cfg.subcycle * 4).max(1) as f64,
fl.regen_seconds.get(),
t_fluid.get(),
phase_start.elapsed().as_secs_f64()
);
}
}
let fl = fluid.borrow();
let final_state_finite = flag_state.displacement.iter().all(|v| v.is_finite());
let steps_done = times.len();
OversetMarchResult {
result: MarchResult {
dt,
coupled_steps: steps_done,
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 / steps_done.max(1) as f64,
max_subiterations,
stalled_steps,
retried_steps,
worst_stall,
worst_conservation,
skipped: 0,
spiked: 0,
newton_rescues: flag.borrow().rescue_counts(),
coupling_rescues: 0,
coupling_rescue_failures: 0,
rescue_records: Vec::new(),
final_state_finite,
elapsed: start.elapsed().as_secs_f64(),
},
death,
rounds_mean: fl.rounds_total.get() as f64 / fl.correctors_total.get().max(1) as f64,
reclassified_mean: fl.reclassified_total.get() as f64
/ (rigid_steps + steps_done * cfg.subcycle).max(1) as f64,
fresh_mean: fl.fresh_total.get() as f64
/ (rigid_steps + steps_done * cfg.subcycle).max(1) as f64,
regen_count: fl.regen_count.get(),
regen_seconds: fl.regen_seconds.get(),
fluid_seconds: t_fluid.get(),
structure_seconds: t_structure.get(),
faces_used,
}
}