Explicit dynamics core: central differences, one-point hex8 with hourglass control, nodal-pressure tet4, solve.explicit (M10 X1) (#27)

* Model: explicit dynamic steps (end time, increment scale and cap, output intervals, initial velocities, rigid walls, hourglass and bulk viscosity controls); deck writers refuse them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* fem: uniform-strain hex8 and tet4 kinematics, Flanagan–Belytschko hourglass shape vectors, element frequency bound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Explicit solver crate (L2): central differences with lumped mass, one-point hex8 with Flanagan–Belytschko hourglass control, Bonet–Burton tet4, bulk viscosity, rigid walls, energy account; wave and impact verification

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Engine: solve.explicit and solve.history commands, explicit results in the session and solve.probe; cantilever period against the modal solver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Docs: explicit solver in the architecture, roadmap (M10 started), parity and literature (Part H)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Simplify: Model::select_step shared by both solvers, Procedure::sets/is_dynamic, fused per-increment passes without allocation, shared element tail, generic command caps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Catalogue: solve.explicit is ready; solve.history beside solve.probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Rebase onto the implicit couplings work: Procedure is no longer Copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 18:28:32 -04:00
1 parent 48039639cb
commit d45a2da5b5
33 files changed
+2757 -65

No files matched your search

+15 -2
View File
@@ -292,7 +292,13 @@ pub static CATALOGUE: &[Capability] = &[
soon("deck.read", "Read Deck", Solve, "Decks", "M9", "Read a deck, keeping keywords ModelCraft does not know"),
ready("solve.static", "Static", Solve, "Implicit", "Linear static solve of a step with ModelCraft's own solver"),
ready("solve.frequency", "Frequency", Solve, "Implicit", "Natural frequencies and mode shapes of a frequency step"),
soon("solve.explicit", "Explicit", Solve, "Explicit", "M10", "Explicit dynamics: central differences, contact, erosion"),
ready(
"solve.explicit",
"Explicit",
Solve,
"Explicit",
"Transient dynamics of an explicit step by central differences (contact and erosion coming in M10)",
),
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
@@ -301,7 +307,14 @@ pub static CATALOGUE: &[Capability] = &[
soon("results.contour", "Contours", Results, "Display", "M4", "Contour plots of stress, strain and displacement"),
soon("results.modes", "Mode Shapes", Results, "Display", "M4", "Mode shapes of a frequency step"),
soon("results.animate", "Animate", Results, "Display", "M9", "Animate modes and time histories"),
ready("solve.probe", "Probe", Results, "Query", "Displacement, reaction and stress at nodes, or a mode shape"),
ready_more(
"solve.probe",
"Probe",
Results,
"Query",
"Displacement, reaction and stress at nodes, a mode shape, or an explicit field frame",
&["solve.history"],
),
soon("results.reactions", "Reactions", Results, "Query", "M4", "Reaction force and moment sums"),
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
+1 -1
View File
@@ -159,7 +159,7 @@ definition_commands!(
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
kind: CommandKind::Model,
run: step_create,
};
+2
View File
@@ -71,6 +71,8 @@ pub static ALL: &[CommandSpec] = &[
deck::WRITE,
solve::STATIC,
solve::FREQUENCY,
solve::EXPLICIT,
solve::HISTORY,
solve::PROBE,
];
+339 -14
View File
@@ -1,11 +1,12 @@
//! `solve.*`: run the model's steps with the implicit solver, keep the results in the session,
//! and read them back.
//! `solve.*`: run the model's steps with the implicit or the explicit solver, keep the results
//! in the session, and read them back.
use modelcraft_solver::{FrequencyOptions, Limits, Location, MassKind, StepResult, solve_frequency, solve_static};
use modelcraft_explicit::{ExplicitResult, solve_explicit};
use modelcraft_solver::{FrequencyOptions, Limits, Location, MassKind, StepResult as ImplicitResult, solve_frequency, solve_static};
use serde_json::{Value, json};
use super::{labels, opt};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
use super::{labels, opt, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
pub const STATIC: CommandSpec = CommandSpec {
id: "solve.static",
@@ -23,10 +24,26 @@ pub const FREQUENCY: CommandSpec = CommandSpec {
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} — 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)"#,
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 PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1} — the stored result at nodes: displacement, reaction, stress and von Mises (static), or the shape of mode `mode` (frequency, from 1); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments)"#,
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default); a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments)"#,
kind: CommandKind::Model,
run: probe,
};
@@ -34,14 +51,18 @@ pub const PROBE: CommandSpec = CommandSpec {
/// Most nodes one probe lists.
const MAX_PROBE: usize = 10_000;
/// 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(p: &Value, key: &str, default: usize) -> Result<usize> {
fn cap<T: Copy + TryInto<u64> + TryFrom<u64> + std::fmt::Display>(p: &Value, key: &str, default: T) -> Result<T> {
let most: u64 = default.try_into().unwrap_or(u64::MAX);
match p.get(key) {
None => Ok(default),
Some(v) => v
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| (1..=default).contains(n))
.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}"))),
}
}
@@ -83,7 +104,7 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
})).collect::<Vec<_>>(),
"warnings": r.warnings,
});
s.store_result(StepResult::Static(r));
s.store_result(StepResult::Implicit(ImplicitResult::Static(r)));
Ok(summary)
}
@@ -107,21 +128,99 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
})).collect::<Vec<_>>(),
"warnings": r.warnings,
});
s.store_result(StepResult::Frequency(r));
s.store_result(StepResult::Implicit(ImplicitResult::Frequency(r)));
Ok(summary)
}
fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
use modelcraft_explicit::Limits as ExplicitLimits;
let limits = ExplicitLimits {
max_dofs: cap(p, "max_dofs", ExplicitLimits::MAX_DOFS)?,
max_increments: cap(p, "max_increments", ExplicitLimits::MAX_INCREMENTS)?,
max_output_values: cap(p, "max_output_values", ExplicitLimits::MAX_OUTPUT_VALUES)?,
};
let step = opt::<String>(p, "step")?;
let r = solve_explicit(&s.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(),
"warnings": r.warnings,
})
}
fn history(s: &mut Session, p: &Value) -> Result<Value> {
let step: String = req(p, "step")?;
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
let StepResult::Explicit(r) = result.as_ref() else {
return Err(EngineError::Params(format!("step `{step}` is not an explicit dynamic step; it has no histories")));
};
let h = &r.history;
let samples = h.time.len();
let every = match opt::<u64>(p, "every")? {
Some(0) => return Err(EngineError::Params("`every` must be at least 1".into())),
Some(k) => usize::try_from(k).unwrap_or(usize::MAX),
None => samples.div_ceil(MAX_HISTORY_SAMPLES).max(1),
};
if samples.div_ceil(every) > MAX_HISTORY_SAMPLES {
return Err(EngineError::Params(format!("{samples} samples in steps of {every} are more than {MAX_HISTORY_SAMPLES}; raise `every`")));
}
let pick = |v: &dyn Fn(usize) -> Value| -> Vec<Value> { (0..samples).step_by(every).map(v).collect() };
let nodes: Vec<Value> = h
.nodes
.iter()
.enumerate()
.map(|(k, label)| {
json!({
"node": label,
"displacement": pick(&|i| json!(h.displacement.get(i).and_then(|d| d.get(k)))),
"velocity": pick(&|i| json!(h.velocity.get(i).and_then(|d| d.get(k)))),
})
})
.collect();
Ok(json!({
"step": step,
"every": every,
"time": pick(&|i| json!(h.time.get(i))),
"increment": pick(&|i| json!(h.increment.get(i))),
"energies": pick(&|i| json!(h.energies.get(i))),
"momentum": pick(&|i| json!(h.momentum.get(i))),
"constraint_force": pick(&|i| json!(h.constraint_force.get(i))),
"nodes": nodes,
}))
}
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
let nodes = labels(p, "nodes", MAX_PROBE)?;
let result = match result.as_ref() {
StepResult::Explicit(r) => return probe_frame(r, &step, &nodes, p),
StepResult::Implicit(r) => r,
};
let locate = |label: u32| {
result
.locate(label)
.ok_or_else(|| EngineError::Params(format!("node {label} has no result (it belongs to no solved element or coupled reference point)")))
};
let out: Vec<Value> = match result.as_ref() {
StepResult::Static(r) => nodes
let out: Vec<Value> = match result {
ImplicitResult::Static(r) => nodes
.iter()
.map(|&l| {
Ok(match locate(l)? {
@@ -139,7 +238,7 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect::<Result<_>>()?,
StepResult::Frequency(r) => {
ImplicitResult::Frequency(r) => {
let mode = opt::<usize>(p, "mode")?.unwrap_or(1);
let m = mode
.checked_sub(1)
@@ -161,6 +260,25 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"step": step, "nodes": out}))
}
/// Displacement and velocity at nodes in one field frame of an explicit result.
fn probe_frame(r: &ExplicitResult, step: &str, nodes: &[u32], p: &Value) -> Result<Value> {
let last = r.frames.len().saturating_sub(1);
let k = opt::<usize>(p, "frame")?.unwrap_or(last);
let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("`frame` must be from 0 to {last}")))?;
if f.displacement.is_empty() {
return Err(EngineError::Params(format!("step `{step}` does not output displacements")));
}
let out: Vec<Value> = nodes
.iter()
.map(|&l| {
let i =
r.nodes.binary_search(&l).map_err(|_| EngineError::Params(format!("node {l} has no result (it belongs to no solved element)")))?;
Ok(json!({"node": l, "displacement": f.displacement.get(i), "velocity": f.velocity.get(i)}))
})
.collect::<Result<_>>()?;
Ok(json!({"step": step, "frame": k, "time": f.time, "nodes": out}))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -306,4 +424,211 @@ pub(crate) mod tests {
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 modelcraft_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();
}
/// Upward zero crossings of a sampled signal, linearly interpolated.
fn crossings(time: &[f64], x: &[f64]) -> Vec<f64> {
let mut out = Vec::new();
for k in 1..x.len() {
let (a, b) = (x[k - 1], x[k]);
if a < 0.0 && b >= 0.0 {
out.push(time[k - 1] + (time[k] - time[k - 1]) * (-a) / (b - a));
}
}
out
}
/// Free vibration of a cantilever after an initial velocity in the shape of its static
/// deflection: the period of the explicit run (one-point hex8) against the first natural
/// frequency of the implicit modal solver (incompatible-mode hex8, lumped mass) on the same
/// mesh. The one-point element's mean strain misses the linear variation of the bending
/// strain through each element: with n elements through the depth it keeps 1 − 1/n² of the
/// bending energy, so without hourglass stiffness (viscous control) the period is longer by
/// (1 − 1/n²)^{−½} (3.3% for n = 4); the stiffness form restores part of it.
#[test]
fn explicit_cantilever_period_matches_the_modal_frequency() {
let (length, n) = (10.0, [40usize, 4usize]);
let mut s = Session::default();
explicit_cantilever(&mut s, length, n);
let modes = s.execute("solve.frequency", &json!({"mass": "lumped"})).unwrap();
let period = 1.0 / modes["modes"][0]["frequency"].as_f64().unwrap();
// Euler–Bernoulli: f₁ = 1.875² / (2π L²) √(E I / (ρ A)), I/A = 1/12.
let euler = 2.0 * std::f64::consts::PI * length * length / 1.875104f64.powi(2) / (1000.0f64 / 12.0).sqrt();
assert!((period / euler - 1.0).abs() < 0.01, "{period} vs {euler}");
let section = json!({"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}});
s.execute("model.section.edit", &section).unwrap();
// v(x) ∝ x²(3L − x): the static deflection of a tip load, close to the first mode.
let velocities: Vec<Value> = (0..=n[0])
.map(|i| {
let x = length * i as f64 / n[0] as f64;
json!({"set": format!("x{i}"), "velocity": [0, 0, 0.01 * x * x * (3.0 * length - x) / (2.0 * length.powi(3))]})
})
.collect();
let one_point = period / (1.0 - 1.0 / (n[1] * n[1]) as f64).sqrt();
let mut measured = Vec::new();
for hourglass in ["viscous", "stiffness"] {
let step = json!({
"type": "explicit_dynamic", "end_time": 4.2 * period, "history_interval": period / 400.0, "output_interval": period,
"history_nodes": "tip", "initial_velocities": velocities, "hourglass": {"type": hourglass}
});
let name = format!("Swing-{hourglass}");
s.execute("model.step.create", &json!({"name": name, "procedure": step, "boundary_conditions": ["clamp"]})).unwrap();
let r = s.execute("solve.explicit", &json!({"step": name})).unwrap();
let h = s.execute("solve.history", &json!({"step": name})).unwrap();
let time: Vec<f64> = h["time"].as_array().unwrap().iter().map(|t| t.as_f64().unwrap()).collect();
let uz: Vec<f64> = h["nodes"][0]["displacement"].as_array().unwrap().iter().map(|d| d[2].as_f64().unwrap()).collect();
// The tip starts at zero moving up: it crosses zero upwards once per period after that.
let ups = crossings(&time, &uz);
let t = (ups[ups.len() - 1] - ups[0]) / (ups.len() - 1) as f64;
eprintln!(
"cantilever {n:?}, {hourglass} hourglass: explicit period {t:.5} vs modal {period:.5} ({:+.3}%), one-point estimate {one_point:.5} ({:+.3}%), Euler–Bernoulli {euler:.5}; energy balance {:.1e}, {} increments",
100.0 * (t / period - 1.0),
100.0 * (t / one_point - 1.0),
r["energy_balance"].as_f64().unwrap(),
r["increments"]
);
assert!(ups.len() >= 4, "{ups:?}");
assert!(r["energy_balance"].as_f64().unwrap() < 0.01, "{r}");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| w.as_str().unwrap().contains("unit system")), "{r}");
measured.push(t);
}
// Viscous control adds no stiffness: the one-point estimate holds closely.
assert!((measured[0] / one_point - 1.0).abs() < 0.005, "{measured:?} vs {one_point}");
// The stiffness form lies between it and the modal period, and within 3% of the latter.
assert!(measured[1] > period && measured[1] < measured[0], "{measured:?}");
assert!((measured[1] / period - 1.0).abs() < 0.03, "{measured:?} vs {period}");
}
/// A small impact model through commands: a hex8 bar moving onto a rigid wall.
fn explicit_impact(s: &mut Session) {
explicit_cantilever(s, 4.0, [8, 1]);
let step = json!({
"type": "explicit_dynamic", "end_time": 0.2, "history_interval": 0.002, "output_interval": 0.05, "history_nodes": "tip",
"initial_velocities": [{"set": "all_nodes", "velocity": [-0.1, 0, 0]}],
"rigid_walls": [{"set": "x0", "point": [0, 0, 0], "normal": [1, 0, 0]}]
});
let nodes: Vec<u32> = s.model.nodes().keys().copied().collect();
let script = json!([
{"command": "model.set.create", "params": {"name": "all_nodes", "nodes": nodes}},
{"command": "model.section.edit", "params": {"name": "beam", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {"integration": "reduced"}}},
{"command": "model.step.create", "params": {"name": "Impact", "procedure": step}},
]);
s.run_script(&script).unwrap();
}
#[test]
fn explicit_results_are_kept_probed_and_guarded() {
let mut s = Session::default();
explicit_impact(&mut s);
let journal = s.journal.len();
let r = s.execute("solve.explicit", &json!({"step": "Impact"})).unwrap();
assert_eq!((r["completed"].as_bool(), r["frames"].as_u64()), (Some(true), Some(5)), "{r}");
// Eight elements, so eight controlling ones; on this coarse mesh the impact excites modes
// near the stability limit, whose energy the integer-step account measures only roughly.
assert_eq!(r["controlling_elements"].as_array().unwrap().len(), 8, "{r}");
assert!(r["energy_balance"].as_f64().unwrap() < 0.1, "{r}");
assert_eq!(s.journal.len(), journal, "solving does not change the model");
let probe = s.execute("solve.probe", &json!({"step": "Impact", "nodes": [1, 9], "frame": 0})).unwrap();
assert_eq!(probe["nodes"][1]["velocity"][0].as_f64(), Some(-0.1), "{probe}");
let last = s.execute("solve.probe", &json!({"step": "Impact", "nodes": [9]})).unwrap();
assert_eq!(last["frame"], 4, "{last}");
let h = s.execute("solve.history", &json!({"step": "Impact", "every": 2})).unwrap();
assert_eq!(h["time"].as_array().unwrap().len(), h["energies"].as_array().unwrap().len());
assert!(h["nodes"][0]["velocity"].as_array().unwrap().len() > 5, "{h}");
assert_eq!(s.solved_steps().collect::<Vec<_>>(), ["Impact"]);
assert!(s.execute("solve.history", &json!({"step": "Modes"})).is_err(), "not solved");
let hostile = [
json!(null),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Modes"}),
json!({"step": "Impact", "max_dofs": 0}),
json!({"step": "Impact", "max_dofs": 5}),
json!({"step": "Impact", "max_increments": 0}),
json!({"step": "Impact", "max_increments": 3}),
json!({"step": "Impact", "max_increments": -1}),
json!({"step": "Impact", "max_increments": 1e300}),
json!({"step": "Impact", "max_output_values": 1}),
json!({"step": "Impact", "every": 0}),
json!({"step": "Impact", "every": -3}),
json!({"step": "Impact", "every": 1e300}),
json!({"step": "Impact", "every": u64::MAX}),
json!({"step": "Impact", "nodes": [1], "frame": 99}),
json!({"step": "Impact", "nodes": [1], "frame": -1}),
json!({"step": "Impact", "nodes": [0, 4294967296u64]}),
];
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);
}
}
+27 -1
View File
@@ -21,10 +21,10 @@ pub mod sample;
use std::collections::BTreeMap;
use std::sync::Arc;
use modelcraft_explicit::ExplicitResult;
use modelcraft_geometry::Design;
use modelcraft_geometry::tagged_mesh::TaggedSurface;
use modelcraft_model::Model;
use modelcraft_solver::StepResult;
use serde_json::Value;
use crate::history::History;
@@ -47,6 +47,8 @@ pub enum EngineError {
Deck(#[from] modelcraft_decks::Error),
#[error(transparent)]
Solver(#[from] modelcraft_solver::Error),
#[error(transparent)]
Explicit(#[from] modelcraft_explicit::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -54,6 +56,30 @@ pub enum EngineError {
pub type Result<T> = std::result::Result<T, EngineError>;
/// The result of a solved step, from the implicit or the explicit solver.
#[derive(Debug, Clone, PartialEq)]
pub enum StepResult {
Implicit(modelcraft_solver::StepResult),
Explicit(ExplicitResult),
}
impl StepResult {
pub fn step(&self) -> &str {
match self {
StepResult::Implicit(r) => r.step(),
StepResult::Explicit(r) => &r.step,
}
}
/// The node labels the result's per-node values follow, sorted.
pub fn nodes(&self) -> &[u32] {
match self {
StepResult::Implicit(r) => r.nodes(),
StepResult::Explicit(r) => &r.nodes,
}
}
}
/// What a command works on, which decides how [`Session::execute`] records it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {