forked from 1b-agent/fieldcraft-upstream
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
824 lines
36 KiB
Rust
824 lines
36 KiB
Rust
//! 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))));
|
||
}
|
||
}
|