results: export probe and reaction results to CSV (results.export.csv, #92)

results.export.csv runs solve.probe or results.reactions and writes their reply as an RFC 4180
CSV: a row per node (node, x, y, z and the quantities the step has, columns it does not have
left out) or per set with the total and the applied load; numbers in shortest round-trip form,
so they read back bit for bit. A stale result is refused. The results panel has Export CSV…
beside Reaction sums and under the probed node: the save picker, or the form without one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 11:07:46 -04:00
1 parent 5112855b83
commit d1557a87fe
8 files changed
+338 -10

No files matched your search

+47
View File
@@ -898,6 +898,29 @@ impl ModelApp {
let _ = self.run("deck.write", json!({"format": format, "path": path}));
}
/// Results › Export CSV… (`results.export.csv` with `params`, its `what` and `step` among
/// them): asks the save picker for `<model>-<step>-<what>.csv`; without a picker, the
/// command's form opens instead.
pub fn export_csv(&mut self, params: Value) {
let Some(pick) = self.services.pick_save.as_ref() else {
self.open_form("results.export.csv", params);
return;
};
let part = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let name = format!("{}-{}-{}.csv", self.model_name(), part("step"), part("what"));
let Some(path) = pick(&name, &[Filter { name: "CSV", extensions: &["csv"] }]) else { return };
let path = if Path::new(&path).extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case("csv")) {
path
} else {
with_extension(path, "csv")
};
let mut params = params;
params["path"] = json!(path);
if self.run("results.export.csv", params).is_ok() {
self.set_status(format!("Wrote {}", file_name(&path)), false);
}
}
/// The autosaved models that can be recovered (`file.recovery.list`), newest first.
pub fn recoverable(&mut self) -> Vec<Value> {
self.session.execute("file.recovery.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
@@ -1498,6 +1521,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Export CSV… asks the picker for `<model>-<step>-reactions.csv` and writes it; without a
/// picker the command's form opens (#92).
#[test]
fn reactions_export_where_the_picker_says() {
let dir = temp("csv");
let target = dir.join("sums").to_string_lossy().to_string();
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
app.run("solve.static", json!({})).unwrap();
let params = json!({"what": "reactions", "step": "Load"});
app.export_csv(params.clone());
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("results.export.csv"), "no picker: the form");
app.dialog = None;
let t = target.clone();
app.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| {
assert_eq!((name, f.first().map(|f| f.extensions)), ("Cantilever (sample)-Load-reactions.csv", Some(&["csv"][..])));
Some(t.clone())
}));
app.export_csv(params);
let text = std::fs::read_to_string(format!("{target}.csv")).unwrap();
assert!(text.starts_with("set,nodes,fx,fy,fz,mx,my,mz\r\n") && text.contains("\r\ntotal,"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recovered_entries_say_how_long_ago() {
let cases = [
+29 -7
View File
@@ -255,6 +255,7 @@ fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &T
/// Every control runs a command or a view setting.
pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
let mut run: Vec<(&'static str, Value)> = Vec::new();
let mut export: Option<Value> = None;
let steps: Vec<(String, bool)> = app.session.result_steps().map(|(n, c)| (n.to_string(), c)).collect();
egui::Grid::new("fc_results_grid").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| {
ui.label("Step");
@@ -385,26 +386,37 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
}
if matches!(plot.result.kind(), Kind::Static(_)) {
ui.separator();
if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() {
run.push(("results.reactions", json!({"step": plot.view.step})));
}
ui.horizontal(|ui| {
if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() {
run.push(("results.reactions", json!({"step": plot.view.step})));
}
if ui.button("Export CSV…").on_hover_text("Write the reaction sums to a CSV file (results.export.csv)").clicked() {
export = Some(json!({"what": "reactions", "step": plot.view.step}));
}
});
if let Some(r) = app.reactions.as_ref().filter(|r| r["step"] == plot.view.step.as_str()) {
reactions_table(ui, r, plot, t);
}
}
ui.separator();
probed(app, plot, ui, t);
if let Some(p) = probed(app, plot, ui, t) {
export = Some(p);
}
if let Some(p) = export {
app.export_csv(p);
}
for (id, params) in run {
// The reaction sums land in `app.reactions` (ModelApp::run_form).
let _ = app.run(id, params);
}
}
/// The probed node's values, one row each, or how to probe one.
fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
/// The probed node's values, one row each, or how to probe one; the export's parameters when
/// its Export CSV… is clicked.
fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) -> Option<Value> {
let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else {
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
return;
return None;
};
ui.label(RichText::new(format!("Node {}", p.node)).strong());
egui::Grid::new("fc_results_probe").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
@@ -414,6 +426,16 @@ fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
ui.end_row();
}
});
ui.button("Export CSV…").on_hover_text("Write the node's results to a CSV file (results.export.csv)").clicked().then(|| {
let mut params = json!({"what": "probe", "step": p.step, "nodes": [p.node]});
if matches!(plot.result.kind(), Kind::Frequency(_)) {
params["mode"] = json!(p.mode);
}
if let Some(f) = p.frame {
params["frame"] = json!(f);
}
params
})
}
/// An explicit step: a frame slider (the deformed shape and contours follow it) and the energy