rtx-cfd + rtx-fsi: ECSW campaign phase 1 — snapshot dump + FlowField save/load
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

FlowField::save/load serialize the complete field state bit-exact
(all twelve matrices including *_old, predictors and sources, so a
load is a true restart state), with a roundtrip test asserting
to_bits equality on every value and rejection of truncated/corrupt
files.

The march gains an ECSW snapshot knob (RTX_FSI{2,3}_SNAP path,
SNAPEVERY, default off): every N committed steps it appends an FSNP
record — t, full-DOF displacement/velocity/acceleration (what
rtx_fea::mor's pod_basis/train_ecsw consume, plus what the phase-4
dynamic reduction will need) and the committed sparse nodal load for
the offline full-vs-reduced replay. Reporting-only: reads committed
state after acceptance, no float ops on the solver path. Verified:
smoke run's FSNP parsed by an independent reader (570 DOFs, correct
record count, physical values); FSI2 committed default
digit-identical with the knob off.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-08-28 21:56:37 -05:00
co-authored by Claude Fable 5
parent 366a46b471
commit 9fe9d7f74a
4 changed files with 240 additions and 1 deletions
@@ -70,6 +70,16 @@ pub struct MarchConfig {
/// measured to change nothing that matters — see the probe).
pub smooth_in_h: f64,
pub csv_path: Option<String>,
/// ECSW snapshot dump (0 = off): every `snap_every` committed steps,
/// append the committed flag state — full-DOF displacement, velocity
/// and acceleration — plus the committed sparse nodal load to
/// `snap_path` (binary, magic `FSNP`; see `write_snapshot`). The POD
/// basis and ECSW training (`rtx_fea::mor`) consume the displacement
/// snapshots; the load records drive the offline full-vs-reduced
/// replay. Reporting-only: reads the committed state after
/// acceptance, no float ops on the solver path.
pub snap_path: Option<String>,
pub snap_every: usize,
/// IQN's relaxation on the very first pass, before any secant
/// information exists. Must CONTRACT a repulsive added-mass map: for
/// a per-pass gain `-g` the first update multiplies the residual by
@@ -105,7 +115,8 @@ pub struct MarchConfig {
impl MarchConfig {
/// Read `RTX_{prefix}_{NY,T_RELEASE,T_END,SUBCYCLE,TOL,RTOL,STALLX,
/// HYST,MAXSUB,FLAG_NX,SMOOTH,COUPLER,REUSE,CSV}` over `defaults`.
/// HYST,MAXSUB,FLAG_NX,SMOOTH,COUPLER,REUSE,CSV,SNAP,SNAPEVERY}`
/// over `defaults`.
pub fn from_env(prefix: &str, defaults: MarchConfig) -> MarchConfig {
let key = |name: &str| format!("RTX_{prefix}_{name}");
let num = |name: &str, default: f64| env_or(&key(name), default);
@@ -124,6 +135,8 @@ impl MarchConfig {
reuse: num("REUSE", defaults.reuse as f64) as usize,
smooth_in_h: num("SMOOTH", defaults.smooth_in_h),
csv_path: std::env::var(key("CSV")).ok().or(defaults.csv_path),
snap_path: std::env::var(key("SNAP")).ok().or(defaults.snap_path),
snap_every: num("SNAPEVERY", defaults.snap_every as f64) as usize,
initial_relaxation: num("OMEGA0", defaults.initial_relaxation),
trace_steps: num("TRACE", defaults.trace_steps as f64) as usize,
c1_interface: num("C1", f64::from(u8::from(defaults.c1_interface))) != 0.0,
@@ -263,6 +276,8 @@ pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
reuse,
smooth_in_h,
ref csv_path,
ref snap_path,
snap_every,
initial_relaxation,
trace_steps,
c1_interface,
@@ -399,6 +414,14 @@ pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
let mut csv = csv_path
.as_ref()
.map(|p| std::fs::File::create(p).expect("csv path"));
let mut snap = snap_path.as_ref().map(|p| {
let mut w = std::io::BufWriter::new(std::fs::File::create(p).expect("snap path"));
w.write_all(b"FSNP").unwrap();
w.write_all(&1u32.to_le_bytes()).unwrap();
w.write_all(&(flag_state.displacement.len() as u64).to_le_bytes())
.unwrap();
w
});
let phase_start = std::time::Instant::now();
for step in 0..coupled_steps {
@@ -578,6 +601,11 @@ pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
times.push(t);
ux_series.push(ux);
uy_series.push(uy);
if snap_every > 0 && (step + 1) % snap_every == 0 {
if let Some(w) = snap.as_mut() {
write_snapshot(w, t, &flag_state, &committed_nodal);
}
}
let (drag_now, lift_now) = harness.measure_force(&solver.borrow(), &field.borrow());
interval_drag.push(drag_now);
interval_lift.push(lift_now);
@@ -631,3 +659,29 @@ pub fn run_march(case: BenchmarkCase, config: &MarchConfig) -> MarchResult {
elapsed: start.elapsed().as_secs_f64(),
}
}
/// Append one `FSNP` record: `t`, the committed full-DOF displacement,
/// velocity and acceleration, then the committed sparse nodal load as
/// `(node id, fx, fy, fz)` tuples — everything the ECSW phase needs
/// (POD/ECSW train on the displacement snapshots; the load records
/// drive the offline full-vs-reduced replay).
fn write_snapshot(
w: &mut std::io::BufWriter<std::fs::File>,
t: f64,
state: &DynamicState,
nodal: &[(NodeId, Vector3<f64>)],
) {
w.write_all(&t.to_le_bytes()).unwrap();
for series in [&state.displacement, &state.velocity, &state.acceleration] {
for v in series.iter() {
w.write_all(&v.to_le_bytes()).unwrap();
}
}
w.write_all(&(nodal.len() as u64).to_le_bytes()).unwrap();
for (node, f) in nodal {
w.write_all(&(node.0 as u64).to_le_bytes()).unwrap();
for c in 0..3 {
w.write_all(&f[c].to_le_bytes()).unwrap();
}
}
}