R4-m: RTX_FSI2O_NODE_CSV — per-node accepted load and displacement along the flag every coupled step

Each wetted node's (dx, dy, fx, fy) grouped bottom / tip / top (sidecar
<path>.nodes.csv: group, node, reference x, y, arc length) plus the
centreline uy at s = 1/4 (mean of the two straddling nodes) and 1/2.
Default off; gate on the R4-l line slice 6.0 -> 6.2 s (CPU V-cycle,
floor 7.5e-8, rtol 1e-3): main vs branch knob-off vs knob-on march CSV
and step CSV byte-identical (md5 70dcf484..., dd535386...); the node
sums equal the step CSV's fx_nodal / fy_nodal.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-24 22:36:17 -05:00
co-authored by Claude Opus 5.5
parent d9847cd818
commit e80b122854
3 changed files with 134 additions and 0 deletions
@@ -0,0 +1,121 @@
//! R4-m (2026-09-24): the per-node load along the flag —
//! `RTX_FSI2O_NODE_CSV=<path>` writes, every committed coupled step, each
//! wetted node's displacement and the load the structure ACCEPTED
//! (`dx, dy, fx, fy`), the nodes grouped bottom (root → tip), tip (bottom
//! corner excluded → top corner excluded, by y), top (tip → root), plus the
//! centreline displacement at s = ¼ and ½ of the flag (the ¼ point lies
//! between two nodes and is their mean). A sidecar `<path>.nodes.csv`
//! names each column block: group, node id, reference (x, y) and the arc
//! length along the wetted walk. Off (the default) nothing is built.
use std::collections::HashMap;
use std::io::Write as _;
use nalgebra::Vector3;
use rtx_fea::analysis::DynamicState;
use rtx_fea::mesh::{Mesh, NodeId};
use super::{Interface, FLAG_X0, FLAG_X1, FLAG_Y0, FLAG_Y1};
pub struct NodeCsv {
file: std::io::BufWriter<std::fs::File>,
/// (node id, dof x, dof y) in column order.
columns: Vec<(NodeId, usize, usize)>,
/// Centreline probes: (label, [dofs of the nodes averaged]).
probes: Vec<(&'static str, Vec<[usize; 2]>)>,
a_dofs: [usize; 2],
}
impl NodeCsv {
/// `None` unless `RTX_FSI2O_NODE_CSV` is set.
pub fn from_env(
mesh: &Mesh,
interface: &Interface,
a_dofs: [usize; 2],
node_dofs: impl Fn(NodeId) -> [usize; 2],
) -> Option<Self> {
let path = std::env::var("RTX_FSI2O_NODE_CSV").ok()?;
let (len, thick) = (FLAG_X1 - FLAG_X0, FLAG_Y1 - FLAG_Y0);
let tip_interior = &interface.tip[1..interface.tip.len() - 1];
let mut order: Vec<(&str, usize)> = Vec::new();
order.extend(interface.bottom.iter().map(|&k| ("bottom", k)));
order.extend(tip_interior.iter().map(|&k| ("tip", k)));
order.extend(interface.top.iter().map(|&k| ("top", k)));
let mut side = std::fs::File::create(format!("{path}.nodes.csv")).expect("node sidecar");
writeln!(side, "col,group,node,x,y,s").unwrap();
let mut columns = Vec::with_capacity(order.len());
for (c, (group, k)) in order.iter().enumerate() {
let id = interface.wetted[*k];
let (x, y) = interface.reference[*k];
let s = match *group {
"bottom" => x - FLAG_X0,
"tip" => len + (y - FLAG_Y0),
_ => len + thick + (FLAG_X1 - x),
};
writeln!(side, "{c},{group},{},{x:.6},{y:.6},{s:.6}", id.0).unwrap();
let d = node_dofs(id);
columns.push((id, d[0], d[1]));
}
let yc = 0.5 * (FLAG_Y0 + FLAG_Y1);
let at = |x: f64| -> [usize; 2] {
let id = mesh
.nodes
.iter()
.find(|(_, n)| {
(n.position().x - x).abs() < 1e-9 && (n.position().y - yc).abs() < 1e-9
})
.map(|(&id, _)| id)
.expect("centreline node");
node_dofs(id)
};
// Quad8 centreline nodes sit every len / (2 nx); s = ¼ is between two.
let n_along = interface.bottom.len(); // 2 nx (the clamp node excluded)
let hx = len / n_along as f64;
let quarter = 0.25 * len / hx;
let (i0, i1) = (quarter.floor() as usize, quarter.ceil() as usize);
let q_nodes = if i0 == i1 {
vec![at(FLAG_X0 + i0 as f64 * hx)]
} else {
vec![at(FLAG_X0 + i0 as f64 * hx), at(FLAG_X0 + i1 as f64 * hx)]
};
let mid = (0.5 * len / hx).round() as usize;
let probes = vec![("q", q_nodes), ("m", vec![at(FLAG_X0 + mid as f64 * hx)])];
let mut file = std::io::BufWriter::new(std::fs::File::create(&path).expect("node csv"));
let mut header = String::from("t,ux_A,uy_A,ux_q,uy_q,ux_m,uy_m");
for c in 0..columns.len() {
header += &format!(",dx{c},dy{c},fx{c},fy{c}");
}
writeln!(file, "{header}").unwrap();
println!(
" node CSV (R4-m): {} wetted columns ({} bottom, {} tip, {} top) → {path} (+ .nodes.csv); centreline probes s = ¼ ({} nodes), ½",
columns.len(),
interface.bottom.len(),
tip_interior.len(),
interface.top.len(),
probes[0].1.len()
);
Some(Self {
file,
columns,
probes,
a_dofs,
})
}
pub fn write(&mut self, t: f64, state: &DynamicState, nodal: &[(NodeId, Vector3<f64>)]) {
let u = &state.displacement;
let load: HashMap<NodeId, Vector3<f64>> = nodal.iter().map(|(id, f)| (*id, *f)).collect();
let mut line = format!("{t:.6},{:.6e},{:.6e}", u[self.a_dofs[0]], u[self.a_dofs[1]]);
for (_, dofs) in &self.probes {
let n = dofs.len() as f64;
let ux: f64 = dofs.iter().map(|d| u[d[0]]).sum::<f64>() / n;
let uy: f64 = dofs.iter().map(|d| u[d[1]]).sum::<f64>() / n;
line += &format!(",{ux:.6e},{uy:.6e}");
}
for (id, dx, dy) in &self.columns {
let f = load.get(id).copied().unwrap_or_else(Vector3::zeros);
line += &format!(",{:.5e},{:.5e},{:.5e},{:.5e}", u[*dx], u[*dy], f.x, f.y);
}
writeln!(self.file, "{line}").unwrap();
}
}