//! 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__*`). #[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, 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 α {}, robin α {}", 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()), std::env::var("RTX_FSI2O_ROBIN_ALPHA").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 newmark_beta: f64; let analysis = match std::env::var("RTX_FSI2O_NEWMARK_GAMMA") .ok() .and_then(|v| v.parse::().ok()) { Some(g) => { let b = (g + 0.5).powi(2) / 4.0; println!(" Newmark γ = {g}, β = {b:.4}"); newmark_beta = b; analysis.with_newmark_parameters(g, b) } None => { newmark_beta = 0.25; 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 { 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 }; let extract_accel = |state: &DynamicState| -> Vec { 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 = 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 Newmark acceleration a CANDIDATE displacement implies at the wetted // DoFs, a(d) = (d − u_pred) / (β Δt²) with u_pred from the committed // state — the acceleration the structure itself would report at d, so // the compensating load M_f a(d) is consistent with the relaxed candidate // the fluid was evaluated at, and cancels exactly at the fixed point // (a lagged structure-output acceleration does not: 09-16's first try // made the loop SLOWER, 1.8 → 5.2 subit/step at α 0 → 2). let accel_of = |d_candidate: &[f64], committed: &DynamicState| -> Vec { let inv = 1.0 / (newmark_beta * dt * dt); let mut a = vec![0.0; 2 * wetted_dofs.len()]; for (k, dofs) in wetted_dofs.iter().enumerate() { for c in 0..2 { let dof = dofs[c]; let u_pred = committed.displacement[dof] + dt * committed.velocity[dof] + dt * dt * (0.5 - newmark_beta) * committed.acceleration[dof]; a[2 * k + c] = inv * (d_candidate[2 * k + c] - u_pred); } } a }; // P6-b design 2 (`docs/overset_metal_campaign.md` §5.19): the Robin wall // on the patch — `RTX_FSI2O_ROBIN_ALPHA` (Pa·s/m; 0 = the Dirichlet wall // bit for bit; the plate's impedance is ρ_s h_s / Δt). Each pass gives // the fluid the tractions of the previous pass as the datum, so at the // coupled fixed point the wall is the Dirichlet one. let robin_alpha: f64 = std::env::var("RTX_FSI2O_ROBIN_ALPHA") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0.0); if robin_alpha > 0.0 { println!( " Robin wall: α = {robin_alpha:.3e} Pa·s/m (ρ_s h_s / Δt = {:.3e})", case.rho_s * 0.02 / dt ); } let robin_datum: RefCell> = RefCell::new(Vec::new()); // The load with the compensating term for a given previous-subiterate acceleration. let with_fict = |nodal: &[(NodeId, Vector3)], accel: &[f64]| -> Vec<(NodeId, Vector3)> { 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::()) .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(); for step in 0..coupled_steps { let d_n = extract(&flag_state); let v_n: Option> = 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(); extract(&predicted) }; if robin_alpha > 0.0 { robin_datum.replace(fluid.borrow().inner_tractions()); } let saved = fluid.borrow().snapshot(); type PassResult = (DynamicState, Vec<(NodeId, Vector3)>, f64, usize); let latest: RefCell> = RefCell::new(None); let pass = |d_candidate: &[f64]| -> Vec { let fs = std::time::Instant::now(); let mut fl = fluid.borrow_mut(); fl.restore(&saved); if robin_alpha > 0.0 { fl.set_robin(robin_alpha, Some(robin_datum.borrow().clone())); } 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); if robin_alpha > 0.0 { robin_datum.replace(fl.inner_tractions()); } 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, &accel_of(d_candidate, &flag_state))); let (candidate_state, _) = flag_ref.step(&flag_state).unwrap(); 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::() .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::().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::() .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, } }