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
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
497 lines
19 KiB
Rust
497 lines
19 KiB
Rust
//! 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");
|
||
}
|
||
}
|
||
}
|