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
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:
co-authored by
Claude Fable 5
parent
366a46b471
commit
9fe9d7f74a
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user