forked from 1b-agent/fieldcraft-upstream
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:
1 parent
5112855b83
commit
d1557a87fe
8 files changed
+338
-10
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user