Files
rustytorch/crates/specialized/rtx-fsi/tests/fsi3_ecsw_offline.rs
T
Omar SobhandClaude Fable 5 0b4f306ed1
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-fea: reduced Newmark (mor::dynamic) + the phase-4a offline replay — the ≥10x gate is REFUTED by measurement at the validated resolution
The dynamic layer over ReducedNonlinearModel: reduced consistent mass
V'MV (full element sum, never ECSW-sampled — ECSW weights are trained
on internal-force virtual work and would conserve the wrong inertia),
reduced_force_and_jacobian exposed (solve() refactored onto it), and
ReducedNewmark mirroring NonlinearDynamicStepper::newmark_newton in
reduced coordinates (same predictor, residual, tangent shape; no
rescue ladder by design — a reduced Newton death is a finding).

TDD (tests/reduced_newmark.rs): identity-basis march reproduces the
full stepper to 2.4e-14 over 15 steps (both Newton loops tightened to
1e-10 so only solver rounding separates them); rigid-translation
reduced mass = rho*A to 1e-9; a 6-mode POD basis tracks its training
trajectory at 4.2e-4 rms against a 1.0e-4 projection floor.

Phase 4a (fsi3_ecsw_offline.rs, fsi3_reduced_newmark_replay,
env-gated): reduced Newmark replay of the harvested FSI3 trajectory at
record cadence (dt_rec = 5x march dt), driven by the recorded
end-of-step loads. Measured, m=12/20:

- COST (dt-independent, the verdict): 3,068/3,580 us/step at 4.6/5.0
  Newton iters — 2.0-2.3x the banded full-order structural step
  (7,200 us/pass, bandedlu_fsi3_ny62_t85). The >=10x gate needs
  <=720 us/step; one reduced eval alone costs ~640 us because phase 2
  refuted hyperreduction (every eval loops all 70 elements). The gate
  arithmetic is closed: reduced Newton needs >=2 evals, capping the
  ROM at ~5x. THE CAMPAIGN GATE (pinned cycle bands at >=10x
  structural speedup) CANNOT BE MET at the validated resolution.
- TRACKING at record cadence diverges in the release transient (dies
  t=4.35-4.45) — and the RTX_REPLAY_IDENTITY control dies EARLIER
  (t=4.13) in the exact subspace: the death is the 5x-coarse
  integration + aliased loads, NOT the reduction. The record-cadence
  replay cannot judge subspace dynamics; the projection floor
  (1.1e-3 at m=12) remains the honest subspace statement.

Campaign verdict to be recorded in omni-cortex in the pre-registered
words.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
2026-08-30 07:07:49 -05:00

497 lines
19 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.
//! 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=<path to .fsnp>`; 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<f64>,
/// Full-DOF velocity and acceleration — the phase-4 replay's initial
/// state.
velocity: Vec<f64>,
acceleration: Vec<f64>,
/// 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<Snapshot>) {
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<f64> {
let v: Vec<f64> = (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<f64> {
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<DVector<f64>> = records
.iter()
.step_by(2)
.map(|r| to_free(&r.displacement))
.collect();
let held_out: Vec<DVector<f64>> = 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<f64>, set: &[DVector<f64>]| -> (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<DVector<f64>> = 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<DVector<f64>> = 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<DVector<f64>> = 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<f64> {
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<f64> {
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<f64> = records.windows(2).map(|w| w[1].t - w[0].t).collect();
let dt_rec = spacings.iter().sum::<f64>() / 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<DVector<f64>> = 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<f64>)> = 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<f64> = 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");
}
}
}