From cbd60a7985fd1c80cc00b0cd3d6ca5a192a24bda Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:04:56 -0400 Subject: [PATCH] results: results.xy, a quantity against the load factor, time or mode (#94) A nodal field at a node per frame (nonlinear static displacements from load factor 0, explicit frames, heat temperatures) or per mode, the explicit energies and histories, history nodes, and the frequencies or buckling load factors; capped at 100000 points with `every`; stale results refused; `path` writes a two-column CSV. XY Plot is ready. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 2 +- crates/engine/src/cmd/forms.rs | 12 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/results.rs | 14 +- crates/engine/src/cmd/xy.rs | 490 +++++++++++++++++++++++++++++++ docs/features.md | 4 +- 6 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/cmd/xy.rs diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index a149666..da3399e 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -627,7 +627,7 @@ pub static CATALOGUE: &[Capability] = &[ ), 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"), + ready("results.xy", "XY Plot", Results, "Plots", "Plot a quantity against the load factor, time or mode"), soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"), soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"), soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 00fd2a7..70bbf10 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1923,6 +1923,18 @@ pub const EXPORT_CSV: &[Field] = &[ int("mode", "Mode", 1, 1_000_000).help("Probe: the mode to read, for frequency and buckling steps."), int("frame", "Frame", 0, 1_000_000_000).help("Probe: the frame to read, for explicit and transient steps."), ]; +pub const RESULTS_XY: &[Field] = &[ + SOLVED, + json("y", "Quantity") + .help("What to plot: a nodal field at a node, an explicit energy or history, a history node's quantity, or \"frequency\".") + .hint(r#"{"field": "displacement_z", "node": 21}"#) + .req(), + choice("x", "Against", &[("auto", "The step's own axis"), ("load_factor", "Load factor"), ("time", "Time"), ("mode", "Mode")]) + .help("The x axis: the load factor (nonlinear static), time (explicit, heat) or mode number (frequency).") + .def("\"auto\""), + int("every", "Keep one in", 1, 1_000_000_000).help("Keep one point in this many; by default enough to fit 100000 points."), + path("path", "CSV file", true).help("Also write the series as a two-column CSV."), +]; pub const EXPORT_VTU: &[Field] = &[ path("path", "File", true).help("A .vtu file for one state, or a .pvd collection for every frame or mode.").req(), SOLVED, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 52161d0..db5a0af 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -35,6 +35,7 @@ mod symmetry; mod tags; pub mod update; pub(crate) mod view; +pub mod xy; use crate::schema::ProblemKind; use fieldcraft_model::MAX_LABEL; @@ -186,6 +187,7 @@ pub static ALL: &[CommandSpec] = &[ results::EXPORT_CSV, results::EXPORT_VTU, results::CLEAR, + xy::XY, diagnostics::CRASH_REPORTS, diagnostics::CRASH_REPORT, diagnostics::CRASH_REPORTS_DELETE, diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index 0514717..627b692 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -112,7 +112,7 @@ fn solved_list(s: &Session) -> String { /// The steps a command works on. #[derive(Clone, Copy, PartialEq)] -enum Want { +pub(super) enum Want { Any, Frequency, /// Steps with frames: explicit dynamics and nonlinear static (one per converged increment). @@ -138,7 +138,7 @@ impl Want { /// The step a command works on: `step`, else the step shown, else the first solved one /// (current results first) of the kind it wants. -fn step_for(s: &Session, p: &Value, want: Want) -> Result { +pub(super) fn step_for(s: &Session, p: &Value, want: Want) -> Result { if let Some(name) = opt::(p, "step")? { if s.latest_result(&name).is_none() { return Err(EngineError::Params(format!("step `{name}` has no result; {}", solved_list(s)))); @@ -660,13 +660,13 @@ fn export_csv(s: &mut Session, p: &Value) -> Result { } /// A table of cells (a number, text, or empty) under a header, written as CSV. -struct Table { - header: Vec, - rows: Vec>, +pub(super) struct Table { + pub(super) header: Vec, + pub(super) rows: Vec>, } #[derive(Clone, Debug, PartialEq)] -enum Cell { +pub(super) enum Cell { Number(f64), Text(String), Empty, @@ -675,7 +675,7 @@ enum Cell { 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 { + pub(super) 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| { diff --git a/crates/engine/src/cmd/xy.rs b/crates/engine/src/cmd/xy.rs new file mode 100644 index 0000000..38608d4 --- /dev/null +++ b/crates/engine/src/cmd/xy.rs @@ -0,0 +1,490 @@ +//! `results.xy`: one quantity of a solved step against its natural axis — a nodal field per +//! frame against the load factor (nonlinear static) or time (explicit, transient heat), a mode +//! shape's value or the frequencies against the mode number, and an explicit step's histories +//! (energies, momentum, constraint force, history nodes) against time. Read-only; with `path` +//! the series is also written as a two-column CSV. + +use serde_json::{Value, json}; + +use super::opt; +use super::results::{Cell, Table, Want, step_for}; +use crate::requirement::Requirement; +use crate::results::{Field, Kind, Pick, Quantity, field_values, with_unit}; +use crate::schema::ProblemKind; +use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult}; + +pub const XY: CommandSpec = CommandSpec { + id: "results.xy", + label: "XY Plot", + params: r#"{"step": "Load", "y": {"field": "displacement_z", "node": 21}, "x": "auto", "every": 1, "path": "tip.csv"} — a quantity of a solved step against its natural axis: `x` "load_factor" (nonlinear static), "time" (explicit, transient heat), "mode" (frequency and buckling) or "auto" (the step's own, the default). `y` is {"field": , "node":