Files
OneCAD-Simulation/crates/engine/src/results.rs
T
jyvikoandClaude Opus 5.5 5112855b83 results: clamp the contour legend's range; values above it in an over colour (#191)
results.contour takes range {min?, max?} (or "auto"), kept until the field changes: a
singular peak no longer squeezes the field into the bottom bands. Values above a clamped
maximum go to an extra band drawn in the over colour, split exactly at the clamp; the legend
shows that band up to the true maximum and says it is clamped. The results panel has Legend
max/min (Auto or a value), the MCP show_results tool a range argument, and headless pictures
follow the clamp too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00

824 lines
36 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.
//! What the results display shows ([`ResultsView`], set by the `results.*` commands) and the
//! nodal values it draws: displacements, mode shapes, explicit field frames and stress fields,
//! read from a solved step's [`StepResult`]. An explicit frame's one-point element stresses are
//! averaged to the nodes (unweighted, like the implicit solver's nodal stresses).
//!
//! The view is session state, like the camera: changing it is not an edit, so it is neither
//! journalled nor undone, and it does not change the session revision. A result kept from an
//! earlier revision is still shown, marked stale ([`Session::latest_result`]).
use fieldcraft_explicit::{ExplicitResult, Frame};
use fieldcraft_model::{Model, UnitSystem};
use fieldcraft_solver::{FrequencyResult, HeatResult, StaticResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::schema::Dim;
use crate::{EngineError, Result, Session, StepResult};
/// A result by its procedure.
pub enum Kind<'a> {
Static(&'a StaticResult),
Frequency(&'a FrequencyResult),
Explicit(&'a ExplicitResult),
/// Per cell, not per node: no nodal fields to display yet.
Fluid(&'a fieldcraft_lbm::FluidResult),
Heat(&'a HeatResult),
}
impl StepResult {
pub fn kind(&self) -> Kind<'_> {
match self {
StepResult::Implicit(fieldcraft_solver::StepResult::Static(r)) => Kind::Static(r),
StepResult::Implicit(fieldcraft_solver::StepResult::Frequency(r)) => Kind::Frequency(r),
StepResult::Implicit(fieldcraft_solver::StepResult::Heat(r)) => Kind::Heat(r),
StepResult::Explicit(r) => Kind::Explicit(r),
StepResult::Fluid(r) => Kind::Fluid(r),
}
}
}
/// Which part of a result is shown: the mode (from 1, frequency steps) and the field frame
/// (explicit steps; `None` is the last).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Pick {
pub mode: usize,
pub frame: Option<usize>,
}
/// The temperature frame of a heat result `pick` asks for.
pub fn heat_frame(r: &HeatResult, pick: Pick) -> Result<(f64, &[f64])> {
let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no temperatures", r.step)))?;
let k = pick.frame.unwrap_or(last);
match (r.times.get(k), r.frames.get(k)) {
(Some(t), Some(f)) => Ok((*t, f)),
_ => Err(EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step))),
}
}
/// The field frame of an explicit result `pick` asks for.
pub fn frame(r: &ExplicitResult, pick: Pick) -> Result<&Frame> {
let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no field frames", r.step)))?;
let k = pick.frame.unwrap_or(last);
r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step)))
}
/// A nodal quantity drawn as contours.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Field {
/// Displacement magnitude (a mode's shape magnitude for a frequency step).
Displacement,
DisplacementX,
DisplacementY,
DisplacementZ,
/// Velocity magnitude (explicit steps).
Velocity,
VonMises,
MaxPrincipal,
MidPrincipal,
MinPrincipal,
StressXx,
StressYy,
StressZz,
StressXy,
StressXz,
StressYz,
/// Temperature (heat transfer and coupled steps).
Temperature,
/// Heat flux magnitude (heat transfer steps, at the end of the step).
HeatFlux,
/// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary
/// surfaces).
ContactPressure,
/// The normalised contact gap of the secondary surfaces' nodes (negative: penetration).
ContactGap,
/// Contact status of the secondary surfaces' nodes: 0 open, 1 closed (frictionless) or
/// sticking, 2 slipping.
ContactStatus,
/// The magnitude of the friction's shear traction (0 off the secondary surfaces).
ContactShear,
/// The magnitude of the frictional slip over the last increment (secondary surfaces' nodes).
ContactSlip,
}
impl Field {
pub const ALL: [Field; 22] = [
Field::Displacement,
Field::DisplacementX,
Field::DisplacementY,
Field::DisplacementZ,
Field::Velocity,
Field::VonMises,
Field::MaxPrincipal,
Field::MidPrincipal,
Field::MinPrincipal,
Field::StressXx,
Field::StressYy,
Field::StressZz,
Field::StressXy,
Field::StressXz,
Field::StressYz,
Field::Temperature,
Field::HeatFlux,
Field::ContactPressure,
Field::ContactGap,
Field::ContactStatus,
Field::ContactShear,
Field::ContactSlip,
];
/// The id commands take (`von_mises`…).
pub fn id(self) -> &'static str {
match self {
Field::Displacement => "displacement",
Field::DisplacementX => "displacement_x",
Field::DisplacementY => "displacement_y",
Field::DisplacementZ => "displacement_z",
Field::Velocity => "velocity",
Field::VonMises => "von_mises",
Field::MaxPrincipal => "max_principal",
Field::MidPrincipal => "mid_principal",
Field::MinPrincipal => "min_principal",
Field::StressXx => "stress_xx",
Field::StressYy => "stress_yy",
Field::StressZz => "stress_zz",
Field::StressXy => "stress_xy",
Field::StressXz => "stress_xz",
Field::StressYz => "stress_yz",
Field::Temperature => "temperature",
Field::HeatFlux => "heat_flux",
Field::ContactPressure => "contact_pressure",
Field::ContactGap => "contact_gap",
Field::ContactStatus => "contact_status",
Field::ContactShear => "contact_shear",
Field::ContactSlip => "contact_slip",
}
}
pub fn label(self) -> &'static str {
match self {
Field::Displacement => "Displacement magnitude",
Field::DisplacementX => "Displacement X",
Field::DisplacementY => "Displacement Y",
Field::DisplacementZ => "Displacement Z",
Field::Velocity => "Velocity magnitude",
Field::VonMises => "Von Mises stress",
Field::MaxPrincipal => "Max. principal stress",
Field::MidPrincipal => "Mid. principal stress",
Field::MinPrincipal => "Min. principal stress",
Field::StressXx => "Stress XX",
Field::StressYy => "Stress YY",
Field::StressZz => "Stress ZZ",
Field::StressXy => "Stress XY",
Field::StressXz => "Stress XZ",
Field::StressYz => "Stress YZ",
Field::Temperature => "Temperature",
Field::HeatFlux => "Heat flux magnitude",
Field::ContactPressure => "Contact pressure",
Field::ContactGap => "Contact gap",
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
Field::ContactShear => "Contact shear traction",
Field::ContactSlip => "Contact slip",
}
}
/// What it measures.
pub fn quantity(self) -> Quantity {
match self {
Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length,
Field::Velocity => Quantity::Velocity,
Field::Temperature => Quantity::Temperature,
Field::HeatFlux => Quantity::HeatFlux,
Field::ContactGap | Field::ContactStatus => Quantity::None,
Field::ContactPressure
| Field::ContactShear
| Field::VonMises
| Field::MaxPrincipal
| Field::MidPrincipal
| Field::MinPrincipal
| Field::StressXx
| Field::StressYy
| Field::StressZz
| Field::StressXy
| Field::StressXz
| Field::StressYz => Quantity::Stress,
}
}
/// Whether `r` holds it as a mode shape: normalised, with no unit.
pub fn is_mode_shape(self, r: &StepResult) -> bool {
matches!(r.kind(), Kind::Frequency(_)) && self.quantity() == Quantity::Length
}
/// Its unit for result `r` in `system`: none for a mode shape (it is normalised).
pub fn unit(self, r: &StepResult, system: UnitSystem) -> Option<String> {
if self.is_mode_shape(r) { None } else { self.quantity().unit(system) }
}
/// Its title over result `r`: the label with its unit (`Von Mises stress [MPa]`), or, for
/// a mode shape, saying it is normalised.
pub fn title(self, r: &StepResult, system: UnitSystem) -> String {
if self.is_mode_shape(r) {
let label = self.label().trim_end_matches(" magnitude");
return format!("{label} (mode shape, normalised)");
}
with_unit(self.label(), self.unit(r, system).as_deref())
}
pub fn parse(id: &str) -> Option<Field> {
Field::ALL.into_iter().find(|f| f.id() == id)
}
/// Whether it is a stress.
pub fn is_stress(self) -> bool {
!matches!(
self,
Field::Displacement
| Field::DisplacementX
| Field::DisplacementY
| Field::DisplacementZ
| Field::Velocity
| Field::Temperature
| Field::HeatFlux
) && !self.is_contact()
}
/// Whether it is a contact quantity.
pub fn is_contact(self) -> bool {
matches!(self, Field::ContactPressure | Field::ContactGap | Field::ContactStatus | Field::ContactShear | Field::ContactSlip)
}
/// Whether it is a heat transfer quantity.
pub fn is_thermal(self) -> bool {
matches!(self, Field::Temperature | Field::HeatFlux)
}
/// The fields a result has: displacements everywhere, stresses for static steps and explicit
/// frames that kept them (mode shapes have no stress scale), velocity for explicit frames.
pub fn available(r: &StepResult) -> impl Iterator<Item = Field> + '_ {
let contact = matches!(r.kind(), Kind::Static(s) if !s.contacts.is_empty());
let (stress, velocity, temperature, flux) = match r.kind() {
Kind::Static(s) => (true, false, s.temperature.is_some(), false),
Kind::Frequency(_) => (false, false, false, false),
Kind::Explicit(e) => {
e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false))
}
// Cell fields have their own display (M12 streamlines); no nodal field.
Kind::Fluid(_) => return Vec::new().into_iter(),
Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(),
};
Field::ALL
.into_iter()
.filter(move |f| match f {
Field::Velocity => velocity,
Field::Temperature => temperature,
Field::HeatFlux => flux,
f if f.is_contact() => contact,
f => stress || !f.is_stress(),
})
.collect::<Vec<_>>()
.into_iter()
}
}
/// What a number the results show measures: its unit follows the model's unit system
/// ([`Quantity::unit`]). The legend, the probe, the reaction sums and the solve summary all name
/// their units through it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Quantity {
/// No unit (a ratio, a code, a normalised value).
None,
Length,
Stress,
Force,
Moment,
Mass,
Time,
Velocity,
Temperature,
/// Energy (work): force × length.
Energy,
/// Heat flow (power): energy per time.
HeatFlow,
/// Heat flux: heat flow per area.
HeatFlux,
}
impl Quantity {
/// Its unit in `system` (`mm`, `MPa`, `N·mm`, `mW`…), or `None` (no unit, or the system is
/// unspecified). The model never converts, so this only names the system's own unit; a
/// temperature is in whatever scale its values were entered in.
pub fn unit(self, system: UnitSystem) -> Option<String> {
let dim = match self {
Quantity::None => return None,
Quantity::Length => Dim::Length,
Quantity::Stress => Dim::Stress,
Quantity::Force => Dim::Force,
Quantity::Moment => Dim::Moment,
Quantity::Mass => Dim::Mass,
Quantity::Time => Dim::Time,
Quantity::Velocity => Dim::Velocity,
Quantity::Temperature | Quantity::Energy | Quantity::HeatFlow | Quantity::HeatFlux => {
// Named units where the system's force × length (per time) has one.
let (energy, power, flux) = match system {
UnitSystem::Unspecified => return None,
UnitSystem::MKgS => ("J", "W", "W/m²"),
UnitSystem::MmTonneS => ("mJ", "mW", "mW/mm²"),
UnitSystem::MmKgMs => ("J", "kW", "kW/mm²"),
UnitSystem::MmGMs => ("mJ", "W", "W/mm²"),
UnitSystem::InLbfS => ("lbf·in", "lbf·in/s", "lbf/(in·s)"),
};
return Some(
match self {
Quantity::Energy => energy,
Quantity::HeatFlow => power,
Quantity::HeatFlux => flux,
_ => "°C or K",
}
.into(),
);
}
};
dim.unit(system)
}
}
/// `label` with `unit` in brackets when there is one: `Von Mises stress [MPa]`.
pub fn with_unit(label: &str, unit: Option<&str>) -> String {
match unit {
Some(u) => format!("{label} [{u}]"),
None => label.to_string(),
}
}
/// Most contour bands.
pub const MAX_BANDS: u32 = 24;
/// Fewest contour bands.
pub const MIN_BANDS: u32 = 2;
/// Contour bands when none are asked for.
pub const DEFAULT_BANDS: u32 = 12;
/// What the results display shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResultsView {
/// The solved step shown.
pub step: String,
/// Draw the deformed shape.
pub deformed: bool,
/// Deformation scale factor; `None` scales the largest displacement to a tenth of the
/// model's size.
pub scale: Option<f64>,
/// Contours of this field; `None` draws the deformed shape in element colours.
pub contour: Option<Field>,
pub bands: u32,
/// The legend's range, clamped below the field's smallest or above its largest value (each
/// `None` follows the field): a singular peak no longer squeezes the field into one band.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_max: Option<f64>,
/// The mode shown, from 1 (frequency steps).
pub mode: usize,
/// The field frame shown (explicit steps); `None` is the last.
#[serde(default)]
pub frame: Option<usize>,
/// Animate the deformation (a mode swings through ±, a static step grows from 0).
pub animate: bool,
}
impl ResultsView {
/// How a step's result is shown first: a linear static step undeformed with von Mises
/// contours, so they are read on the true geometry (`results.deformed` shows the deformed
/// shape, at the automatic scale); a frequency step's first mode deformed with displacement
/// contours (a mode shape is a deformation); an explicit step at its last frame and a
/// nonlinear static step at its last increment, deformed to true scale ([`true_scale`];
/// with von Mises contours when they kept stresses); a heat step undeformed.
pub fn for_result(r: &StepResult) -> ResultsView {
let heat = matches!(r.kind(), Kind::Heat(_));
let contour = if heat {
Field::Temperature
} else if Field::available(r).any(|f| f == Field::VonMises) {
Field::VonMises
} else {
Field::Displacement
};
let true_scale = true_scale(r);
ResultsView {
step: r.step().to_string(),
deformed: true_scale || matches!(r.kind(), Kind::Frequency(_)),
scale: true_scale.then_some(1.0),
contour: Some(contour),
bands: DEFAULT_BANDS,
range_min: None,
range_max: None,
mode: 1,
frame: None,
animate: false,
}
}
pub fn pick(&self) -> Pick {
Pick { mode: self.mode, frame: self.frame }
}
/// The contour bands for a field ranging from `min` to `max`, clamped to the view's range.
pub fn bands(&self, min: f64, max: f64) -> fieldcraft_render::contour::Bands {
fieldcraft_render::contour::Bands::clamped(min, max, self.range_min, self.range_max, self.bands)
}
}
/// The automatic deformation scale draws the largest displacement this fraction of the model's
/// size.
pub const AUTO_FRACTION: f64 = 0.1;
/// Whether a result's deformation is drawn at true scale even when the scale is automatic: an
/// explicit step's and a nonlinear static step's displacements are already large (finite strain,
/// contact), and magnifying them draws the part crushed through its neighbours. Linear static,
/// frequency and buckling results are magnified.
pub fn true_scale(r: &StepResult) -> bool {
match r.kind() {
Kind::Explicit(_) => true,
Kind::Static(s) => s.nonlinear.is_some(),
Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => false,
}
}
/// The automatic deformation scale of `r` at `pick` on a model `size` across (its bounding box
/// diagonal): 1 for a [`true_scale`] result, else the factor that draws the largest
/// displacement [`AUTO_FRACTION`] of the size; 0 when nothing moves.
pub fn auto_scale(r: &StepResult, pick: Pick, size: f64) -> f64 {
if true_scale(r) {
return 1.0;
}
let largest = displacements(r, pick).map_or(0.0, |u| u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max));
if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 }
}
/// The nodal displacements a result's deformed shape uses: the static displacement, the shape
/// of the picked mode or the picked frame's displacement, in [`StepResult::nodes`] order.
pub fn displacements(r: &StepResult, pick: Pick) -> Result<&[[f64; 3]]> {
match r.kind() {
Kind::Static(s) => match (pick.frame, &s.nonlinear) {
(Some(k), Some(h)) => h.frames.get(k).map(|f| f.displacement.as_slice()).ok_or_else(|| {
EngineError::Params(format!("step `{}` has frames 0 to {}; there is no frame {k}", s.step, h.frames.len().saturating_sub(1)))
}),
_ => Ok(&s.displacement),
},
Kind::Frequency(f) => pick
.mode
.checked_sub(1)
.and_then(|k| f.modes.get(k))
.map(|m| m.shape.as_slice())
.ok_or_else(|| EngineError::Params(format!("step `{}` has modes 1 to {}; there is no mode {}", f.step, f.modes.len(), pick.mode))),
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.displacement.is_empty() {
return Err(EngineError::Params(format!("step `{}` kept no displacements in its field frames", e.step)));
}
Ok(&f.displacement)
}
Kind::Fluid(f) => Err(EngineError::Params(format!("step `{}` is a fluid step: its fields are per cell (solve.probe)", f.step))),
Kind::Heat(h) => Err(EngineError::Params(format!("step `{}` is heat transfer and has no displacements", h.step))),
}
}
/// A field's value at every node of the result, in [`StepResult::nodes`] order. `model` gives
/// the element connectivity an explicit frame's element stresses are averaged over.
pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) -> Result<Vec<f64>> {
let unavailable = || EngineError::Params(format!("{} is not available for step `{}`", field.label(), r.step()));
let component = |f: fn(&[f64; 3]) -> f64| -> Result<Vec<f64>> { Ok(displacements(r, pick)?.iter().map(f).collect()) };
let stresses = || -> Result<std::borrow::Cow<'_, [[f64; 6]]>> {
match r.kind() {
Kind::Static(s) => Ok(std::borrow::Cow::Borrowed(s.stress.as_slice())),
Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.stress.is_empty() {
return Err(unavailable());
}
Ok(std::borrow::Cow::Owned(nodal_average(model, &e.nodes, &e.elements, &f.stress)))
}
}
};
let each = |f: &dyn Fn(&[f64; 6]) -> f64| -> Result<Vec<f64>> { Ok(stresses()?.iter().map(f).collect()) };
let principal = |k: usize| each(&|t| fieldcraft_fem::principal(t).get(k).copied().unwrap_or(f64::NAN));
match field {
Field::Displacement => component(|v| magnitude(*v)),
Field::DisplacementX => component(|v| v[0]),
Field::DisplacementY => component(|v| v[1]),
Field::DisplacementZ => component(|v| v[2]),
Field::Velocity => match r.kind() {
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.velocity.is_empty() { Err(unavailable()) } else { Ok(f.velocity.iter().map(|v| magnitude(*v)).collect()) }
}
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
},
Field::Temperature => match r.kind() {
Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()),
Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()),
_ => Err(unavailable()),
},
Field::HeatFlux => match r.kind() {
Kind::Heat(h) => Ok(h.flux.iter().map(|v| magnitude(*v)).collect()),
_ => Err(unavailable()),
},
Field::ContactPressure | Field::ContactGap | Field::ContactStatus | Field::ContactShear | Field::ContactSlip => match r.kind() {
Kind::Static(s) if !s.contacts.is_empty() => Ok(contact_values(s, field)),
_ => Err(unavailable()),
},
Field::VonMises => each(&fieldcraft_fem::von_mises),
Field::MaxPrincipal => principal(0),
Field::MidPrincipal => principal(1),
Field::MinPrincipal => principal(2),
Field::StressXx => each(&|t| t[0]),
Field::StressYy => each(&|t| t[1]),
Field::StressZz => each(&|t| t[2]),
Field::StressXy => each(&|t| t[3]),
Field::StressXz => each(&|t| t[4]),
Field::StressYz => each(&|t| t[5]),
}
}
/// A contact field at every node of a static result: the largest pressure, shear, slip or status
/// code, or the smallest gap, over the contacts a node is a secondary node of; off them 0
/// pressure and shear, and no gap, status or slip (NaN).
fn contact_values(s: &StaticResult, field: Field) -> Vec<f64> {
use fieldcraft_solver::contact::Status;
let zero = matches!(field, Field::ContactPressure | Field::ContactShear);
let mut out = vec![if zero { 0.0 } else { f64::NAN }; s.nodes.len()];
let larger = |v: &mut f64, x: f64| *v = if v.is_nan() { x } else { v.max(x) };
for c in &s.contacts {
for (k, label) in c.nodes.iter().enumerate() {
let Some(v) = s.nodes.binary_search(label).ok().and_then(|i| out.get_mut(i)) else { continue };
match field {
Field::ContactPressure => larger(v, c.pressure.get(k).copied().unwrap_or(0.0)),
Field::ContactShear => larger(v, c.shear.get(k).map_or(0.0, |t| magnitude(*t))),
Field::ContactSlip => {
if let Some(g) = c.slip.get(k) {
larger(v, magnitude(*g));
}
}
Field::ContactGap => {
if let Some(Some(g)) = c.gap.get(k) {
*v = if v.is_nan() { *g } else { v.min(*g) };
}
}
Field::ContactStatus => {
let code = match c.status.get(k) {
Some(Status::Closed | Status::Stick) => 1.0,
Some(Status::Slip) => 2.0,
Some(Status::Open) | None => 0.0,
};
larger(v, code);
}
_ => {}
}
}
}
out
}
/// Element values (in `elements` order) averaged at the nodes (in `nodes` order) of the
/// elements around them; a node no element reaches gets NaN.
fn nodal_average(model: &Model, nodes: &[u32], elements: &[u32], values: &[[f64; 6]]) -> Vec<[f64; 6]> {
let mut sum = vec![[0.0; 6]; nodes.len()];
let mut count = vec![0u32; nodes.len()];
for (label, v) in elements.iter().zip(values) {
let Some(e) = model.element(*label) else { continue };
for n in &e.nodes {
let Ok(i) = nodes.binary_search(n) else { continue };
if let (Some(s), Some(c)) = (sum.get_mut(i), count.get_mut(i)) {
for (a, b) in s.iter_mut().zip(v) {
*a += b;
}
*c += 1;
}
}
}
sum.into_iter().zip(count).map(|(s, c)| if c == 0 { [f64::NAN; 6] } else { s.map(|x| x / f64::from(c)) }).collect()
}
/// The smallest and largest finite value, with the nodes they are at (`labels` in the same
/// order as `values`).
pub fn range(labels: &[u32], values: &[f64]) -> Option<((u32, f64), (u32, f64))> {
let mut out: Option<((u32, f64), (u32, f64))> = None;
for (&l, &v) in labels.iter().zip(values).filter(|(_, v)| v.is_finite()) {
out = Some(match out {
None => ((l, v), (l, v)),
Some((lo, hi)) => (if v < lo.1 { (l, v) } else { lo }, if v > hi.1 { (l, v) } else { hi }),
});
}
out
}
/// A fingerprint of what solving step `step` of `model` reads ([`Model::step_inputs`]), or
/// `None` when there is no such step.
pub(crate) fn inputs_fingerprint(model: &Model, step: &str) -> Option<u64> {
use std::hash::Hasher;
/// Hashes what is written to it.
struct HashWriter(std::hash::DefaultHasher);
impl std::io::Write for HashWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.write(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let inputs = model.step_inputs(step)?;
let mut h = HashWriter(std::hash::DefaultHasher::new());
serde_json::to_writer(&mut h, &inputs).ok()?;
Some(h.0.finish())
}
/// The principal values of a stress (xx, yy, zz, xy, xz, yz), largest first.
pub fn principal(t: &[f64; 6]) -> [f64; 3] {
fieldcraft_fem::principal(t)
}
pub fn magnitude(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
impl Session {
/// The latest result of a step and whether it describes the current state (`false`: the
/// model changed since it was solved; it is stale).
pub fn latest_result(&self, step: &str) -> Option<(&std::sync::Arc<StepResult>, bool)> {
self.results.get(step).map(|(revision, r)| (r, *revision == self.revision))
}
/// What the results display shows: the view, whether its result is stale, the contour
/// field's range, a frequency step's modes and an explicit step's frame times (the
/// `results.*` commands' reply).
pub fn results_report(&self) -> Value {
let Some(v) = &self.results_view else { return json!({"view": null}) };
let Some((r, current)) = self.latest_result(&v.step) else { return json!({"view": v}) };
let mut out = json!({"view": v, "stale": !current});
if let Some(f) = v.contour
&& let Ok(values) = field_values(&self.model, r, f, v.pick())
{
let ends =
range(r.nodes(), &values).map(|((ln, lv), (hn, hv))| json!({"min": {"node": ln, "value": lv}, "max": {"node": hn, "value": hv}}));
out["contour"] = json!({"field": f, "label": f.label(), "unit": f.unit(r, self.model.units()), "range": ends});
if v.range_min.is_some() || v.range_max.is_some() {
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
}
}
match r.kind() {
Kind::Frequency(fr) => {
out["modes"] = fr.modes.iter().enumerate().map(|(i, m)| json!({"mode": i + 1, "frequency": m.frequency})).collect();
}
Kind::Explicit(e) => {
out["frames"] = e.frames.iter().map(|f| f.time).collect();
if let Ok(f) = frame(e, v.pick()) {
out["time"] = json!(f.time);
}
}
Kind::Fluid(f) => {
out["frames"] = f.frames.iter().map(|f| f.time).collect();
}
Kind::Heat(h) => {
out["heat_flows"] = heat_flows(h);
out["frames"] = json!(h.times);
if let Ok((t, _)) = heat_frame(h, v.pick()) {
out["time"] = json!(t);
}
}
Kind::Static(s) => {
if let Some(h) = &s.nonlinear {
out["frames"] = json!(h.load_factors());
let shown = v.frame.and_then(|k| h.frames.get(k)).or(h.frames.last());
out["load_factor"] = json!(shown.map(|f| f.load_factor));
}
}
}
out
}
/// Every step with a result, current or stale, by name.
pub fn result_steps(&self) -> impl Iterator<Item = (&str, bool)> {
self.results.iter().map(|(name, (revision, _))| (name.as_str(), *revision == self.revision))
}
}
/// The heat balance of a heat transfer result: each heat load's flow into the body (`loads`:
/// `{name, type, heat}`, negative out of it), the prescribed temperatures' total (`reactions`),
/// and `imbalance`, their sum: about 0 in a steady state, the heat stored per time in a
/// transient one.
pub fn heat_flows(h: &HeatResult) -> Value {
let loads: Vec<Value> = h.loads.iter().map(|l| json!({"name": l.name, "type": l.kind, "heat": l.heat})).collect();
let applied: f64 = h.loads.iter().map(|l| l.heat).sum();
json!({"loads": loads, "reactions": h.reaction_total, "imbalance": applied + h.reaction_total})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::solve::tests::cantilever;
use serde_json::json;
#[test]
fn fields_follow_the_result() {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
let (bend, current) = s.latest_result("Bend").unwrap();
assert!(current);
let mag = field_values(&s.model, bend, Field::Displacement, Pick { mode: 1, frame: None }).unwrap();
let uz = field_values(&s.model, bend, Field::DisplacementZ, Pick { mode: 1, frame: None }).unwrap();
assert_eq!(mag.len(), bend.nodes().len());
for (m, z) in mag.iter().zip(&uz) {
assert!(*m >= z.abs() - 1e-12);
}
// The tip bends down the most; the clamped root does not move.
let ((lo_node, lo), (_, hi)) = range(bend.nodes(), &uz).unwrap();
assert!(lo < -3.8 && hi.abs() < 1e-12, "{lo} {hi}");
assert!([11, 22, 33, 44].contains(&lo_node), "{lo_node}");
let vm = field_values(&s.model, bend, Field::VonMises, Pick { mode: 1, frame: None }).unwrap();
let max = field_values(&s.model, bend, Field::MaxPrincipal, Pick { mode: 1, frame: None }).unwrap();
let min = field_values(&s.model, bend, Field::MinPrincipal, Pick { mode: 1, frame: None }).unwrap();
for ((v, a), b) in vm.iter().zip(&max).zip(&min) {
assert!(a >= b && *v >= 0.0, "{v} {a} {b}");
}
let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::available(modes).count(), 4);
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 8, "all but velocity, the thermal and the contact fields");
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
// An edit leaves the results stale, not gone.
s.execute("model.material.edit", &json!({"name": "Mat", "elastic": {"type": "isotropic", "young": 2000, "poisson": 0}, "density": 1}))
.unwrap();
assert!(s.result("Bend").is_none());
assert_eq!(s.latest_result("Bend").map(|(_, current)| current), Some(false));
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", false)]);
}
/// A step's result goes stale only when something it reads changes (#318); undoing the
/// change makes it current again.
#[test]
fn unrelated_edits_keep_results_current() {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
// A set, a boundary condition and a step that neither solved step uses.
s.execute("model.set.create", &json!({"name": "extra", "nodes": [1]})).unwrap();
s.execute("model.boundary_condition.create", &json!({"name": "X", "type": "fixed", "target": {"set": "extra"}})).unwrap();
s.execute("model.step.create", &json!({"name": "Other", "procedure": {"type": "static"}, "boundary_conditions": ["X"]})).unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", true)]);
// The load only Bend lists: Bend goes stale, Modes (no loads) stays current.
s.execute("model.load.edit", &json!({"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -2]})).unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", true)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", true)]);
// The clamp's set, which both use.
s.execute("model.set.edit", &json!({"name": "root", "nodes": [1, 12]})).unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", false)]);
s.execute("edit.undo", &json!({})).unwrap();
// A step's own definition.
s.execute("model.step.edit", &json!({"name": "Modes", "procedure": {"type": "frequency", "modes": 3}, "boundary_conditions": ["clamp"]}))
.unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", false)]);
}
#[test]
fn units_follow_the_unit_system() {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
let mm = UnitSystem::MmTonneS;
let (bend, _) = s.latest_result("Bend").unwrap();
let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::VonMises.title(bend, mm), "Von Mises stress [MPa]");
assert_eq!(Field::Displacement.title(bend, mm), "Displacement magnitude [mm]");
assert_eq!(Field::Displacement.title(modes, mm), "Displacement (mode shape, normalised)");
assert_eq!(Field::DisplacementZ.title(modes, mm), "Displacement Z (mode shape, normalised)");
assert_eq!(Field::VonMises.title(bend, UnitSystem::Unspecified), "Von Mises stress");
assert_eq!(Field::ContactStatus.unit(bend, mm), None);
let unit = |q: Quantity, u: UnitSystem| q.unit(u);
assert_eq!(unit(Quantity::HeatFlow, mm).as_deref(), Some("mW"));
assert_eq!(unit(Quantity::Energy, mm).as_deref(), Some("mJ"));
assert_eq!(unit(Quantity::Moment, mm).as_deref(), Some("N·mm"));
assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J"));
assert_eq!(unit(Quantity::Temperature, UnitSystem::Unspecified), None);
assert_eq!(unit(Quantity::None, mm), None);
}
#[test]
fn field_ids_round_trip() {
for f in Field::ALL {
assert_eq!(Field::parse(f.id()), Some(f));
assert_eq!(serde_json::to_value(f).unwrap(), json!(f.id()));
}
assert_eq!(Field::parse("nope"), None);
assert_eq!(range(&[1, 2], &[f64::NAN, f64::INFINITY]), None);
assert_eq!(range(&[1, 2, 3], &[2.0, -1.0, 5.0]), Some(((2, -1.0), (3, 5.0))));
}
}