Files
OneCAD-Simulation/crates/engine/src/cmd/solve.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

1358 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `solve.*`: run the model's steps with the implicit, the explicit or the lattice Boltzmann
//! solver, keep the results in the session, and read them back.
use fieldcraft_explicit::{ExplicitResult, solve_explicit};
use fieldcraft_lbm::{FluidResult, Obstacle};
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
use fieldcraft_model::FluidDomain;
use fieldcraft_parallel as parallel;
use fieldcraft_solver::{
FrequencyOptions, Limits, Location, MassKind, StaticResult, StepResult as ImplicitResult, solve_frequency, solve_heat, solve_static,
};
use serde_json::{Value, json};
use super::{labels, opt, points, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
pub const RUN: CommandSpec = CommandSpec {
id: "solve.run",
label: "Solve All Steps",
params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#,
kind: CommandKind::Model,
run: run_all,
};
pub const STATIC: CommandSpec = CommandSpec {
id: "solve.static",
label: "Solve Static Step",
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
kind: CommandKind::Model,
run: run_static,
};
pub const HEAT: CommandSpec = CommandSpec {
id: "solve.heat",
label: "Solve Heat Transfer Step",
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_heat,
};
pub const FREQUENCY: CommandSpec = CommandSpec {
id: "solve.frequency",
label: "Solve Frequency Step",
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_frequency,
};
pub const EXPLICIT: CommandSpec = CommandSpec {
id: "solve.explicit",
label: "Solve Explicit Dynamic Step",
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences; keeps its histories and field frames in the session; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_explicit,
};
pub const HISTORY: CommandSpec = CommandSpec {
id: "solve.history",
label: "Result Histories",
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
kind: CommandKind::Model,
run: history,
};
pub const FLUID: CommandSpec = CommandSpec {
id: "solve.fluid",
label: "Solve Fluid Step",
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_fluid,
};
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses, shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
kind: CommandKind::Model,
run: probe,
};
/// Most nodes one probe lists.
const MAX_PROBE: usize = 10_000;
/// Most force histories one fluid probe lists.
const MAX_PROBE_FORCES: usize = 100;
/// Most history samples one `solve.history` returns.
const MAX_HISTORY_SAMPLES: usize = 10_000;
/// An optional cap that may only lower the default.
fn cap<T: Copy + TryInto<u64> + TryFrom<u64> + std::fmt::Display>(p: &Value, key: &str, default: T) -> Result<T> {
let most: u64 = default.try_into().unwrap_or(u64::MAX);
match p.get(key) {
None => Ok(default),
Some(v) => v
.as_u64()
.filter(|n| (1..=most).contains(n))
.and_then(|n| T::try_from(n).ok())
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number from 1 to {default}"))),
}
}
/// The `threads` parameter: 1 to [`parallel::MAX_THREADS`], by default the session's.
fn threads(s: &Session, p: &Value) -> Result<usize> {
match p.get("threads") {
None => Ok(s.limits.threads.clamp(1, parallel::MAX_THREADS)),
Some(v) => v
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| (1..=parallel::MAX_THREADS).contains(n))
.ok_or_else(|| EngineError::Params(format!("`threads` must be a whole number from 1 to {}", parallel::MAX_THREADS))),
}
}
fn limits(p: &Value) -> Result<Limits> {
Ok(Limits {
max_dofs: cap(p, "max_dofs", Limits::MAX_DOFS)?,
max_factor_entries: cap(p, "max_factor_entries", Limits::MAX_FACTOR_ENTRIES)?,
max_lanczos_vectors: cap(p, "max_iterations", Limits::MAX_LANCZOS_VECTORS)?,
})
}
/// The node with the value that `better` prefers, as `{"node", "value"}`.
fn extreme(values: impl Iterator<Item = (u32, f64)>, better: fn(f64, f64) -> bool) -> Value {
match values.fold(None, |best: Option<(u32, f64)>, (l, v)| if best.is_none_or(|(_, b)| better(v, b)) { Some((l, v)) } else { best }) {
Some((node, value)) => json!({"node": node, "value": value}),
None => Value::Null,
}
}
fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
use fieldcraft_model::Procedure;
type Solve = fn(&mut Session, &Value) -> Result<Value>;
let steps: Vec<(String, &'static str, Solve)> = s
.model
.steps()
.iter()
.map(|st| {
let solve: Solve = match st.procedure {
Procedure::Static {} | Procedure::CoupledTemperatureDisplacement {} => run_static,
Procedure::HeatTransfer { .. } => run_heat,
Procedure::Frequency { .. } => run_frequency,
Procedure::ExplicitDynamic(_) => run_explicit,
Procedure::Fluid { .. } => run_fluid,
};
(st.name.clone(), st.procedure.name(), solve)
})
.collect();
if steps.is_empty() {
return Err(EngineError::Params("the model has no steps to solve: add one (model.step.create)".into()));
}
let mut done = Vec::new();
for (name, procedure, solve) in steps {
let summary = solve(s, &json!({"step": name})).map_err(|e| EngineError::Params(format!("step `{name}`: {e}")))?;
done.push(json!({"step": name, "procedure": procedure, "summary": summary}));
}
Ok(json!({"steps": done}))
}
fn largest(values: impl Iterator<Item = (u32, f64)>) -> Value {
extreme(values, |v, b| v > b)
}
fn smallest(values: impl Iterator<Item = (u32, f64)>) -> Value {
extreme(values, |v, b| v < b)
}
fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
let summary = json!({
"step": r.step,
"dofs": r.stats.dofs,
"equations": r.stats.equations,
"factor_entries": r.stats.factor_entries,
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
"applied_total": r.applied_total,
"reaction_total": r.reaction_total,
"reference_points": r.reference_points.iter().map(|p| json!({
"name": p.name,
"label": p.label,
"displacement": p.displacement,
"reaction": p.reaction,
})).collect::<Vec<_>>(),
"max_temperature": r.temperature.as_ref().map(|t| largest(r.nodes.iter().copied().zip(t.iter().copied()))),
"warnings": r.warnings,
});
s.store_result(StepResult::Implicit(ImplicitResult::Static(r)));
Ok(summary)
}
fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
let t = r.temperature();
let summary = json!({
"step": r.step,
"dofs": r.stats.dofs,
"equations": r.stats.equations,
"factor_entries": r.stats.factor_entries,
"frames": r.frames.len(),
"end_time": r.times.last(),
"min_temperature": smallest(r.nodes.iter().copied().zip(t.iter().copied())),
"max_temperature": largest(r.nodes.iter().copied().zip(t.iter().copied())),
"heat_supplied": r.applied_total,
"heat_removed": r.reaction_total,
"warnings": r.warnings,
});
s.store_result(StepResult::Implicit(ImplicitResult::Heat(r)));
Ok(summary)
}
fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let mass = opt::<MassKind>(p, "mass")?.unwrap_or_default();
let shift = opt::<f64>(p, "shift")?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
let summary = json!({
"step": r.step,
"dofs": r.stats.dofs,
"equations": r.stats.equations,
"lanczos_vectors": r.stats.lanczos_vectors,
"total_mass": r.total_mass,
"modes": r.modes.iter().enumerate().map(|(i, m)| json!({
"mode": i + 1,
"frequency": m.frequency,
"eigenvalue": m.eigenvalue,
"effective_mass": m.effective_mass,
})).collect::<Vec<_>>(),
"warnings": r.warnings,
});
s.store_result(StepResult::Implicit(ImplicitResult::Frequency(r)));
Ok(summary)
}
fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
use fieldcraft_explicit::Limits as ExplicitLimits;
let limits = ExplicitLimits {
max_dofs: cap(p, "max_dofs", ExplicitLimits::MAX_DOFS)?,
max_increments: cap(p, "max_increments", ExplicitLimits::MAX_INCREMENTS)?,
max_output_values: cap(p, "max_output_values", ExplicitLimits::MAX_OUTPUT_VALUES)?,
};
let step = opt::<String>(p, "step")?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
let summary = explicit_summary(&r);
s.store_result(StepResult::Explicit(r));
Ok(summary)
}
fn explicit_summary(r: &ExplicitResult) -> Value {
json!({
"step": r.step,
"dofs": 3 * r.nodes.len(),
"elements": r.elements.len(),
"increments": r.increments,
"time": r.time,
"completed": r.completed,
"initial_increment": r.initial_increment,
"controlling_elements": r.controlling,
"total_mass": r.total_mass,
"energy": r.energy,
"energy_balance": r.energy.relative_error(r.initial_kinetic),
"frames": r.frames.len(),
"history_samples": r.history.time.len(),
"eroded": r.eroded,
"warnings": r.warnings,
})
}
fn history(s: &mut Session, p: &Value) -> Result<Value> {
let step: String = req(p, "step")?;
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
let StepResult::Explicit(r) = result.as_ref() else {
return Err(EngineError::Params(format!("step `{step}` is not an explicit dynamic step; it has no histories")));
};
let h = &r.history;
let samples = h.time.len();
let every = match opt::<u64>(p, "every")? {
Some(0) => return Err(EngineError::Params("`every` must be at least 1".into())),
Some(k) => usize::try_from(k).unwrap_or(usize::MAX),
None => samples.div_ceil(MAX_HISTORY_SAMPLES).max(1),
};
if samples.div_ceil(every) > MAX_HISTORY_SAMPLES {
return Err(EngineError::Params(format!("{samples} samples in steps of {every} are more than {MAX_HISTORY_SAMPLES}; raise `every`")));
}
let pick = |v: &dyn Fn(usize) -> Value| -> Vec<Value> { (0..samples).step_by(every).map(v).collect() };
let nodes: Vec<Value> = h
.nodes
.iter()
.enumerate()
.map(|(k, label)| {
json!({
"node": label,
"displacement": pick(&|i| json!(h.displacement.get(i).and_then(|d| d.get(k)))),
"velocity": pick(&|i| json!(h.velocity.get(i).and_then(|d| d.get(k)))),
})
})
.collect();
Ok(json!({
"step": step,
"every": every,
"time": pick(&|i| json!(h.time.get(i))),
"increment": pick(&|i| json!(h.increment.get(i))),
"energies": pick(&|i| json!(h.energies.get(i))),
"momentum": pick(&|i| json!(h.momentum.get(i))),
"constraint_force": pick(&|i| json!(h.constraint_force.get(i))),
"nodes": nodes,
}))
}
/// The surfaces of a fluid domain's obstacles: bodies of the open design (triangulated at the
/// domain's tolerance) or imported surfaces, by name.
fn obstacles(s: &Session, domain: &FluidDomain) -> Result<Vec<Obstacle>> {
let tolerance = domain.tolerance.unwrap_or(domain.spacing / 10.0);
let mut out = Vec::with_capacity(domain.obstacles.len());
for body in &domain.obstacles {
let (mesh, faces) = if let Some(shape) = s.design.as_deref().and_then(|d| d.body(body)) {
(std::borrow::Cow::Owned(shape.tessellate(tolerance)?), shape.faces().iter().map(|f| f.name.clone()).collect())
} else if let Some(t) = s.surfaces.iter().find(|t| &t.name == body) {
// Imported faces have no names: the solver calls them face0, face1, ….
(std::borrow::Cow::Borrowed(&t.mesh), Vec::new())
} else {
return Err(EngineError::Params(format!("obstacle `{body}` is neither a body of the open design nor an imported surface")));
};
let options = SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() };
let surface = BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)
.map_err(|e| EngineError::Params(format!("obstacle `{body}`: its surface cannot bound a solid: {e}")))?;
out.push(Obstacle { body: body.clone(), surface, faces });
}
Ok(out)
}
fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
let defaults = fieldcraft_lbm::Limits::default();
let limits = fieldcraft_lbm::Limits {
max_cells: cap(p, "max_cells", defaults.max_cells)?,
max_steps: cap(p, "max_steps", defaults.max_steps)?,
max_cell_updates: cap(p, "max_cell_updates", defaults.max_cell_updates)?,
max_frame_bytes: cap(p, "max_frame_bytes", defaults.max_frame_bytes)?,
};
let step = opt::<String>(p, "step")?;
let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?;
let obstacles = obstacles(s, domain)?;
let model = &s.model;
let r = parallel::with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
let summary = fluid_summary(&r);
s.store_result(StepResult::Fluid(r));
Ok(summary)
}
fn fluid_summary(r: &FluidResult) -> Value {
let speed = |v: &[f32; 3]| (f64::from(v[0]).powi(2) + f64::from(v[1]).powi(2) + f64::from(v[2]).powi(2)).sqrt();
let last = r.frames.last();
let max_speed = last.map_or(0.0, |f| f.velocity.iter().map(speed).fold(0.0, f64::max));
// The mean over the last quarter of the history smooths the acoustic noise of the
// weakly compressible scheme.
let tail = r.history_times.len().div_ceil(4).max(1);
let forces: Vec<Value> = r
.forces
.iter()
.filter(|f| f.force.iter().any(|v| *v != [0.0; 3]))
.map(|f| {
let recent = f.force.get(f.force.len().saturating_sub(tail)..).unwrap_or_default();
let n = recent.len().max(1) as f64;
let mean = [0, 1, 2].map(|a| recent.iter().map(|v| v[a]).sum::<f64>() / n);
json!({"name": f.name, "body": f.body, "final": f.force.last(), "mean": mean})
})
.collect();
let (m0, m1) = (r.frames.first().map_or(0.0, |f| f.mass), last.map_or(0.0, |f| f.mass));
json!({
"step": r.step,
"cells": r.stats.cells,
"fluid_cells": r.stats.fluid_cells,
"steps": r.stats.steps,
"time_step": r.stats.time_step,
"end_time": r.stats.end_time,
"tau": r.stats.tau_plus,
"tau_minus": r.stats.tau_minus,
"magic": r.stats.magic,
"velocity_scale": r.stats.velocity,
"lattice_velocity": r.stats.lattice_velocity,
"mach": r.stats.mach,
"frames": r.frames.iter().map(|f| f.time).collect::<Vec<_>>(),
"max_speed": max_speed,
"mass": {"first": m0, "last": m1, "relative_change": if m0 != 0.0 { (m1 - m0) / m0 } else { 0.0 }},
"forces": forces,
"warnings": r.warnings,
})
}
fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
if let Some(names) = opt::<Vec<String>>(p, "forces")? {
if names.len() > MAX_PROBE_FORCES {
return Err(EngineError::Params(format!("`forces` lists at most {MAX_PROBE_FORCES} walls")));
}
let out: Vec<Value> = names
.iter()
.map(|n| {
let f =
r.forces.iter().find(|f| &f.name == n || f.body.as_ref().is_some_and(|b| format!("{b}/{}", f.name) == *n)).ok_or_else(|| {
EngineError::Params(format!("no wall `{n}` (box faces x_min … z_max, or obstacle faces by name or body/name)"))
})?;
Ok(json!({"name": f.name, "body": f.body, "force": f.force}))
})
.collect::<Result<_>>()?;
return Ok(json!({"step": r.step, "times": r.history_times, "forces": out}));
}
let pts = points(p, "points", MAX_PROBE)?;
let frame = match opt::<usize>(p, "frame")? {
Some(k) if k < r.frames.len() => k,
Some(_) => return Err(EngineError::Params(format!("`frame` must be from 0 to {}", r.frames.len().saturating_sub(1)))),
None => r.frames.len().saturating_sub(1),
};
let time = r.frames.get(frame).map(|f| f.time);
let out: Vec<Value> = pts
.iter()
.map(|&pt| match r.probe(frame, pt) {
Some((cell, velocity, pressure)) => {
json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure})
}
None => json!({"point": pt, "cell": null}),
})
.collect();
Ok(json!({"step": r.step, "frame": frame, "time": time, "points": out}))
}
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
let result = s.result(&step).ok_or_else(|| {
EngineError::Params(if s.latest_result(&step).is_some() {
format!("the result of step `{step}` is stale (the model changed since it was solved); solve it again")
} else {
format!("step `{step}` has no result; solve it first")
})
})?;
if let StepResult::Fluid(r) = result.as_ref() {
return probe_fluid(r, p);
}
let (nodes, located) = match (p.get("nodes"), p.get("at")) {
(Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None),
(None, Some(_)) => {
let at = points(p, "at", MAX_PROBE)?;
let found = nearest_nodes(&s.model, result.nodes(), &at)?;
(found.iter().map(|(l, _, _)| *l).collect(), Some(found))
}
(None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) || p.get("elements").is_some() => (Vec::new(), None),
_ => return Err(EngineError::Params("give either `nodes` or `at`".into())),
};
let mut out = match result.as_ref() {
StepResult::Explicit(r) => {
let elements = if p.get("elements").is_some() { labels(p, "elements", MAX_PROBE)? } else { Vec::new() };
probe_frame(r, &step, &nodes, &elements, p)?
}
StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?,
StepResult::Fluid(r) => probe_fluid(r, p)?,
};
if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) {
for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) {
if let Some(obj) = entry.as_object_mut() {
obj.insert("xyz".into(), json!(xyz));
obj.insert("distance".into(), json!(distance));
}
}
}
Ok(out)
}
/// The node with a result nearest to each point (lowest label on a tie), where it is and how far.
fn nearest_nodes(model: &fieldcraft_model::Model, with_result: &[u32], at: &[[f64; 3]]) -> Result<Vec<(u32, [f64; 3], f64)>> {
let candidates: Vec<(u32, [f64; 3])> = with_result.iter().filter_map(|&l| Some((l, model.node(l)?.xyz))).collect();
at.iter()
.map(|p| {
candidates
.iter()
.map(|&(l, x)| (l, x, ((x[0] - p[0]).powi(2) + (x[1] - p[1]).powi(2) + (x[2] - p[2]).powi(2)).sqrt()))
.min_by(|a, b| a.2.total_cmp(&b.2))
.ok_or_else(|| EngineError::Params("the result has no nodes to probe".into()))
})
.collect()
}
/// The result of an implicit step at nodes.
fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value) -> Result<Value> {
let elements = if p.get("elements").is_some() { labels(p, "elements", MAX_PROBE)? } else { Vec::new() };
let locate = |label: u32| {
result
.locate(label)
.ok_or_else(|| EngineError::Params(format!("node {label} has no result (it belongs to no solved element or coupled reference point)")))
};
let out: Vec<Value> = match result {
ImplicitResult::Static(r) => nodes
.iter()
.map(|&l| {
Ok(match locate(l)? {
Location::Point(k) => {
let p = r.reference_points.get(k);
json!({"node": l, "reference_point": p.map(|p| &p.name), "displacement": p.map(|p| p.displacement), "reaction": p.map(|p| p.reaction)})
}
Location::Node(i) => json!({
"node": l,
"displacement": r.displacement.get(i),
"rotation": r.rotation.get(i),
"reaction": r.reaction.get(i),
"reaction_moment": r.reaction_moment.get(i),
"stress": r.stress.get(i),
"von_mises": r.von_mises.get(i),
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
}),
})
})
.collect::<Result<_>>()?,
ImplicitResult::Frequency(r) => {
if !elements.is_empty() {
return Err(EngineError::Params("element results are kept for static and explicit steps".into()));
}
let mode = opt::<usize>(p, "mode")?.unwrap_or(1);
let m = mode
.checked_sub(1)
.and_then(|k| r.modes.get(k))
.ok_or_else(|| EngineError::Params(format!("`mode` must be from 1 to {}", r.modes.len())))?;
nodes
.iter()
.map(|&l| {
Ok(match locate(l)? {
Location::Point(k) => {
json!({"node": l, "reference_point": r.reference_points.get(k).map(|p| &p.0), "shape": m.point_shape.get(k)})
}
Location::Node(i) => json!({"node": l, "shape": m.shape.get(i), "rotation": m.rotation.get(i)}),
})
})
.collect::<Result<_>>()?
}
ImplicitResult::Heat(r) => {
let (time, t) = crate::results::heat_frame(r, crate::results::Pick { mode: 1, frame: opt::<usize>(p, "frame")? })?;
let out = nodes
.iter()
.map(|&l| {
let i = r.nodes.binary_search(&l).map_err(|_| EngineError::Params(format!("node {l} has no temperature (it belongs to no solid element)")))?;
Ok(json!({"node": l, "temperature": t.get(i), "heat_flux": r.flux.get(i), "reaction": r.reaction.get(i)}))
})
.collect::<Result<Vec<Value>>>()?;
return Ok(json!({"step": step, "time": time, "nodes": out}));
}
};
let mut response = json!({"step": step, "nodes": out});
if let ImplicitResult::Static(r) = result
&& !elements.is_empty()
{
let found: Vec<Value> = elements
.iter()
.map(|&l| element_result(r, l).ok_or_else(|| EngineError::Params(format!("element {l} has no result in step `{step}`"))))
.collect::<Result<_>>()?;
response["elements"] = Value::Array(found);
}
Ok(response)
}
/// The entry of a label-sorted result list.
fn by_label<T>(list: &[T], label: u32, key: impl Fn(&T) -> u32) -> Option<&T> {
list.binary_search_by_key(&label, key).ok().and_then(|i| list.get(i))
}
/// The stored results of one element of a static step.
fn element_result(r: &StaticResult, label: u32) -> Option<Value> {
if let Some(e) = by_label(&r.elements, label, |e| e.label) {
return Some(json!({"element": label, "kind": e.kind.name(), "stress": e.nodal}));
}
if let Some(e) = by_label(&r.shells, label, |e| e.label) {
let s = &e.stress;
return Some(
json!({"element": label, "kind": e.kind.name(), "axes": s.axes, "forces": s.forces, "bottom": s.bottom, "middle": s.middle, "top": s.top}),
);
}
let e = by_label(&r.beams, label, |e| e.label)?;
Some(json!({"element": label, "kind": e.kind.name(), "axes": e.forces.axes, "ends": e.forces.ends}))
}
/// Displacement and velocity at nodes, and element results, in one field frame of an explicit
/// result.
fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], elements: &[u32], p: &Value) -> Result<Value> {
let last = r.frames.len().saturating_sub(1);
let k = opt::<usize>(p, "frame")?.unwrap_or(last);
let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("`frame` must be from 0 to {last}")))?;
if nodes.is_empty() && elements.is_empty() {
return Err(EngineError::Params("give `nodes`, `elements` or both".into()));
}
if !elements.is_empty() && f.stress.is_empty() {
return Err(EngineError::Params(format!("step `{step}` does not output stresses")));
}
let element_values: Vec<Value> = elements
.iter()
.map(|&l| {
let i =
r.elements.binary_search(&l).map_err(|_| EngineError::Params(format!("element {l} has no result (it is not a solved element)")))?;
Ok(json!({
"element": l,
"stress": f.stress.get(i),
"von_mises": f.stress.get(i).map(fieldcraft_fem::von_mises),
"strain": f.strain.get(i),
"plastic_strain": f.plastic_strain.get(i),
"damage": f.damage.get(i),
"temperature": f.temperature.get(i),
"eroded": f.failed.get(i),
}))
})
.collect::<Result<_>>()?;
if nodes.is_empty() {
return Ok(json!({"step": step, "frame": k, "time": f.time, "elements": element_values}));
}
if f.displacement.is_empty() {
return Err(EngineError::Params(format!("step `{step}` does not output displacements")));
}
let out: Vec<Value> = nodes
.iter()
.map(|&l| {
let i =
r.nodes.binary_search(&l).map_err(|_| EngineError::Params(format!("node {l} has no result (it belongs to no solved element)")))?;
Ok(json!({"node": l, "displacement": f.displacement.get(i), "velocity": f.velocity.get(i)}))
})
.collect::<Result<_>>()?;
Ok(json!({"step": step, "frame": k, "time": f.time, "nodes": out, "elements": element_values}))
}
#[cfg(test)]
pub(crate) mod tests {
/// `file.sample` opens the cantilever with nothing to save; `solve.run` solves both its steps
/// in order and the results view comes up on the first.
#[test]
fn samples_open_and_solve_run_solves_every_step() {
use serde_json::json;
let mut s = Session::default();
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
let r = s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
assert_eq!(r["sample"], "cantilever");
assert!(!s.is_dirty() && s.path.is_none());
assert!(s.model.node(1).is_some_and(|n| n.xyz != [0.0, 0.0, 0.0]) || s.model.nodes().len() > 1);
assert!(s.execute("file.sample", &json!({"name": "nope"})).unwrap_err().to_string().contains("cantilever"));
let r = s.execute("solve.run", &json!({})).unwrap();
let steps: Vec<&str> = r["steps"].as_array().unwrap().iter().map(|x| x["step"].as_str().unwrap()).collect();
assert_eq!(steps, ["Load", "Modes"]);
assert!(s.result("Load").is_some() && s.result("Modes").is_some());
assert_eq!(s.results_view.as_ref().map(|v| v.step.as_str()), Some("Load"));
s.execute("file.sample", &json!({"name": "impact"})).unwrap();
assert!(s.results_view.is_none(), "a new model shows no old results");
s.execute("solve.run", &json!({})).unwrap();
assert!(s.result("Impact").is_some());
let mut empty = Session::default();
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("no steps"));
}
use super::*;
/// A cantilever 10 × 1 × 1 of ten hex8 elements with incompatible modes, clamped at x = 0,
/// a total force of 1 down on the free end; a static and a frequency step.
pub(crate) fn cantilever(s: &mut Session) {
let mut script = Vec::new();
let label = |i: usize, j: usize, k: usize| 1 + i + 11 * (j + 2 * k);
for k in 0..2 {
for j in 0..2 {
for i in 0..11 {
script.push(json!({"command": "mesh.node.add", "params": {"label": label(i, j, k), "xyz": [i, j, k]}}));
}
}
}
for i in 0..10 {
let nodes = [
label(i, 0, 0),
label(i + 1, 0, 0),
label(i + 1, 1, 0),
label(i, 1, 0),
label(i, 0, 1),
label(i + 1, 0, 1),
label(i + 1, 1, 1),
label(i, 1, 1),
];
script.push(json!({"command": "mesh.element.add", "params": {"label": i + 1, "kind": "hex8", "nodes": nodes}}));
}
let root: Vec<usize> = (0..4).map(|n| label(0, n % 2, n / 2)).collect();
let tip: Vec<usize> = (0..4).map(|n| label(10, n % 2, n / 2)).collect();
script.extend([
json!({"command": "model.set.create", "params": {"name": "all", "elements": (1..=10).collect::<Vec<_>>()}}),
json!({"command": "model.set.create", "params": {"name": "root", "nodes": root}}),
json!({"command": "model.set.create", "params": {"name": "tip", "nodes": tip}}),
json!({"command": "model.material.create", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1}}),
json!({"command": "model.section.create", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": true}}}),
json!({"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}}),
json!({"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1]}}),
json!({"command": "model.step.create", "params": {"name": "Bend", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}}),
json!({"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 4}, "boundary_conditions": ["clamp"]}}),
]);
s.run_script(&Value::Array(script)).unwrap();
}
#[test]
fn solve_keep_and_probe_results() {
let mut s = Session::default();
cantilever(&mut s);
let journal = s.journal.len();
let r = s.execute("solve.static", &json!({})).unwrap();
assert_eq!(r["step"], "Bend");
// Euler–Bernoulli P L³ / (3 E I) = 4, plus shear; the clamp and one element through the
// depth keep it within a few percent.
let d = r["max_displacement"]["value"].as_f64().unwrap();
assert!((d - 4.024).abs() / 4.024 < 0.03, "{r}");
assert!((r["reaction_total"][2].as_f64().unwrap() - 1.0).abs() < 1e-9, "{r}");
assert_eq!(s.journal.len(), journal, "solving does not change the model");
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
// By location: the nearest node, with where it is.
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
assert_eq!(at["nodes"][0]["displacement"], probe["nodes"][0]["displacement"]);
assert_eq!(at["nodes"][0]["xyz"], json!([10.0, 0.0, 0.0]));
assert!((at["nodes"][0]["distance"].as_f64().unwrap() - 0.06f64.sqrt()).abs() < 1e-12);
for bad in [json!({"step": "Bend"}), json!({"step": "Bend", "nodes": [1], "at": [[0, 0, 0]]}), json!({"step": "Bend", "at": [[0, 0]]})] {
assert!(s.execute("solve.probe", &bad).is_err(), "{bad}");
}
let f = s.execute("solve.frequency", &json!({"mass": "lumped"})).unwrap();
assert_eq!(f["modes"].as_array().unwrap().len(), 4, "{f}");
let shape = s.execute("solve.probe", &json!({"step": "Modes", "nodes": [44], "mode": 2})).unwrap();
assert!(shape["nodes"][0]["shape"].is_array(), "{shape}");
assert_eq!(s.solved_steps().count(), 2);
// Results describe the model they were solved for: an edit clears them, a failed command
// keeps them.
assert!(s.execute("model.material.edit", &json!({"name": "Mat", "elastic": {"type": "isotropic", "young": -1}})).is_err());
assert_eq!(s.solved_steps().count(), 2);
s.execute("model.material.edit", &json!({"name": "Mat", "elastic": {"type": "isotropic", "young": 2000, "poisson": 0}, "density": 1}))
.unwrap();
assert_eq!(s.solved_steps().count(), 0);
// Undo returns to the solved state, and its results are current again.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.solved_steps().count(), 2);
s.execute("edit.redo", &json!({})).unwrap();
let e = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1]})).unwrap_err().to_string();
assert!(e.contains("stale"), "{e}");
}
#[test]
fn solve_hostile_params_never_panic() {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
let hostile = [
json!(null),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Modes"}),
json!({"step": "Bend", "max_dofs": 0}),
json!({"step": "Bend", "max_dofs": 1e300}),
json!({"step": "Bend", "max_dofs": 3}),
json!({"step": "Bend", "max_factor_entries": -1}),
json!({"step": "Bend", "max_factor_entries": 10}),
json!({"step": "Modes", "max_iterations": 1}),
json!({"step": "Modes", "max_iterations": 7}),
json!({"step": "Modes", "mass": "heavy"}),
json!({"step": "Modes", "shift": 1e308}),
json!({"step": "Modes", "shift": -1e308}),
json!({"step": "Modes", "shift": 5000.0}),
json!({"step": "Modes", "shift": "x"}),
json!({"step": "Modes", "nodes": [1], "mode": 0}),
json!({"step": "Modes", "nodes": [1], "mode": 1e300}),
json!({"step": "Bend", "nodes": [0, 99999999, 4294967296u64]}),
json!({"step": "Bend", "nodes": vec![1; 20_000]}),
json!({"step": "Bend", "threads": 0}),
json!({"step": "Bend", "threads": -3}),
json!({"step": "Bend", "threads": 257}),
json!({"step": "Bend", "threads": 1e300}),
json!({"step": "Bend", "threads": "all"}),
json!({"step": "Modes", "threads": 18446744073709551615u64}),
json!({"step": "Modes", "threads": 2.5}),
];
for id in ["solve.static", "solve.frequency", "solve.probe"] {
for p in &hostile {
let mut t = s.clone();
let before = t.model.clone();
let results = t.solved_steps().count();
if t.execute(id, p).is_err() {
assert_eq!(t.model, before);
assert_eq!(t.solved_steps().count(), results, "{id} {p}");
}
}
}
}
/// Couette flow between y = 0 and y = 1 (eight cells), the top wall sliding at 0.1 along x,
/// periodic in x and z: material, domain and fluid step through commands.
pub(crate) fn couette(s: &mut Session) {
let periodic = json!({"type": "periodic"});
let script = json!([
{"command": "model.material.create", "params": {"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}}},
{"command": "model.fluid_domain.create", "params": {"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125,
"faces": {"x_min": periodic, "x_max": periodic, "z_min": periodic, "z_max": periodic}}},
{"command": "model.fluid_domain.boundary", "params": {"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.1, 0, 0]}}},
{"command": "model.step.create", "params": {"name": "Shear", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 50, "frames": 4}}},
]);
s.run_script(&script).unwrap();
}
#[test]
fn solve_a_fluid_step_and_probe_its_fields_and_wall_forces() {
let mut s = Session::default();
couette(&mut s);
let journal = s.journal.len();
let r = s.execute("solve.fluid", &json!({})).unwrap();
assert_eq!(r["step"], "Shear");
assert_eq!(r["frames"].as_array().unwrap().len(), 5, "{r}");
assert!((r["tau"].as_f64().unwrap() - 1.0).abs() < 1e-3, "the automatic time step keeps τ ≤ 1: {r}");
assert!(r["mass"]["relative_change"].as_f64().unwrap().abs() < 1e-12, "{r}");
assert_eq!(s.journal.len(), journal, "solving does not change the model");
// The wall shear μ U / H over the wall's area 0.125².
let bottom = r["forces"].as_array().unwrap().iter().find(|f| f["name"] == "y_min").unwrap();
let expected = 0.1 * 0.1 / 1.0 * 0.125 * 0.125;
assert!((bottom["mean"][0].as_f64().unwrap() / expected - 1.0).abs() < 1e-3, "{bottom}");
let probe = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06], [5, 5, 5]]})).unwrap();
let u = probe["points"][0]["velocity"][0].as_f64().unwrap();
assert!((u - 0.05625).abs() < 1e-5, "{probe}");
assert!(probe["points"][1]["cell"].is_null());
let first = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06]], "frame": 0})).unwrap();
assert_eq!(first["points"][0]["velocity"][0], 0.0, "the initial frame is at rest");
let h = s.execute("solve.probe", &json!({"step": "Shear", "forces": ["y_min", "y_max"]})).unwrap();
assert_eq!(h["forces"].as_array().unwrap().len(), 2);
assert_eq!(h["times"].as_array().unwrap().len(), h["forces"][0]["force"].as_array().unwrap().len());
// An edit makes the result stale; the step solves again.
s.execute(
"model.fluid_domain.boundary",
&json!({"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.2, 0, 0]}}),
)
.unwrap();
assert!(s.execute("solve.probe", &json!({"step": "Shear", "points": [[0, 0, 0]]})).is_err());
// The domain cannot go while a step uses it; the material cannot lose its fluid behaviour.
assert!(s.execute("model.fluid_domain.delete", &json!({"name": "Gap"})).is_err());
assert!(s.execute("model.material.edit", &json!({"name": "Oil", "density": 1.0})).is_err());
// A structural step is not a fluid one, and a fluid step is not written to decks.
assert!(s.execute("solve.static", &json!({"step": "Shear"})).is_err());
for format in ["abaqus", "nastran", "lsdyna"] {
let e = s.execute("deck.write", &json!({"format": format, "path": "/no/such/dir/x.deck"})).unwrap_err().to_string();
assert!(e.contains("fluid step"), "{format}: {e}");
}
}
#[test]
fn a_fluid_domain_takes_its_obstacles_from_the_open_design() {
use fieldcraft_geometry::sample::{Plate, plate_design};
let dir = std::env::temp_dir().join(format!("fieldcraft-fluid-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plate.solidcraft");
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
let [lo, hi] = [0, 1].map(|k| bodies[0]["bbox"][k].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>());
// A box 20 mm larger than the plate on every side, 5 mm cells: a viscous liquid (ν = 100 mm²/s) flowing along x.
let min: Vec<f64> = lo.iter().map(|v| (v / 5.0).floor() * 5.0 - 20.0).collect();
let max: Vec<f64> = hi.iter().map(|v| (v / 5.0).ceil() * 5.0 + 20.0).collect();
let script = json!([
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": min, "max": max, "spacing": 5, "obstacles": ["Body1"],
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
]);
s.run_script(&script).unwrap();
let r = s.execute("solve.fluid", &json!({"step": "Flow"})).unwrap();
assert!(r["fluid_cells"].as_u64().unwrap() < r["cells"].as_u64().unwrap(), "{r}");
let on_plate: Vec<&Value> = r["forces"].as_array().unwrap().iter().filter(|f| f["body"] == "Body1").collect();
assert!(!on_plate.is_empty(), "{r}");
let faces = bodies[0]["faces"].as_array().unwrap();
for f in &on_plate {
assert!(faces.iter().any(|g| g["name"] == f["name"]), "forces are reported per named face: {f}");
}
let drag: f64 = on_plate.iter().map(|f| f["final"][0].as_f64().unwrap()).sum();
assert!(drag > 0.0, "the flow pushes the plate downstream: {r}");
// An obstacle that is not in the design.
s.execute(
"model.fluid_domain.edit",
&json!({"name": "Tank", "material": "Water", "min": min, "max": max, "spacing": 5, "obstacles": ["Nope"]}),
)
.unwrap();
assert!(s.execute("solve.fluid", &json!({})).is_err());
}
#[test]
fn solve_fluid_hostile_params_never_panic() {
let mut s = Session::default();
couette(&mut s);
s.execute("solve.fluid", &json!({"step": "Shear"})).unwrap();
let hostile = [
json!(null),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Shear", "max_cells": 0}),
json!({"step": "Shear", "max_cells": 1e300}),
json!({"step": "Shear", "max_cells": 3}),
json!({"step": "Shear", "max_steps": 3}),
json!({"step": "Shear", "max_steps": -1}),
json!({"step": "Shear", "max_cell_updates": 10}),
json!({"step": "Shear", "max_cell_updates": 0.5}),
json!({"step": "Shear", "max_frame_bytes": 16}),
json!({"step": "Shear", "max_frame_bytes": "lots"}),
json!({"step": "Shear", "points": [[1e308, -1e308, 0]]}),
json!({"step": "Shear", "points": [[f64::MAX, 0, 0]], "frame": 1e300}),
json!({"step": "Shear", "points": [[0, 0, 0]], "frame": 99}),
json!({"step": "Shear", "points": null}),
json!({"step": "Shear", "points": vec![[0, 0, 0]; 20_000]}),
json!({"step": "Shear", "forces": ["nope"]}),
json!({"step": "Shear", "forces": vec!["y_min"; 1000]}),
json!({"step": "Shear", "forces": "y_min"}),
json!({"step": "Shear", "nodes": [1]}),
json!({"step": "Shear", "threads": 0}),
json!({"step": "Shear", "threads": 100000}),
json!({"step": "Shear", "threads": null}),
];
for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] {
for p in &hostile {
let mut t = s.clone();
let before = t.model.clone();
let results = t.solved_steps().count();
if t.execute(id, p).is_err() {
assert_eq!(t.model, before);
assert_eq!(t.solved_steps().count(), results, "{id} {p}");
}
}
}
}
#[test]
fn solve_and_probe_a_coupled_reference_point() {
// The defined bar: a force on a reference point kinematically coupled to the free end.
let mut s = Session::default();
crate::cmd::define::tests::defined_bar(&mut s);
let r = s.execute("solve.static", &json!({"step": "Bend"})).unwrap();
let rp = &r["reference_points"][0];
assert_eq!(rp["name"], "RP", "{r}");
assert!(rp["displacement"][2].as_f64().unwrap() < 0.0, "{r}");
assert!((r["reaction_total"][2].as_f64().unwrap() - 100.0).abs() < 1e-6, "{r}");
let label = rp["label"].as_u64().unwrap();
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [label, 1]})).unwrap();
assert_eq!(probe["nodes"][0]["reference_point"], "RP", "{probe}");
assert_eq!(probe["nodes"][0]["displacement"].as_array().unwrap().len(), 6, "{probe}");
assert!(probe["nodes"][1]["reaction"].is_array(), "{probe}");
s.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
let shape = s.execute("solve.probe", &json!({"step": "Modes", "nodes": [label], "mode": 1})).unwrap();
assert_eq!(shape["nodes"][0]["shape"].as_array().unwrap().len(), 6, "{shape}");
for p in [json!({"step": "Bend", "nodes": [label, label, 0]}), json!({"step": "Modes", "nodes": [label], "mode": 99})] {
assert!(s.execute("solve.probe", &p).is_err(), "{p}");
}
}
/// A cantilever L × 1 × 1 along x of `n[0]` × `n[1]` × `n[1]` hex8 elements, E = 1000, ν = 0,
/// ρ = 1, clamped at x = 0; one node set per cross-section (`x0`, `x1`, …) and the tip's
/// corner node `tip`; a frequency step `Modes` and the section `beam`.
fn explicit_cantilever(s: &mut Session, length: f64, n: [usize; 2]) {
use fieldcraft_model::{ElementKind, Set};
let [nx, ny] = n;
let label = |i: usize, j: usize, k: usize| (1 + i + (nx + 1) * (j + (ny + 1) * k)) as u32;
for k in 0..=ny {
for j in 0..=ny {
for i in 0..=nx {
s.model.set_node(label(i, j, k), [length * i as f64 / nx as f64, j as f64 / ny as f64, k as f64 / ny as f64]).unwrap();
}
}
}
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
let mut e = 1;
for k in 0..ny {
for j in 0..ny {
for i in 0..nx {
s.model.set_element(e, ElementKind::Hex8, corners.iter().map(|c| label(i + c[0], j + c[1], k + c[2])).collect()).unwrap();
e += 1;
}
}
}
for i in 0..=nx {
let nodes = (0..=ny).flat_map(|k| (0..=ny).map(move |j| label(i, j, k))).collect();
s.model.add_set(&format!("x{i}"), Set { nodes, ..Set::default() }).unwrap();
}
s.model.add_set("tip", Set { nodes: vec![label(nx, 0, 0)], ..Set::default() }).unwrap();
s.model.add_set("all", Set { elements: (1..e).collect(), ..Set::default() }).unwrap();
let script = json!([
{"command": "model.material.create", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1}},
{"command": "model.section.create", "params": {"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": true}}},
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "x0"}}},
{"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 1}, "boundary_conditions": ["clamp"]}},
]);
s.run_script(&script).unwrap();
}
/// The stored result of a step, hashed bit for bit (Debug prints every float exactly).
fn result_hash(s: &Session, step: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
format!("{:?}", s.result(step).unwrap()).hash(&mut h);
h.finish()
}
/// Implicit (static, frequency), explicit and lattice Boltzmann solves large enough for every
/// parallel path (factorization tasks and tiles, element rounds, cell blocks) give the same
/// bits on 1, 2 and 7 threads.
#[test]
fn results_do_not_depend_on_the_thread_count() {
let solve_all = |threads: usize| -> Vec<u64> {
let mut s = Session::default();
explicit_cantilever(&mut s, 10.0, [30, 6]);
let script = json!([
{"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "x30"}, "vector": [0, 0.3, -1]}},
{"command": "model.step.create", "params": {"name": "Bend", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.static", &json!({"step": "Bend", "threads": threads})).unwrap();
s.execute("solve.frequency", &json!({"step": "Modes", "threads": threads})).unwrap();
let mut hashes = vec![result_hash(&s, "Bend"), result_hash(&s, "Modes")];
let step = json!({
"type": "explicit_dynamic", "end_time": 0.15, "output_interval": 0.05,
"initial_velocities": [{"set": "x30", "velocity": [0, 0, -2]}], "hourglass": {"type": "stiffness"}
});
let script = json!([
{"command": "model.section.edit", "params": {"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}}},
{"command": "model.step.create", "params": {"name": "Hit", "procedure": step, "boundary_conditions": ["clamp"]}},
]);
s.run_script(&script).unwrap();
s.execute("solve.explicit", &json!({"step": "Hit", "threads": threads})).unwrap();
hashes.push(result_hash(&s, "Hit"));
let mut f = Session::default();
let periodic = json!({"type": "periodic"});
let script = json!([
{"command": "model.material.create", "params": {"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}}},
{"command": "model.fluid_domain.create", "params": {"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [5, 1, 2], "spacing": 0.125,
"faces": {"z_min": periodic, "z_max": periodic}}},
{"command": "model.fluid_domain.boundary", "params": {"domain": "Gap", "at": "y_max", "condition": {"type": "moving_wall", "velocity": [0.1, 0, 0.02]}}},
{"command": "model.step.create", "params": {"name": "Shear", "procedure": {"type": "fluid", "domain": "Gap", "end_time": 2, "frames": 2}}},
]);
f.run_script(&script).unwrap();
f.execute("solve.fluid", &json!({"step": "Shear", "threads": threads})).unwrap();
hashes.push(result_hash(&f, "Shear"));
hashes
};
let one = solve_all(1);
for threads in [2, 7] {
assert_eq!(solve_all(threads), one, "{threads} threads");
}
let mut s = Session::default();
cantilever(&mut s);
for bad in [json!(0), json!(257), json!(-1), json!(1.5), json!("8")] {
let e = s.execute("solve.static", &json!({"threads": bad})).unwrap_err().to_string();
assert!(e.contains("`threads` must be a whole number from 1 to 256"), "{e}");
}
s.limits.threads = 3;
s.execute("solve.static", &json!({})).unwrap();
}
/// Upward zero crossings of a sampled signal, linearly interpolated.
fn crossings(time: &[f64], x: &[f64]) -> Vec<f64> {
let mut out = Vec::new();
for k in 1..x.len() {
let (a, b) = (x[k - 1], x[k]);
if a < 0.0 && b >= 0.0 {
out.push(time[k - 1] + (time[k] - time[k - 1]) * (-a) / (b - a));
}
}
out
}
/// Free vibration of a cantilever after an initial velocity in the shape of its static
/// deflection: the period of the explicit run (one-point hex8) against the first natural
/// frequency of the implicit modal solver (incompatible-mode hex8, lumped mass) on the same
/// mesh. The one-point element's mean strain misses the linear variation of the bending
/// strain through each element: with n elements through the depth it keeps 1 − 1/n² of the
/// bending energy, so without hourglass stiffness (viscous control) the period is longer by
/// (1 − 1/n²)^{−½} (3.3% for n = 4); the stiffness form restores part of it.
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn explicit_cantilever_period_matches_the_modal_frequency() {
let (length, n) = (10.0, [40usize, 4usize]);
let mut s = Session::default();
explicit_cantilever(&mut s, length, n);
let modes = s.execute("solve.frequency", &json!({"mass": "lumped"})).unwrap();
let period = 1.0 / modes["modes"][0]["frequency"].as_f64().unwrap();
// Euler–Bernoulli: f₁ = 1.875² / (2π L²) √(E I / (ρ A)), I/A = 1/12.
let euler = 2.0 * std::f64::consts::PI * length * length / 1.875104f64.powi(2) / (1000.0f64 / 12.0).sqrt();
assert!((period / euler - 1.0).abs() < 0.01, "{period} vs {euler}");
let section = json!({"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}});
s.execute("model.section.edit", &section).unwrap();
// v(x) ∝ x²(3L − x): the static deflection of a tip load, close to the first mode.
let velocities: Vec<Value> = (0..=n[0])
.map(|i| {
let x = length * i as f64 / n[0] as f64;
json!({"set": format!("x{i}"), "velocity": [0, 0, 0.01 * x * x * (3.0 * length - x) / (2.0 * length.powi(3))]})
})
.collect();
let one_point = period / (1.0 - 1.0 / (n[1] * n[1]) as f64).sqrt();
let mut measured = Vec::new();
for hourglass in ["viscous", "stiffness"] {
let step = json!({
"type": "explicit_dynamic", "end_time": 4.2 * period, "history_interval": period / 400.0, "output_interval": period,
"history_nodes": "tip", "initial_velocities": velocities, "hourglass": {"type": hourglass}
});
let name = format!("Swing-{hourglass}");
s.execute("model.step.create", &json!({"name": name, "procedure": step, "boundary_conditions": ["clamp"]})).unwrap();
let r = s.execute("solve.explicit", &json!({"step": name})).unwrap();
let h = s.execute("solve.history", &json!({"step": name})).unwrap();
let time: Vec<f64> = h["time"].as_array().unwrap().iter().map(|t| t.as_f64().unwrap()).collect();
let uz: Vec<f64> = h["nodes"][0]["displacement"].as_array().unwrap().iter().map(|d| d[2].as_f64().unwrap()).collect();
// The tip starts at zero moving up: it crosses zero upwards once per period after that.
let ups = crossings(&time, &uz);
let t = (ups[ups.len() - 1] - ups[0]) / (ups.len() - 1) as f64;
eprintln!(
"cantilever {n:?}, {hourglass} hourglass: explicit period {t:.5} vs modal {period:.5} ({:+.3}%), one-point estimate {one_point:.5} ({:+.3}%), Euler–Bernoulli {euler:.5}; energy balance {:.1e}, {} increments",
100.0 * (t / period - 1.0),
100.0 * (t / one_point - 1.0),
r["energy_balance"].as_f64().unwrap(),
r["increments"]
);
assert!(ups.len() >= 4, "{ups:?}");
assert!(r["energy_balance"].as_f64().unwrap() < 0.01, "{r}");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| w.as_str().unwrap().contains("unit system")), "{r}");
measured.push(t);
}
// Viscous control adds no stiffness: the one-point estimate holds closely.
assert!((measured[0] / one_point - 1.0).abs() < 0.005, "{measured:?} vs {one_point}");
// The stiffness form lies between it and the modal period, and within 3% of the latter.
assert!(measured[1] > period && measured[1] < measured[0], "{measured:?}");
assert!((measured[1] / period - 1.0).abs() < 0.03, "{measured:?} vs {period}");
}
/// A small impact model through commands: a hex8 bar moving onto a rigid wall.
fn explicit_impact(s: &mut Session) {
explicit_cantilever(s, 4.0, [8, 1]);
let step = json!({
"type": "explicit_dynamic", "end_time": 0.2, "history_interval": 0.002, "output_interval": 0.05, "history_nodes": "tip",
"initial_velocities": [{"set": "all_nodes", "velocity": [-0.1, 0, 0]}],
"rigid_walls": [{"set": "x0", "point": [0, 0, 0], "normal": [1, 0, 0]}]
});
let nodes: Vec<u32> = s.model.nodes().keys().copied().collect();
let script = json!([
{"command": "model.set.create", "params": {"name": "all_nodes", "nodes": nodes}},
{"command": "model.section.edit", "params": {"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}}},
{"command": "model.step.create", "params": {"name": "Impact", "procedure": step}},
]);
s.run_script(&script).unwrap();
}
#[test]
fn explicit_results_are_kept_probed_and_guarded() {
let mut s = Session::default();
explicit_impact(&mut s);
let journal = s.journal.len();
let r = s.execute("solve.explicit", &json!({"step": "Impact"})).unwrap();
assert_eq!((r["completed"].as_bool(), r["frames"].as_u64()), (Some(true), Some(5)), "{r}");
// Eight elements, so eight controlling ones; on this coarse mesh the impact excites modes
// near the stability limit, whose energy the integer-step account measures only roughly.
assert_eq!(r["controlling_elements"].as_array().unwrap().len(), 8, "{r}");
assert!(r["energy_balance"].as_f64().unwrap() < 0.1, "{r}");
assert_eq!(s.journal.len(), journal, "solving does not change the model");
let probe = s.execute("solve.probe", &json!({"step": "Impact", "nodes": [1, 9], "frame": 0})).unwrap();
assert_eq!(probe["nodes"][1]["velocity"][0].as_f64(), Some(-0.1), "{probe}");
let last = s.execute("solve.probe", &json!({"step": "Impact", "nodes": [9]})).unwrap();
assert_eq!(last["frame"], 4, "{last}");
let el = s.execute("solve.probe", &json!({"step": "Impact", "elements": [1, 8]})).unwrap();
let e = &el["elements"][0];
assert!(e["stress"].as_array().unwrap().len() == 6 && e["von_mises"].as_f64().unwrap() >= 0.0 && e["eroded"] == false, "{el}");
assert!(e["plastic_strain"].is_null(), "an elastic model has no plastic strain: {el}");
assert_eq!(r["eroded"].as_array().map(Vec::len), Some(0), "{r}");
let h = s.execute("solve.history", &json!({"step": "Impact", "every": 2})).unwrap();
assert_eq!(h["time"].as_array().unwrap().len(), h["energies"].as_array().unwrap().len());
assert!(h["nodes"][0]["velocity"].as_array().unwrap().len() > 5, "{h}");
assert_eq!(s.solved_steps().collect::<Vec<_>>(), ["Impact"]);
assert!(s.execute("solve.history", &json!({"step": "Modes"})).is_err(), "not solved");
let hostile = [
json!(null),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Modes"}),
json!({"step": "Impact", "max_dofs": 0}),
json!({"step": "Impact", "max_dofs": 5}),
json!({"step": "Impact", "max_increments": 0}),
json!({"step": "Impact", "max_increments": 3}),
json!({"step": "Impact", "max_increments": -1}),
json!({"step": "Impact", "max_increments": 1e300}),
json!({"step": "Impact", "max_output_values": 1}),
json!({"step": "Impact", "every": 0}),
json!({"step": "Impact", "every": -3}),
json!({"step": "Impact", "every": 1e300}),
json!({"step": "Impact", "every": u64::MAX}),
json!({"step": "Impact", "nodes": [1], "frame": 99}),
json!({"step": "Impact", "nodes": [1], "frame": -1}),
json!({"step": "Impact", "nodes": [0, 4294967296u64]}),
json!({"step": "Impact"}),
json!({"step": "Impact", "elements": [999]}),
json!({"step": "Impact", "elements": [0, -1, 1.5]}),
json!({"step": "Impact", "elements": vec![1; 20_000]}),
json!({"step": "Impact", "elements": "all", "nodes": [1]}),
];
for id in ["solve.explicit", "solve.history", "solve.probe"] {
for p in &hostile {
let mut t = s.clone();
let before = t.model.clone();
let results = t.solved_steps().count();
if t.execute(id, p).is_err() {
assert_eq!(t.model, before);
assert_eq!(t.solved_steps().count(), results, "{id} {p}");
}
}
}
// Hostile steps are refused when they are defined; solving one that a later edit makes
// impossible (a node behind its wall) is an error, not a panic.
for bad in [
json!({"type": "explicit_dynamic", "end_time": -1}),
json!({"type": "explicit_dynamic", "end_time": 1, "dt_scale": 2}),
json!({"type": "explicit_dynamic", "end_time": 1, "max_increments": 0}),
json!({"type": "explicit_dynamic", "end_time": 1, "output_interval": 1e-300}),
json!({"type": "explicit_dynamic", "end_time": 1e308, "history_interval": 1e-308}),
json!({"type": "explicit_dynamic", "end_time": 1, "rigid_walls": [{"set": "x0", "point": [0, 0, 0], "normal": [0, 0, 0]}]}),
json!({"type": "explicit_dynamic", "end_time": 1, "initial_velocities": [{"set": "nope", "velocity": [1, 0, 0]}]}),
json!({"type": "explicit_dynamic", "end_time": 1, "hourglass": {"type": "magic"}}),
] {
assert!(s.execute("model.step.create", &json!({"name": "Bad", "procedure": bad})).is_err(), "{bad}");
}
let behind = json!({"type": "explicit_dynamic", "end_time": 0.1, "rigid_walls": [{"set": "x0", "point": [1, 0, 0], "normal": [1, 0, 0]}]});
s.execute("model.step.create", &json!({"name": "Behind", "procedure": behind})).unwrap();
assert!(s.execute("solve.explicit", &json!({"step": "Behind"})).unwrap_err().to_string().contains("behind"));
let long = json!({"type": "explicit_dynamic", "end_time": 1e6});
s.execute("model.step.create", &json!({"name": "Long", "procedure": long})).unwrap();
assert!(s.execute("solve.explicit", &json!({"step": "Long"})).unwrap_err().to_string().contains("increments"));
assert!(s.execute("model.set.delete", &json!({"name": "all_nodes"})).unwrap_err().to_string().contains("step `Impact`"));
// Defining steps edited the model, so the impact result is stale.
assert_eq!(s.solved_steps().count(), 0);
}
/// A mass on a spring and a dashpot, defined, solved and written through commands; editing
/// and deleting them goes through the same checks as every definition.
#[test]
fn spring_mass_through_commands() {
let mut s = Session::default();
let script = json!([
{"command": "mesh.node.add", "params": {"label": 1, "xyz": [0, 0, 0]}},
{"command": "model.set.create", "params": {"name": "n", "nodes": [1]}},
{"command": "model.mass.create", "params": {"name": "m", "target": {"set": "n"}, "mass": 4}},
{"command": "model.spring.create", "params": {"name": "k", "type": "spring", "first": {"set": "n"}, "direction": {"dof": 1}, "stiffness": 400}},
{"command": "model.spring.create", "params": {"name": "c", "type": "dashpot", "first": {"set": "n"}, "direction": {"vector": [1, 0, 0]}, "coefficient": 1}},
{"command": "model.boundary_condition.create", "params": {"name": "yz", "type": "displacement", "target": {"set": "n"}, "values": [null, 0, 0, null, null, null]}},
{"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 1}, "boundary_conditions": ["yz"]}},
]);
s.run_script(&script).unwrap();
let r = s.execute("solve.frequency", &json!({})).unwrap();
let f = r["modes"][0]["frequency"].as_f64().unwrap();
assert!((f - 10.0 / (2.0 * std::f64::consts::PI)).abs() < 1e-12, "{r}");
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("dashpots `c`")), "{r}");
assert!(s.execute("model.mass.edit", &json!({"name": "m", "target": {"set": "n"}, "mass": -1})).is_err());
assert!(s.execute("model.set.delete", &json!({"name": "n"})).is_err(), "the set is in use");
s.execute("model.mass.edit", &json!({"name": "m", "target": {"set": "n"}, "mass": 1})).unwrap();
let r = s.execute("solve.frequency", &json!({})).unwrap();
assert!((r["modes"][0]["frequency"].as_f64().unwrap() - 20.0 / (2.0 * std::f64::consts::PI)).abs() < 1e-12);
s.execute("model.spring.delete", &json!({"name": "c"})).unwrap();
assert!(s.model.springs().len() == 1 && s.model.mass("m").is_some());
}
#[test]
fn solve_and_probe_shells_and_beams() {
// A cantilever strip of two quad4 shells clamped at x = 0, stiffened along its tip edge
// by a beam; a downward force at a tip corner.
let mut s = Session::default();
let mut script = Vec::new();
for (label, xyz) in [(1, [0, 0, 0]), (2, [10, 0, 0]), (3, [20, 0, 0]), (4, [0, 5, 0]), (5, [10, 5, 0]), (6, [20, 5, 0])] {
script.push(json!({"command": "mesh.node.add", "params": {"label": label, "xyz": xyz}}));
}
script.extend([
json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "quad4", "nodes": [1, 2, 5, 4]}}),
json!({"command": "mesh.element.add", "params": {"label": 2, "kind": "quad4", "nodes": [2, 3, 6, 5]}}),
json!({"command": "mesh.element.add", "params": {"label": 3, "kind": "beam2", "nodes": [3, 6]}}),
json!({"command": "model.set.create", "params": {"name": "plate", "elements": [1, 2]}}),
json!({"command": "model.set.create", "params": {"name": "stiffener", "elements": [3]}}),
json!({"command": "model.set.create", "params": {"name": "root", "nodes": [1, 4]}}),
json!({"command": "model.set.create", "params": {"name": "corner", "nodes": [3]}}),
json!({"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}}),
json!({"command": "model.section.create", "params": {"name": "skin", "set": "plate", "material": "Steel", "kind": {"type": "shell", "thickness": 1}}}),
json!({"command": "model.section.create", "params": {"name": "rib", "set": "stiffener", "material": "Steel", "kind": {"type": "beam", "profile": {"type": "rectangle", "width": 1, "height": 2}, "orientation": [1, 0, 0]}}}),
json!({"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}}),
json!({"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "corner"}, "vector": [0, 0, -1]}}),
json!({"command": "model.step.create", "params": {"name": "Bend", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}}),
json!({"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 3}, "boundary_conditions": ["clamp"]}}),
]);
s.run_script(&Value::Array(script)).unwrap();
let r = s.execute("solve.static", &json!({"step": "Bend"})).unwrap();
assert!((r["reaction_total"][2].as_f64().unwrap() - 1.0).abs() < 1e-9, "{r}");
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [3, 1], "elements": [1, 3]})).unwrap();
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < 0.0, "{probe}");
assert!(probe["nodes"][0]["rotation"][0].as_f64().unwrap() != 0.0, "{probe}");
assert!(probe["nodes"][1]["reaction_moment"].is_array(), "{probe}");
assert_eq!(probe["elements"][0]["forces"].as_array().unwrap().len(), 4, "{probe}");
assert_eq!(probe["elements"][1]["ends"].as_array().unwrap().len(), 2, "{probe}");
let only = s.execute("solve.probe", &json!({"step": "Bend", "elements": [2]})).unwrap();
assert_eq!(only["nodes"].as_array().unwrap().len(), 0, "{only}");
s.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
let shape = s.execute("solve.probe", &json!({"step": "Modes", "nodes": [3], "mode": 1})).unwrap();
assert!(shape["nodes"][0]["rotation"].is_array(), "{shape}");
for p in [
json!({"step": "Bend", "elements": [99]}),
json!({"step": "Bend", "elements": [0]}),
json!({"step": "Bend", "elements": "1"}),
json!({"step": "Bend", "elements": vec![1; 20_000]}),
json!({"step": "Modes", "nodes": [3], "elements": [1]}),
] {
assert!(s.execute("solve.probe", &p).is_err(), "{p}");
}
}
#[test]
fn solve_heat_and_probe_temperatures() {
// The cantilever bar as a conductor: 0 at the root, 100 at the tip, linear between.
let mut s = Session::default();
cantilever(&mut s);
let script = json!([
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2, "specific_heat": 3, "expansion": 1e-5}}},
{"command": "model.boundary_condition.create", "params": {"name": "cold", "type": "temperature", "target": {"set": "root"}, "value": 0}},
{"command": "model.boundary_condition.create", "params": {"name": "hot", "type": "temperature", "target": {"set": "tip"}, "value": 100}},
{"command": "model.step.create", "params": {"name": "Heat", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["cold", "hot"]}},
{"command": "model.step.create", "params": {"name": "Warm", "procedure": {"type": "heat_transfer", "transient": {"end_time": 10, "increment": 1}}, "boundary_conditions": ["cold", "hot"]}},
]);
s.run_script(&script).unwrap();
let r = s.execute("solve.heat", &json!({"step": "Heat"})).unwrap();
assert!((r["max_temperature"]["value"].as_f64().unwrap() - 100.0).abs() < 1e-9, "{r}");
assert!(r["min_temperature"]["value"].as_f64().unwrap().abs() < 1e-9, "{r}");
let probe = s.execute("solve.probe", &json!({"step": "Heat", "nodes": [6]})).unwrap();
assert!((probe["nodes"][0]["temperature"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{probe}");
assert!((probe["nodes"][0]["heat_flux"][0].as_f64().unwrap() + 20.0).abs() < 1e-9, "{probe}");
let w = s.execute("solve.heat", &json!({"step": "Warm"})).unwrap();
assert_eq!(w["frames"], 11, "{w}");
let early = s.execute("solve.probe", &json!({"step": "Warm", "nodes": [6], "frame": 1})).unwrap();
assert_eq!(early["time"], 1.0);
assert!(s.execute("solve.probe", &json!({"step": "Warm", "nodes": [6], "frame": 99})).is_err());
assert!(s.execute("solve.probe", &json!({"step": "Warm", "nodes": [999]})).is_err());
assert!(s.execute("solve.heat", &json!({"step": "Bend"})).is_err(), "not a heat step");
assert!(s.execute("solve.heat", &json!({"step": "Heat", "max_dofs": 0})).is_err());
// The results display shows temperatures, undeformed.
let view = crate::results::ResultsView::for_result(&s.latest_result("Heat").unwrap().0.clone());
assert_eq!((view.contour, view.deformed), (Some(crate::results::Field::Temperature), false));
let (heat, _) = s.latest_result("Heat").unwrap();
let fields: Vec<_> = crate::results::Field::available(heat).collect();
assert_eq!(fields, vec![crate::results::Field::Temperature, crate::results::Field::HeatFlux]);
assert!(crate::results::displacements(heat, crate::results::Pick::default()).is_err());
}
}