rtx-fea: reduced Newmark (mor::dynamic) + the phase-4a offline replay — the ≥10x gate is REFUTED by measurement at the validated resolution
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
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
This commit is contained in:
co-authored by
Claude Fable 5
parent
10c779e96e
commit
0b4f306ed1
@@ -26,15 +26,23 @@ 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, ReducedNonlinearModel, ecsw_residual, pod_basis, train_ecsw_formulated,
|
||||
Formulation, ReducedNewmark, ReducedNonlinearModel, ecsw_residual, pod_basis,
|
||||
train_ecsw_formulated,
|
||||
};
|
||||
|
||||
/// One FSNP record (the phase-1 dump format; see `march::write_snapshot`).
|
||||
struct Snapshot {
|
||||
#[allow(dead_code)]
|
||||
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>) {
|
||||
@@ -50,11 +58,34 @@ fn read_fsnp(path: &str) -> (usize, Vec<Snapshot>) {
|
||||
while off < data.len() {
|
||||
let t = f64_at(&data, off);
|
||||
off += 8;
|
||||
let displacement: Vec<f64> = (0..n_dofs).map(|i| f64_at(&data, off + 8 * i)).collect();
|
||||
off += 8 * n_dofs * 3; // skip velocity + acceleration for this phase
|
||||
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 + n_forces * 32;
|
||||
records.push(Snapshot { t, displacement });
|
||||
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)
|
||||
@@ -274,3 +305,192 @@ fn fsi3_ecsw_offline_study() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user