//! `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 + TryFrom + std::fmt::Display>(p: &Value, key: &str, default: T) -> Result { 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 { 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 { 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, 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 { use fieldcraft_model::Procedure; type Solve = fn(&mut Session, &Value) -> Result; 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) -> Value { extreme(values, |v, b| v > b) } fn smallest(values: impl Iterator) -> Value { extreme(values, |v, b| v < b) } fn run_static(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(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::>(), "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 { let limits = limits(p)?; let step = opt::(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 { let limits = limits(p)?; let step = opt::(p, "step")?; let mass = opt::(p, "mass")?.unwrap_or_default(); let shift = opt::(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::>(), "warnings": r.warnings, }); s.store_result(StepResult::Implicit(ImplicitResult::Frequency(r))); Ok(summary) } fn run_explicit(s: &mut Session, p: &Value) -> Result { 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::(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 { 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::(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 { (0..samples).step_by(every).map(v).collect() }; let nodes: Vec = 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> { 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 { 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::(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 = 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::() / 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::>(), "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 { if let Some(names) = opt::>(p, "forces")? { if names.len() > MAX_PROBE_FORCES { return Err(EngineError::Params(format!("`forces` lists at most {MAX_PROBE_FORCES} walls"))); } let out: Vec = 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::>()?; return Ok(json!({"step": r.step, "times": r.history_times, "forces": out})); } let pts = points(p, "points", MAX_PROBE)?; let frame = match opt::(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 = 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 { let step = opt::(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> { 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 { 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 = 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::>()?, ImplicitResult::Frequency(r) => { if !elements.is_empty() { return Err(EngineError::Params("element results are kept for static and explicit steps".into())); } let mode = opt::(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::>()? } ImplicitResult::Heat(r) => { let (time, t) = crate::results::heat_frame(r, crate::results::Pick { mode: 1, frame: opt::(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::>>()?; 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 = elements .iter() .map(|&l| element_result(r, l).ok_or_else(|| EngineError::Params(format!("element {l} has no result in step `{step}`")))) .collect::>()?; response["elements"] = Value::Array(found); } Ok(response) } /// The entry of a label-sorted result list. fn by_label(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 { 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 { let last = r.frames.len().saturating_sub(1); let k = opt::(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 = 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::>()?; 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 = 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::>()?; 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 = (0..4).map(|n| label(0, n % 2, n / 2)).collect(); let tip: Vec = (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::>()}}), 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::>()); // 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 = lo.iter().map(|v| (v / 5.0).floor() * 5.0 - 20.0).collect(); let max: Vec = 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 { 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 { 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", §ion).unwrap(); // v(x) ∝ x²(3L − x): the static deflection of a tip load, close to the first mode. let velocities: Vec = (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 = h["time"].as_array().unwrap().iter().map(|t| t.as_f64().unwrap()).collect(); let uz: Vec = 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 = 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::>(), ["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()); } }