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
@@ -386,6 +386,131 @@ impl FlowField {
}
}
impl FlowField {
const SAVE_MAGIC: [u8; 4] = *b"RTXF";
const SAVE_VERSION: u32 = 1;
/// Serialize the complete field state to a file, bit-exact.
///
/// Every matrix is written (including `*_old`, the starred
/// predictors and the sources), so a [`Self::load`] of the file is
/// a true restart state, not a view: a solver resumed from it sees
/// exactly the arrays the saved solver held. Layout: magic `RTXF`,
/// version, `nx`/`ny` (u64 LE), `dx`/`dy` (f64 LE), then each
/// matrix as `nrows`/`ncols` (u64 LE) + column-major f64 LE data,
/// in declaration order.
pub fn save(&self, path: &std::path::Path) -> CfdResult<()> {
use std::io::Write as _;
let file = std::fs::File::create(path)
.map_err(|e| CfdError::invalid_parameter(format!("save {}: {e}", path.display())))?;
let mut w = std::io::BufWriter::new(file);
let mut write = |bytes: &[u8]| -> CfdResult<()> {
w.write_all(bytes)
.map_err(|e| CfdError::invalid_parameter(format!("save write: {e}")))
};
write(&Self::SAVE_MAGIC)?;
write(&Self::SAVE_VERSION.to_le_bytes())?;
write(&(self.nx as u64).to_le_bytes())?;
write(&(self.ny as u64).to_le_bytes())?;
write(&self.dx.to_le_bytes())?;
write(&self.dy.to_le_bytes())?;
for m in self.matrices() {
write(&(m.nrows() as u64).to_le_bytes())?;
write(&(m.ncols() as u64).to_le_bytes())?;
for v in m.iter() {
write(&v.to_le_bytes())?;
}
}
w.flush()
.map_err(|e| CfdError::invalid_parameter(format!("save flush: {e}")))
}
/// Deserialize a field saved by [`Self::save`], validating magic,
/// version and every matrix shape against a fresh field of the
/// stored dimensions.
pub fn load(path: &std::path::Path) -> CfdResult<Self> {
use std::io::Read as _;
let mut data = Vec::new();
std::fs::File::open(path)
.and_then(|mut f| f.read_to_end(&mut data))
.map_err(|e| CfdError::invalid_parameter(format!("load {}: {e}", path.display())))?;
let mut off = 0usize;
let take = |off: &mut usize, n: usize| -> CfdResult<&[u8]> {
let s = data
.get(*off..*off + n)
.ok_or_else(|| CfdError::invalid_parameter("load: truncated file"))?;
*off += n;
Ok(s)
};
if take(&mut off, 4)? != Self::SAVE_MAGIC {
return Err(CfdError::invalid_parameter("load: bad magic"));
}
let version = u32::from_le_bytes(take(&mut off, 4)?.try_into().unwrap());
if version != Self::SAVE_VERSION {
return Err(CfdError::invalid_parameter(format!(
"load: unsupported version {version}"
)));
}
let nx = u64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap()) as usize;
let ny = u64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap()) as usize;
let dx = f64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap());
let dy = f64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap());
let mut field = Self::new(nx, ny, dx, dy)?;
for m in field.matrices_mut() {
let nrows = u64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap()) as usize;
let ncols = u64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap()) as usize;
if nrows != m.nrows() || ncols != m.ncols() {
return Err(CfdError::invalid_parameter(format!(
"load: matrix shape {nrows}x{ncols} does not match field {}x{}",
m.nrows(),
m.ncols()
)));
}
for v in m.iter_mut() {
*v = f64::from_le_bytes(take(&mut off, 8)?.try_into().unwrap());
}
}
if off != data.len() {
return Err(CfdError::invalid_parameter("load: trailing bytes"));
}
Ok(field)
}
fn matrices(&self) -> [&DMatrix<f64>; 12] {
[
&self.u,
&self.v,
&self.p,
&self.u_old,
&self.v_old,
&self.p_old,
&self.u_star,
&self.v_star,
&self.p_prime,
&self.su,
&self.sv,
&self.sp,
]
}
fn matrices_mut(&mut self) -> [&mut DMatrix<f64>; 12] {
[
&mut self.u,
&mut self.v,
&mut self.p,
&mut self.u_old,
&mut self.v_old,
&mut self.p_old,
&mut self.u_star,
&mut self.v_star,
&mut self.p_prime,
&mut self.su,
&mut self.sv,
&mut self.sp,
]
}
}
/// Analytical solutions for testing and validation
#[derive(Debug, Clone, Copy)]
pub enum AnalyticalSolution {
@@ -394,3 +519,59 @@ pub enum AnalyticalSolution {
/// Taylor-Green vortex (decaying vortex solution)
TaylorGreenVortex { amplitude: f64 },
}
#[cfg(test)]
mod save_load_tests {
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join("rtx_cfd_flow_field_tests");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn save_load_roundtrip_is_bit_exact_across_every_matrix() {
let mut field = FlowField::new(7, 5, 0.125, 0.25).unwrap();
// Fill every matrix with distinct full-mantissa values so a
// field mix-up or truncation cannot roundtrip by accident.
for (k, m) in field.matrices_mut().into_iter().enumerate() {
for (i, v) in m.iter_mut().enumerate() {
*v = ((k * 1000 + i) as f64 * 0.7391 + 0.001).sin() * 3.7e3;
}
}
let path = scratch("roundtrip.rtxf");
field.save(&path).unwrap();
let loaded = FlowField::load(&path).unwrap();
assert_eq!(loaded.nx, field.nx);
assert_eq!(loaded.ny, field.ny);
assert_eq!(loaded.dx.to_bits(), field.dx.to_bits());
assert_eq!(loaded.dy.to_bits(), field.dy.to_bits());
for (a, b) in field.matrices().iter().zip(loaded.matrices().iter()) {
assert_eq!(a.nrows(), b.nrows());
assert_eq!(a.ncols(), b.ncols());
for (x, y) in a.iter().zip(b.iter()) {
assert_eq!(x.to_bits(), y.to_bits(), "field value changed in roundtrip");
}
}
}
#[test]
fn load_rejects_truncated_and_corrupt_files() {
let field = FlowField::new(5, 4, 0.1, 0.1).unwrap();
let path = scratch("truncate.rtxf");
field.save(&path).unwrap();
let full = std::fs::read(&path).unwrap();
let cut = scratch("truncate_cut.rtxf");
std::fs::write(&cut, &full[..full.len() / 2]).unwrap();
assert!(FlowField::load(&cut).is_err(), "truncated file must fail");
let bad = scratch("bad_magic.rtxf");
let mut corrupted = full.clone();
corrupted[0] = b'X';
std::fs::write(&bad, &corrupted).unwrap();
assert!(FlowField::load(&bad).is_err(), "bad magic must fail");
}
}