//! ECSW×FSI campaign, phase 2: the offline POD/ECSW measurements on the //! harvested FSI3 flag trajectory (omni-cortex `next_session.md`, the //! campaign section; snapshots from the phase-1 `FSNP` dump). //! //! Three measurements, pre-registered in the campaign doc — deliberately //! NOT a static-solve-vs-dynamic-state comparison (the recorded //! trajectory is dynamic; the dynamic reduced Newmark is phase 4): //! //! 1. **POD projection error vs mode count** — does the flapping flag's //! solution manifold live in a small subspace? (The campaign's //! load-bearing question.) //! 2. **ECSW training + HELD-OUT residual** (total-Lagrangian operators, //! matching the march's `with_total_lagrangian`) — does a small //! element sample reproduce the reduced internal force on states it //! never trained on? //! 3. **Assembly wall-clock, full-active vs sampled** — the //! per-Newton-iteration cost ECSW actually reduces. //! //! Env-gated study: set `RTX_ECSW_SNAP=`; without it the //! test prints a skip note and passes (committed default is a no-op). mod fsi2_harness; use fsi2_harness::{FLAG_X0, FSI3, flag_mesh}; use nalgebra::{DMatrix, DVector}; use rtx_fea::assembly::dof_mapping::{AdvancedDofNumbering, DofComponent, DofMappingStrategy}; use rtx_fea::materials::{LinearElastic, MaterialDatabase}; use rtx_fea::mesh::MaterialId; use rtx_fea::mesh::NodeId; use rtx_fea::mor::{ Formulation, ReducedNewmark, ReducedNonlinearModel, ecsw_residual, pod_basis, train_ecsw_formulated, }; /// One FSNP record (the phase-1 dump format; see `march::write_snapshot`). struct Snapshot { t: f64, displacement: Vec, /// Full-DOF velocity and acceleration — the phase-4 replay's initial /// state. velocity: Vec, acceleration: Vec, /// The committed sparse nodal load `(node id, [fx, fy, fz])` — the /// external force the structural step was fed. loads: Vec<(usize, [f64; 3])>, } fn read_fsnp(path: &str) -> (usize, Vec) { let data = std::fs::read(path).expect("snapshot file"); assert_eq!(&data[0..4], b"FSNP", "bad magic"); assert_eq!(u32::from_le_bytes(data[4..8].try_into().unwrap()), 1); let n_dofs = u64::from_le_bytes(data[8..16].try_into().unwrap()) as usize; let mut off = 16usize; let mut records = Vec::new(); let f64_at = |data: &[u8], off: usize| -> f64 { f64::from_le_bytes(data[off..off + 8].try_into().unwrap()) }; while off < data.len() { let t = f64_at(&data, off); off += 8; let mut series = |off: &mut usize| -> Vec { let v: Vec = (0..n_dofs).map(|i| f64_at(&data, *off + 8 * i)).collect(); *off += 8 * n_dofs; v }; let displacement = series(&mut off); let velocity = series(&mut off); let acceleration = series(&mut off); let n_forces = u64::from_le_bytes(data[off..off + 8].try_into().unwrap()) as usize; off += 8; let mut loads = Vec::with_capacity(n_forces); for _ in 0..n_forces { let node = u64::from_le_bytes(data[off..off + 8].try_into().unwrap()) as usize; let f = [ f64_at(&data, off + 8), f64_at(&data, off + 16), f64_at(&data, off + 24), ]; off += 32; loads.push((node, f)); } records.push(Snapshot { t, displacement, velocity, acceleration, loads, }); } assert_eq!(off, data.len(), "trailing bytes"); (n_dofs, records) } /// The numbering the flag's analysis uses internally, rebuilt identically /// (same strategy, same clamp criterion as `clamp_left`), so full-DOF /// indices in the dump line up. fn numbering(mesh: &rtx_fea::mesh::Mesh) -> AdvancedDofNumbering { let mut dof_numbering = AdvancedDofNumbering::displacement_only(mesh, DofMappingStrategy::Sequential).unwrap(); for (&node_id, node) in &mesh.nodes { if (node.position().x - FLAG_X0).abs() < 1e-9 { for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] { let dof = dof_numbering.get_dof(node_id, component).unwrap(); dof_numbering.constrain_dof(dof).unwrap(); } } } dof_numbering } #[test] fn fsi3_ecsw_offline_study() { let Ok(snap_path) = std::env::var("RTX_ECSW_SNAP") else { println!(" RTX_ECSW_SNAP not set — offline ECSW study skipped"); return; }; let (n_dofs, records) = read_fsnp(&snap_path); println!( " {} snapshots, {} full DOFs, t in [{:.4}, {:.4}]", records.len(), n_dofs, records.first().unwrap().t, records.last().unwrap().t ); // The flag exactly as the march builds it (FSI3 defaults). let mesh = flag_mesh(35, 2); let mut materials = MaterialDatabase::new(); materials.add_material( MaterialId(0), LinearElastic::new(FSI3.e_s, FSI3.nu_s).with_density(FSI3.rho_s), None, ); let dof_numbering = numbering(&mesh); let n_free = dof_numbering.free_dofs.len(); assert_eq!(dof_numbering.total_dofs, n_dofs, "DOF count mismatch"); // Numbering self-check: the dump's clamped DOFs must be exactly zero // at every snapshot — if the rebuilt numbering disagreed with the // analysis's internal one, real displacements would land on // "constrained" indices and this fails loudly. let mut is_free = vec![false; dof_numbering.total_dofs]; for &dof in &dof_numbering.free_dofs { is_free[dof] = true; } let worst_clamped = records .iter() .flat_map(|r| { r.displacement .iter() .enumerate() .filter(|(dof, _)| !is_free[*dof]) .map(|(_, v)| v.abs()) }) .fold(0.0f64, f64::max); println!(" numbering self-check: worst clamped-DOF displacement {worst_clamped:.2e}"); assert!( worst_clamped < 1e-14, "clamped DOFs carry displacement {worst_clamped:.2e} — numbering mismatch" ); let to_free = |full: &[f64]| -> DVector { DVector::from_iterator(n_free, dof_numbering.free_dofs.iter().map(|&dof| full[dof])) }; // Interleaved split so both halves span release AND settled cycle. let train: Vec> = records .iter() .step_by(2) .map(|r| to_free(&r.displacement)) .collect(); let held_out: Vec> = records .iter() .skip(1) .step_by(2) .map(|r| to_free(&r.displacement)) .collect(); // ---- Measurement 1: POD projection error vs mode count. ---- let basis_full = pod_basis(&train, 1e-14).unwrap(); println!( " POD basis: {} modes at energy tolerance 1e-14 (of {n_free} free DOFs)", basis_full.ncols() ); let proj_errors = |basis: &DMatrix, set: &[DVector]| -> (f64, f64) { let mut max = 0.0f64; let mut sum_sq = 0.0f64; for d in set { let q = basis.transpose() * d; let err = (d - basis * q).norm() / d.norm().max(1e-30); max = max.max(err); sum_sq += err * err; } (max, (sum_sq / set.len() as f64).sqrt()) }; println!(" modes | train max / rms | held-out max / rms"); for m in [2usize, 4, 6, 8, 12, 16, 20, 30] { if m > basis_full.ncols() { break; } let v = basis_full.columns(0, m).into_owned(); let (tr_max, tr_rms) = proj_errors(&v, &train); let (ho_max, ho_rms) = proj_errors(&v, &held_out); println!(" {m:5} | {tr_max:.3e} / {tr_rms:.3e} | {ho_max:.3e} / {ho_rms:.3e}"); } // ---- Measurement 2: ECSW training + held-out residual (TL). ---- // Thinned sets: the NNLS system is (snapshots x modes) rows. let ecsw_held: Vec> = held_out.iter().step_by(4).cloned().collect(); // Cycle-only variant: restrict to the settled cycle (t > 6), the // narrower manifold the coupled phase-4 model would actually live on. let cycle_train: Vec> = records .iter() .filter(|r| r.t > 6.0) .step_by(8) .map(|r| to_free(&r.displacement)) .collect(); for (m, tol, thin) in [ (8usize, 1e-3f64, 4usize), (8, 1e-2, 4), (8, 3e-2, 4), (8, 1e-1, 4), (4, 1e-1, 4), (8, 1e-2, 16), (12, 1e-2, 4), (8, 1e-2, 0), // thin = 0 marks the cycle-only training set ] { let ecsw_train: Vec> = if thin == 0 { cycle_train.clone() } else { train.iter().step_by(thin).cloned().collect() }; let v = basis_full.columns(0, m).into_owned(); let model = train_ecsw_formulated( &mesh, &materials, &dof_numbering, &v, &ecsw_train, tol, Formulation::TotalLagrangian, ) .unwrap(); let held = ecsw_residual( &mesh, &materials, &dof_numbering, &v, &ecsw_held, &model, Formulation::TotalLagrangian, ) .unwrap(); println!( " ECSW m={m} tol={tol:.0e} thin={thin} ({} train states): {} of {} elements, \ training residual {:.3e}, held-out residual {held:.3e}", ecsw_train.len(), model.weights.len(), mesh.num_elements(), model.training_residual ); // ---- Measurement 3: assembly wall-clock, full vs sampled. ---- let full_model = ReducedNonlinearModel::new_formulated( &mesh, &materials, &dof_numbering, v.clone(), Formulation::TotalLagrangian, ) .unwrap(); let sampled_model = ReducedNonlinearModel::new_formulated( &mesh, &materials, &dof_numbering, v.clone(), Formulation::TotalLagrangian, ) .unwrap() .with_ecsw(&model); let time_per_eval = |model: &ReducedNonlinearModel, label: &str| -> f64 { let states = &ecsw_held; // Warm-up pass, then best of 3 timed sweeps. for d in states.iter().take(8) { let _ = model.assemble_reduced_force(d).unwrap(); } let mut best = f64::INFINITY; for _ in 0..3 { let start = std::time::Instant::now(); for d in states { let _ = model.assemble_reduced_force(d).unwrap(); } best = best.min(start.elapsed().as_secs_f64() / states.len() as f64); } println!( " {label}: {:.1} us/assembly over {} states ({} elements)", best * 1e6, states.len(), model.active_elements() ); best }; let t_full = time_per_eval(&full_model, "full-active"); let t_sampled = time_per_eval(&sampled_model, "ECSW-sampled"); println!(" assembly speedup {:.1}x", t_full / t_sampled); } } /// ECSW×FSI campaign, phase 4a: the OFFLINE reduced Newmark replay. /// /// The reduced dynamic model (POD basis, total-Lagrangian operators, /// projected consistent mass, plain reduced Newton — `mor::dynamic`, /// identity-basis-verified against the full stepper at 2.4e-14) marches /// the harvested trajectory's horizon at the RECORD cadence, driven by /// the recorded end-of-step nodal loads, from the recorded initial /// state. Measured, pre-registered: /// /// 1. Tracking error vs the recorded full-order displacement, release /// window and settled cycle separately, against the projection floor /// (the best any model in this subspace can do). /// 2. Wall-clock per reduced step (the gate's numerator): the banded /// full-order structural step measured 7.2 ms/pass on the FSI3 /// study march (2026-08-30, `bandedlu_fsi3_ny62_t85`), so ≥10× /// demands ≤0.72 ms/step here. /// /// Caveat, pre-named: the replay integrates at the record spacing /// (5× the march dt), so tracking error conflates subspace closure /// with integrator-dt difference; the cost number does not care, and a /// model that tracks at THIS dt would only track better at the march's. /// A reduced Newton death (no rescue ladder) is a finding, printed and /// not papered over. #[test] fn fsi3_reduced_newmark_replay() { let Ok(snap_path) = std::env::var("RTX_ECSW_SNAP") else { println!(" RTX_ECSW_SNAP not set — reduced Newmark replay skipped"); return; }; let (n_dofs, records) = read_fsnp(&snap_path); let mesh = flag_mesh(35, 2); let mut materials = MaterialDatabase::new(); materials.add_material( MaterialId(0), LinearElastic::new(FSI3.e_s, FSI3.nu_s).with_density(FSI3.rho_s), None, ); let dof_numbering = numbering(&mesh); let n_free = dof_numbering.free_dofs.len(); assert_eq!(dof_numbering.total_dofs, n_dofs, "DOF count mismatch"); let to_free = |full: &[f64]| -> DVector { DVector::from_iterator(n_free, dof_numbering.free_dofs.iter().map(|&dof| full[dof])) }; let mut free_index = vec![None; dof_numbering.total_dofs]; for (i, &dof) in dof_numbering.free_dofs.iter().enumerate() { free_index[dof] = Some(i); } let load_to_free = |loads: &[(usize, [f64; 3])]| -> DVector { let mut force = DVector::zeros(n_free); for &(node, f) in loads { for (component, &dof) in dof_numbering.get_node_dofs(NodeId(node)).iter().enumerate() { if let Some(free) = free_index[dof] { force[free] += f[component]; } } } force }; // Record cadence (the replay dt). The march's own dt is 5x finer. let spacings: Vec = records.windows(2).map(|w| w[1].t - w[0].t).collect(); let dt_rec = spacings.iter().sum::() / spacings.len() as f64; let worst_spacing = spacings .iter() .map(|s| (s - dt_rec).abs()) .fold(0.0f64, f64::max); println!( " {} records, dt_rec {dt_rec:.6e} (worst spacing deviation {worst_spacing:.2e})", records.len() ); assert!( worst_spacing < 1e-9, "record spacing is not uniform — the fixed-dt replay is invalid" ); // The basis: trained exactly as phase 2's measurement 1 (interleaved // half), so the projection numbers line up with the campaign record. let train: Vec> = records .iter() .step_by(2) .map(|r| to_free(&r.displacement)) .collect(); let basis_full = pod_basis(&train, 1e-14).unwrap(); let scale = records .iter() .map(|r| to_free(&r.displacement).norm()) .fold(0.0f64, f64::max); // The dt-vs-reduction control: RTX_REPLAY_IDENTITY runs the same // replay with the identity basis — the exact subspace, so any death // there is the record-cadence integration (or the plain Newton // without a rescue ladder), NOT the reduction. let bases: Vec<(String, DMatrix)> = if std::env::var("RTX_REPLAY_IDENTITY").is_ok() { vec![("identity".to_string(), DMatrix::identity(n_free, n_free))] } else { [12usize, 20] .iter() .map(|&m| (format!("m={m}"), basis_full.columns(0, m).into_owned())) .collect() }; for (label, v) in bases { let modes = &label; // Projection floor over the whole recorded trajectory, same // normalization as the tracking metric. let mut proj_sum = 0.0; for r in &records { let d = to_free(&r.displacement); let err = (&d - &v * (v.transpose() * &d)).norm(); proj_sum += err * err; } let proj_rms = (proj_sum / records.len() as f64).sqrt() / scale; let model = ReducedNonlinearModel::new_formulated( &mesh, &materials, &dof_numbering, v.clone(), Formulation::TotalLagrangian, ) .unwrap(); let newmark = ReducedNewmark::new(&model, dt_rec).unwrap(); // Initial state: the first record, projected. let mut state = rtx_fea::mor::ReducedState { q: v.transpose() * to_free(&records[0].displacement), q_dot: v.transpose() * to_free(&records[0].velocity), q_ddot: v.transpose() * to_free(&records[0].acceleration), }; let mut sum_sq_release = 0.0f64; let mut n_release = 0usize; let mut sum_sq_cycle = 0.0f64; let mut max_cycle = 0.0f64; let mut n_cycle = 0usize; let mut total_iterations = 0usize; let mut died_at: Option = None; let start = std::time::Instant::now(); for k in 0..records.len() - 1 { let external = load_to_free(&records[k + 1].loads); match newmark.step(&state, &external) { Ok((next, iterations)) => { state = next; total_iterations += iterations; } Err(e) => { died_at = Some(records[k + 1].t); println!( " {modes}: reduced Newton DIED at t = {:.4} (step {k} of {}): {e:?}", records[k + 1].t, records.len() - 1 ); break; } } let err = (to_free(&records[k + 1].displacement) - model.expand(&state.q)).norm(); if records[k + 1].t < 6.0 { sum_sq_release += err * err; n_release += 1; } else { sum_sq_cycle += err * err; max_cycle = max_cycle.max(err); n_cycle += 1; } } let steps_done = n_release + n_cycle; let per_step = start.elapsed().as_secs_f64() / steps_done.max(1) as f64; println!( " {modes}: {steps_done} steps, {:.2} Newton iters/step, {:.0} us/step \ (full-order structural: 7200 us/pass -> {:.1}x)", total_iterations as f64 / steps_done.max(1) as f64, per_step * 1e6, 7.2e-3 / per_step ); println!( " tracking rms: release {:.3e}, cycle {:.3e} (max {:.3e}); projection floor \ {:.3e} (of max |d| {scale:.3e} m)", (sum_sq_release / n_release.max(1) as f64).sqrt() / scale, (sum_sq_cycle / n_cycle.max(1) as f64).sqrt() / scale, max_cycle / scale, proj_rms ); if let Some(t) = died_at { println!(" DIED at t = {t:.4} — recorded as a phase-4 finding"); } } }