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

+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);
}
}