diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 5a9b9ba..92d49b6 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -573,7 +573,7 @@ pub static CATALOGUE: &[Capability] = &[ "Displacement, rotation, reaction and stress at nodes, element section forces, a mode shape, or an explicit field frame (or click the model)", &["solve.history"], ), - ready("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set"), + ready_more("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set", &["results.export.csv"]), soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"), soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"), soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 38174f5..63776b0 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1687,6 +1687,13 @@ pub const FRAME: &[Field] = &[ int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."), positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"), ]; +pub const EXPORT_CSV: &[Field] = &[ + choice("what", "Export", &[("reactions", "Reaction sums"), ("probe", "Probed nodes")]).help("What to write.").req().def("\"reactions\""), + path("path", "File", true).help("The CSV file to write.").req(), + SOLVED, + references("sets", "Sets", C::Set).help("Reactions: the sets to sum; the step's BC sets when empty.").hint("the step's BC sets"), + json("nodes", "Nodes").help("Probe: the node labels, e.g. [1, 2, 3]."), +]; pub const REACTIONS: &[Field] = &[ SOLVED, references("sets", "Sets", C::Set) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b351ac..5dc514a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -162,6 +162,7 @@ pub static ALL: &[CommandSpec] = &[ results::ANIMATE, results::FRAME, results::REACTIONS, + results::EXPORT_CSV, results::CLEAR, diagnostics::CRASH_REPORTS, diagnostics::CRASH_REPORT, diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index 7fd2dce..54434d1 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -70,6 +70,16 @@ pub const REACTIONS: CommandSpec = CommandSpec { run: reactions, }; +pub const EXPORT_CSV: CommandSpec = CommandSpec { + id: "results.export.csv", + label: "Export Results (CSV)", + params: r#"{"what": "probe" | "reactions", "path": "tip.csv", "step": "Load", …} — writes probe or reaction results as a CSV file (RFC 4180, a header line, numbers in shortest round-trip form); the other parameters are solve.probe's (`nodes`, `at` or `set`, `mode`, `frame`) or results.reactions' (`sets`, `about`). Probe: a row per node with node, x, y, z and the quantities the step has; reactions: a row per set, a total row and an applied row. Replies with the path, rows, columns, step and units"#, + form: Some(super::forms::EXPORT_CSV), + kind: CommandKind::Session, + requires: &[Requirement::Result], + run: export_csv, +}; + pub const CLEAR: CommandSpec = CommandSpec { id: "results.clear", label: "Hide Results", @@ -369,6 +379,171 @@ fn reactions(s: &mut Session, p: &Value) -> Result { Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total})) } +fn export_csv(s: &mut Session, p: &Value) -> Result { + let what: String = super::req(p, "what")?; + let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?; + let (step, table) = match what.as_str() { + "probe" => { + let r = super::solve::probe(s, p)?; + (r.get("step").cloned().unwrap_or(Value::Null), probe_table(&r, &|label| s.model.node(label).map(|n| n.xyz))?) + } + "reactions" => { + let r = reactions(s, p)?; + if r.get("stale").and_then(Value::as_bool).unwrap_or(false) { + let step = r.get("step").and_then(Value::as_str).unwrap_or_default(); + return Err(EngineError::Params(format!( + "the result of step `{step}` is stale (the model changed since it was solved); solve it again" + ))); + } + (r.get("step").cloned().unwrap_or(Value::Null), reactions_table(&r)) + } + _ => return Err(EngineError::Params("`what` must be \"probe\" or \"reactions\"".into())), + }; + let text = table.csv(); + fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), text.as_bytes(), false)?; + Ok(json!({"path": path, "rows": table.rows.len(), "columns": table.header, "step": step, "units": s.model.units()})) +} + +/// A table of cells (a number, text, or empty) under a header, written as CSV. +struct Table { + header: Vec, + rows: Vec>, +} + +#[derive(Clone, Debug, PartialEq)] +enum Cell { + Number(f64), + Text(String), + Empty, +} + +impl Table { + /// RFC 4180: comma-separated, CRLF line ends, a field with a comma, quote or line break in + /// quotes (its quotes doubled); numbers in Rust's shortest round-trip form. + fn csv(&self) -> String { + let field = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let mut out = String::new(); + let line = |cells: Vec, out: &mut String| { + out.push_str(&cells.join(",")); + out.push_str("\r\n"); + }; + line(self.header.iter().map(|h| field(h)).collect(), &mut out); + for row in &self.rows { + let cells = row + .iter() + .map(|c| match c { + Cell::Number(v) => format!("{v}"), + Cell::Text(t) => field(t), + Cell::Empty => String::new(), + }) + .collect(); + line(cells, &mut out); + } + out + } + + /// Rows of named cells into a table whose columns are every name that has a value, in the + /// order first met; a column no row has a value in is left out. + fn from_named(rows: Vec>) -> Table { + let mut header: Vec = Vec::new(); + for row in &rows { + for (name, c) in row { + if *c != Cell::Empty && !header.contains(name) { + header.push(name.clone()); + } + } + } + let rows = rows + .into_iter() + .map(|row| header.iter().map(|h| row.iter().find(|(n, _)| n == h).map_or(Cell::Empty, |(_, c)| c.clone())).collect()) + .collect(); + Table { header, rows } + } +} + +/// The CSV columns of a quantity of a probe's node entry: a vector's components (and a +/// displacement's magnitude), a stress tensor's six components, a scalar under its own name. +fn quantity_cells(key: &str, v: &Value, out: &mut Vec<(String, Cell)>) { + const VECTORS: [(&str, &[&str]); 7] = [ + ("displacement", &["ux", "uy", "uz"]), + ("rotation", &["rx", "ry", "rz"]), + ("reaction", &["rfx", "rfy", "rfz"]), + ("reaction_moment", &["rmx", "rmy", "rmz"]), + ("velocity", &["vx", "vy", "vz"]), + ("shape", &["ux", "uy", "uz", "rx", "ry", "rz"]), + ("stress", &["s_xx", "s_yy", "s_zz", "s_xy", "s_xz", "s_yz"]), + ]; + match v { + Value::Number(n) => out.push((key.to_string(), n.as_f64().map_or(Cell::Empty, Cell::Number))), + Value::Array(items) if items.iter().all(Value::is_number) => { + let nums: Vec = items.iter().filter_map(Value::as_f64).collect(); + let names: Vec = match VECTORS.iter().find(|(k, _)| *k == key) { + Some((_, n)) if n.len() == nums.len() => n.iter().map(|s| (*s).to_string()).collect(), + _ => (1..=nums.len()).map(|i| format!("{key}_{i}")).collect(), + }; + out.extend(names.into_iter().zip(nums.iter().map(|&x| Cell::Number(x)))); + if key == "displacement" && nums.len() == 3 { + out.push(("u_mag".into(), Cell::Number(nums.iter().map(|x| x * x).sum::().sqrt()))); + } + } + _ => {} + } +} + +/// `solve.probe`'s reply as a table: a row per node with its label, undeformed position and +/// the quantities the step has. +fn probe_table(r: &Value, position: &dyn Fn(u32) -> Option<[f64; 3]>) -> Result { + let nodes = r.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("the probe has no node results to export".into()))?; + let rows = nodes + .iter() + .map(|e| { + let label = e.get("node").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()); + let mut row = vec![("node".to_string(), label.map_or(Cell::Empty, |l| Cell::Number(f64::from(l))))]; + if let Some(name) = e.get("reference_point").and_then(Value::as_str) { + row.push(("reference_point".into(), Cell::Text(name.to_string()))); + } + let xyz = label.and_then(position); + for (k, axis) in ["x", "y", "z"].iter().enumerate() { + row.push(((*axis).to_string(), xyz.and_then(|p| p.get(k).copied()).map_or(Cell::Empty, Cell::Number))); + } + if let Some(o) = e.as_object() { + for (k, v) in o.iter().filter(|(k, _)| !matches!(k.as_str(), "node" | "reference_point" | "xyz" | "distance")) { + quantity_cells(k, v, &mut row); + } + } + row + }) + .collect(); + Ok(Table::from_named(rows)) +} + +/// `results.reactions`' reply as a table: a row per set, the total and the applied load. +fn reactions_table(r: &Value) -> Table { + let row = |name: &str, e: &Value| { + let mut row = vec![("set".to_string(), Cell::Text(name.to_string()))]; + row.push(("nodes".into(), e.get("nodes").and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number))); + for (key, names) in [("force", ["fx", "fy", "fz"]), ("moment", ["mx", "my", "mz"])] { + let v = e.get(key).and_then(Value::as_array); + for (k, n) in names.iter().enumerate() { + row.push(((*n).to_string(), v.and_then(|a| a.get(k)).and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number))); + } + } + row + }; + let mut rows: Vec> = r + .get("sets") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|e| row(e.get("set").and_then(Value::as_str).unwrap_or_default(), e)) + .collect(); + rows.push(row("total", r.get("total").unwrap_or(&Value::Null))); + if let Some(applied) = r.get("applied").filter(|a| a.is_array()) { + rows.push(row("applied", &json!({"force": applied}))); + } + Table::from_named(rows) +} + #[cfg(test)] mod tests { use super::*; @@ -384,6 +559,75 @@ mod tests { /// `range` clamps the legend (#191): kept with the field, cleared by "auto" or another /// field, reported in the reply; the true range is still there. + /// A CSV reader (RFC 4180) for the tests: rows of fields, quotes undone. + fn read_csv(text: &str) -> Vec> { + let (mut rows, mut row, mut field, mut quoted, mut chars) = (Vec::new(), Vec::new(), String::new(), false, text.chars().peekable()); + while let Some(c) = chars.next() { + match (quoted, c) { + (true, '"') if chars.peek() == Some(&'"') => { + chars.next(); + field.push('"'); + } + (true, '"') => quoted = false, + (true, c) => field.push(c), + (false, '"') => quoted = true, + (false, ',') => row.push(std::mem::take(&mut field)), + (false, '\r') => {} + (false, '\n') => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + } + (false, c) => field.push(c), + } + } + rows + } + + fn temp_csv(tag: &str) -> String { + std::env::temp_dir().join(format!("fieldcraft-csv-{tag}-{}.csv", std::process::id())).to_string_lossy().into_owned() + } + + /// `results.export.csv` writes what results.reactions and solve.probe reply, bit for bit + /// after parsing; a set name with a comma and a quote round-trips (#92). + #[test] + fn results_export_as_csv() { + let mut s = solved(); + let path = temp_csv("reactions"); + let sets = json!(["root", "tip"]); + let reply = s.execute("results.export.csv", &json!({"what": "reactions", "path": path, "step": "Bend", "sets": sets})).unwrap(); + let sums = s.execute("results.reactions", &json!({"step": "Bend", "sets": sets})).unwrap(); + let rows = read_csv(&std::fs::read_to_string(&path).unwrap()); + assert_eq!(rows[0], ["set", "nodes", "fx", "fy", "fz", "mx", "my", "mz"]); + assert_eq!((reply["rows"].as_u64(), reply["step"].as_str()), (Some(4), Some("Bend")), "{reply}"); + // A name with a comma and a quote round-trips through a CSV reader. + let named = + Table { header: vec!["set".into(), "fx".into()], rows: vec![vec![Cell::Text("the \"root\", clamped".into()), Cell::Number(0.1)]] }; + assert_eq!(read_csv(&named.csv()), [vec!["set", "fx"], vec!["the \"root\", clamped", "0.1"]]); + let total = rows.iter().find(|r| r[0] == "total").unwrap(); + assert_eq!(total[4].parse::().unwrap(), sums["total"]["force"][2].as_f64().unwrap(), "bit for bit"); + let applied = rows.iter().find(|r| r[0] == "applied").unwrap(); + assert_eq!((applied[1].as_str(), applied[5].as_str()), ("", ""), "the applied load has no nodes or moments"); + let path = temp_csv("probe"); + s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "nodes": [1, 2, 3]})).unwrap(); + let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1, 2, 3]})).unwrap(); + let rows = read_csv(&std::fs::read_to_string(&path).unwrap()); + assert_eq!(rows.len(), 4); + let col = |name: &str| rows[0].iter().position(|h| h == name).unwrap_or_else(|| panic!("no {name}: {:?}", rows[0])); + for (i, node) in probe["nodes"].as_array().unwrap().iter().enumerate() { + assert_eq!(rows[i + 1][col("node")], node["node"].to_string()); + assert_eq!(rows[i + 1][col("uz")].parse::().unwrap(), node["displacement"][2].as_f64().unwrap()); + assert_eq!(rows[i + 1][col("s_yz")].parse::().unwrap(), node["stress"][5].as_f64().unwrap()); + assert_eq!(rows[i + 1][col("von_mises")].parse::().unwrap(), node["von_mises"].as_f64().unwrap()); + } + assert!(!rows[0].iter().any(|h| h == "temperature"), "columns the step does not have are left out: {:?}", rows[0]); + // A stale result is refused. + s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 0]})).unwrap(); + assert!(s.execute("results.export.csv", &json!({"what": "reactions", "path": temp_csv("stale"), "step": "Bend"})).is_err()); + for p in ["reactions", "probe"] { + let _ = std::fs::remove_file(temp_csv(p)); + } + } + #[test] fn the_legend_range_can_be_clamped() { let mut s = solved(); @@ -515,6 +759,13 @@ mod tests { json!({"field": "von_mises", "range": {"min": 5, "max": 5}}), json!({"field": "von_mises", "range": {"min": 1e308, "max": -1e308}}), json!({"field": "von_mises", "range": 7}), + json!({"what": 7, "path": "x.csv"}), + json!({"what": "probe"}), + json!({"what": "probe", "path": "", "nodes": [1]}), + json!({"what": "probe", "path": "/no/such/dir/x.csv", "nodes": [1]}), + json!({"what": "probe", "path": "x.csv", "nodes": vec![1; 1_000_000]}), + json!({"what": "reactions", "path": "x.csv", "step": "Modes"}), + json!({"what": "reactions", "path": "x.csv", "step": "Nope"}), json!({"scale": -1}), json!({"scale": -0.0001}), json!({"scale": 1e300}), diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index abde3fc..0851625 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -565,7 +565,7 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result { Ok(json!({"step": r.step, "frame": frame, "time": time, "points": out})) } -fn probe(s: &mut Session, p: &Value) -> Result { +pub(crate) fn probe(s: &mut Session, p: &Value) -> Result { let step = opt::(p, "step")?.ok_or_else(|| EngineError::param("step", ProblemKind::Required))?; let result = s.result(&step).ok_or_else(|| { EngineError::Params(if s.latest_result(&step).is_some() { diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 83c2eaf..7daa161 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -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 `--.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 { 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 `--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 = [ diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index f6abc27..ca54791 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -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 = 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 { 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 diff --git a/docs/features.md b/docs/features.md index 7eda4a0..eadf0af 100644 --- a/docs/features.md +++ b/docs/features.md @@ -142,7 +142,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Display | Mode Shapes — Mode shapes and frequencies of a frequency step | ready | `results.modes` | | Display | Animate — Animate the deformed shape, a mode or an explicit step's frames | ready | `results.animate`, `results.frame` | | Query | Probe — Displacement, rotation, reaction and stress at nodes, element section forces, a mode shape, or an explicit field frame (or click the model) | ready | `solve.probe`, `solve.history` | -| Query | Reactions — Reaction force and moment sums per set | ready | `results.reactions` | +| Query | Reactions — Reaction force and moment sums per set | ready | `results.reactions`, `results.export.csv` | | Query | Free Body — Free-body forces across a cut | coming soon (M9) | | | Plots | XY Plot — Plot a quantity against time or another | coming soon (M9) | | | Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | |