Files
OneCAD-Simulation/crates/explicit/tests/one_d.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

308 lines
15 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.
//! Trusses, point masses, springs and dashpots in explicit dynamics against closed-form
//! solutions. Run with `--nocapture` to see the errors.
//!
//! - A mass on a spring started with a velocity: x = (v₀/ω) sin ωt.
//! - The same with a dashpot: the amplitude decays as e^{−ξωt}, the period is 2π/ω_d with
//! ω_d = ω√(1 − ξ²); a mass on a dashpot alone slows as v₀ e^{−ct/m}.
//! - Two masses on a two-point spring along its line: ω² = k(1/m₁ + 1/m₂), momentum conserved.
//! - A truss bar driven at one end: a wave front at c t with stress −ρ c v behind it.
use std::f64::consts::PI;
use fieldcraft_explicit::{ExplicitResult, Limits, solve_explicit};
use fieldcraft_fem::truss::axial;
use fieldcraft_model::*;
fn model(step: ExplicitDynamic) -> Model {
let mut m = Model::default();
m.add_step(Step::new("Dynamic", Procedure::ExplicitDynamic(ExplicitDynamic::new(step.end_time))), None).unwrap();
// The step's settings name sets; they are put in once the sets exist (see `solve`).
PENDING.with(|p| *p.borrow_mut() = Some(step));
m
}
thread_local! {
static PENDING: std::cell::RefCell<Option<ExplicitDynamic>> = const { std::cell::RefCell::new(None) };
}
/// Put the step's settings in, keeping what is active in it, and solve.
fn solve(m: &mut Model) -> ExplicitResult {
if let Some(settings) = PENDING.with(|p| p.borrow_mut().take()) {
let mut step = m.step("Dynamic").unwrap().clone();
step.procedure = Procedure::ExplicitDynamic(settings);
m.replace_step(step).unwrap();
}
solve_explicit(m, None, &Limits::default()).unwrap()
}
fn set(m: &mut Model, name: &str, nodes: &[u32]) -> Target {
m.add_set(name, Set { nodes: nodes.to_vec(), ..Set::default() }).unwrap();
Target::Set(name.into())
}
fn hold(m: &mut Model, name: &str, target: Target, values: [Option<f64>; 6]) {
m.add_boundary_condition(name, BoundaryCondition::Displacement { target, values }).unwrap();
m.set_boundary_condition_active("Dynamic", name, true).unwrap();
}
fn step(end: f64, scale: f64, moving: &str, velocity: [f64; 3]) -> ExplicitDynamic {
let mut s = ExplicitDynamic::new(end);
s.dt_scale = scale;
s.history_nodes = Some(moving.into());
s.history_interval = Some(end / 20_000.0);
s.initial_velocities = vec![InitialVelocity { set: moving.into(), velocity }];
s.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 0.0 };
s
}
/// The history of one direction of the first history node.
fn trace(r: &ExplicitResult, k: usize) -> Vec<(f64, f64)> {
r.history.time.iter().zip(&r.history.displacement).map(|(t, d)| (*t, d[0][k])).collect()
}
/// Local maxima of a sampled signal (parabolic interpolation through the three samples).
fn peaks(x: &[(f64, f64)]) -> Vec<(f64, f64)> {
x.windows(3)
.filter(|w| w[1].1 > w[0].1 && w[1].1 >= w[2].1)
.map(|w| {
let (h, a, b, c) = (w[1].0 - w[0].0, w[0].1, w[1].1, w[2].1);
let d = 0.5 * (a - c) / (a - 2.0 * b + c);
(w[1].0 + d * h, b - 0.25 * (a - c) * d)
})
.collect()
}
/// A mass on a spring to the ground (and optionally a dashpot), started at velocity v₀.
fn oscillator(k: f64, m: f64, damping: Option<f64>, periods: f64, scale: f64) -> ExplicitResult {
let omega = (k / m).sqrt();
let v0 = 0.3;
let mut model = model(step(periods * 2.0 * PI / omega, scale, "n", [v0, 0.0, 0.0]));
model.set_node(1, [0.0, 0.0, 0.0]).unwrap();
let n = set(&mut model, "n", &[1]);
model.add_spring("k", Spring::Spring { first: n.clone(), second: None, direction: Direction::Dof(1), stiffness: k }).unwrap();
if let Some(c) = damping {
model.add_spring("c", Spring::Dashpot { first: n.clone(), second: None, direction: Direction::Dof(1), coefficient: c }).unwrap();
}
model.add_mass("m", PointMass { target: n.clone(), mass: Some(Mass::Isotropic(m)), inertia: None }).unwrap();
hold(&mut model, "yz", n, [None, Some(0.0), Some(0.0), None, None, None]);
solve(&mut model)
}
#[test]
fn spring_mass_oscillation() {
let (k, m) = (200.0f64, 0.5);
let r = oscillator(k, m, None, 3.0, 0.02);
let omega = (k / m).sqrt();
let amplitude = 0.3 / omega;
let worst = trace(&r, 0).iter().map(|(t, x)| (x - amplitude * (omega * t).sin()).abs()).fold(0.0, f64::max);
let p = peaks(&trace(&r, 0));
let period = (p[2].0 - p[0].0) / 2.0;
eprintln!(
"spring–mass: worst error {:.2e} of the amplitude, period {period:.6} vs {:.6}, Δt {:.3e}, energy error {:.1e}",
worst / amplitude,
2.0 * PI / omega,
r.initial_increment,
r.energy.relative_error(r.initial_kinetic)
);
assert!(worst < 2e-3 * amplitude, "second-order phase error");
assert!((period - 2.0 * PI / omega).abs() < 1e-4 * period);
assert!(r.energy.relative_error(r.initial_kinetic) < 1e-3);
assert!(r.warnings.iter().any(|w| w.contains("limits the time increment")), "{:?}", r.warnings);
// The increment is the scale times the spring's 2/ω.
assert!((r.initial_increment - 0.02 * 2.0 / omega).abs() < 1e-12 * r.initial_increment);
}
#[test]
fn damped_decay_rate() {
let (k, m, xi) = (200.0f64, 0.5, 0.05);
let omega = (k / m).sqrt();
let c = 2.0 * xi * m * omega;
let r = oscillator(k, m, Some(c), 6.0, 0.01);
let p = peaks(&trace(&r, 0));
let wd = omega * (1.0 - xi * xi).sqrt();
// Decay rate from the first and last peaks, period from their spacing.
let (first, last) = (p[0], p[p.len() - 1]);
let rate = (first.1 / last.1).ln() / (last.0 - first.0);
let period = (last.0 - first.0) / (p.len() - 1) as f64;
let rate_err = (rate - xi * omega).abs() / (xi * omega);
let period_err = (period - 2.0 * PI / wd).abs() / (2.0 * PI / wd);
eprintln!("damped oscillator: decay rate {rate:.6} vs ξω {:.6} ({rate_err:.2e}), period error {period_err:.2e}", xi * omega);
assert!(rate_err < 0.01, "{rate} vs {}", xi * omega);
assert!(period_err < 1e-3);
// The dashpot dissipates what the oscillator loses: the balance holds.
assert!(r.energy.viscous > 0.9 * r.initial_kinetic && r.energy.relative_error(r.initial_kinetic) < 0.01, "{:?}", r.energy);
// A dashpot alone: v = v₀ e^{−ct/m}.
let mut model = model(step(3.0 * m / c, 0.001, "n", [0.3, 0.0, 0.0]));
model.set_node(1, [0.0; 3]).unwrap();
let n = set(&mut model, "n", &[1]);
model.add_spring("c", Spring::Dashpot { first: n.clone(), second: None, direction: Direction::Vector([2.0, 0.0, 0.0]), coefficient: c }).unwrap();
model.add_mass("m", PointMass { target: n.clone(), mass: Some(Mass::Isotropic(m)), inertia: None }).unwrap();
hold(&mut model, "yz", n, [None, Some(0.0), Some(0.0), None, None, None]);
let r = solve(&mut model);
let (t, v) = (r.history.time.last().unwrap(), r.history.velocity.last().unwrap()[0][0]);
let exact = 0.3 * (-c * t / m).exp();
eprintln!("dashpot alone: v {v:.6e} vs {exact:.6e} at t = {t:.4}");
assert!((v - exact).abs() < 0.01 * exact);
// Travel: x(∞) = v₀ m / c.
let x = r.history.displacement.last().unwrap()[0][0];
let travel = 0.3 * m / c * (1.0 - (-c * t / m).exp());
assert!((x - travel).abs() < 0.01 * travel, "{x} vs {travel}");
}
#[test]
fn two_masses_on_a_line_spring() {
let (k, m1, m2) = (50.0f64, 1.0, 3.0);
let omega = (k * (1.0 / m1 + 1.0 / m2)).sqrt();
let dir = [0.6, 0.0, 0.8];
let mut model = model(step(4.0 * 2.0 * PI / omega, 0.02, "a", [0.0; 3]));
model.set_node(1, [0.0; 3]).unwrap();
model.set_node(2, dir.map(|c| 2.0 * c)).unwrap();
let a = set(&mut model, "a", &[1]);
let b = set(&mut model, "b", &[2]);
model.add_spring("k", Spring::Spring { first: a.clone(), second: Some(b.clone()), direction: Direction::Line, stiffness: k }).unwrap();
model.add_mass("m1", PointMass { target: a, mass: Some(Mass::Isotropic(m1)), inertia: None }).unwrap();
model.add_mass("m2", PointMass { target: b, mass: Some(Mass::Isotropic(m2)), inertia: None }).unwrap();
// Node 1 starts towards node 2, node 2 at rest: the centre of mass drifts, the spring rings.
PENDING.with(|p| p.borrow_mut().as_mut().unwrap().initial_velocities = vec![InitialVelocity { set: "a".into(), velocity: dir.map(|c| 0.5 * c) }]);
let r = solve(&mut model);
// Momentum is conserved exactly (internal forces are equal and opposite).
let p0 = r.history.momentum[0];
let drift = r.history.momentum.iter().map(|p| (0..3).map(|k| (p[k] - p0[k]).abs()).fold(0.0, f64::max)).fold(0.0, f64::max);
// The relative stretch along the line oscillates at ω.
let x1 = trace(&r, 0);
let along: Vec<(f64, f64)> = r
.history
.time
.iter()
.zip(&r.history.displacement)
.map(|(t, d)| (*t, d[0][0] * dir[0] + d[0][2] * dir[2] - 0.5 * m1 / (m1 + m2) * t))
.collect();
let p = peaks(&along);
let period = (p[3].0 - p[0].0) / 3.0;
eprintln!("two masses: period {period:.6} vs {:.6}, momentum drift {drift:.1e}", 2.0 * PI / omega);
assert!((period - 2.0 * PI / omega).abs() < 1e-4 * period);
assert!(drift < 1e-14);
assert!(x1.iter().all(|(_, x)| x.is_finite()));
}
/// A rod2 bar along x, held across, its end pushed at velocity v: a wave front at c t and
/// σ = −ρ c v behind it, carried at the bar's own critical increment L/c.
#[test]
fn velocity_wave_in_a_truss_bar() {
let (young, rho, area, length, cells) = (1.0e4f64, 1.0, 0.01, 10.0, 200usize);
let c = (young / rho).sqrt();
let t = 0.6 * length / c;
let v = 0.01;
for kind in [ElementKind::Rod2, ElementKind::Rod3] {
let mut s = ExplicitDynamic::new(t);
s.output_interval = Some(t / 2.0);
// A rod3 is small-displacement only; the rod2 runs corotational (the default).
s.nonlinear_geometry = kind == ElementKind::Rod2;
let mut m = model(s);
let h = length / cells as f64;
for i in 0..=cells {
m.set_node(1 + i as u32, [i as f64 * h, 0.0, 0.0]).unwrap();
}
for i in 0..cells {
let mut nodes = vec![1 + i as u32, 2 + i as u32];
if kind == ElementKind::Rod3 {
m.set_node(1000 + i as u32, [(i as f64 + 0.5) * h, 0.0, 0.0]).unwrap();
nodes.push(1000 + i as u32);
}
m.set_element(1 + i as u32, kind, nodes).unwrap();
}
m.add_set("bar", Set { elements: (1..=cells as u32).collect(), ..Set::default() }).unwrap();
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young, poisson: 0.0 }), density: Some(rho), ..Material::default() })
.unwrap();
m.add_section(
"bar",
Section { set: "bar".into(), material: "Mat".into(), kind: SectionKind::Rod { area }, formulation: Formulation::default() },
)
.unwrap();
let all: Vec<u32> = m.nodes().keys().copied().collect();
let line = set(&mut m, "line", &all);
hold(&mut m, "line", line, [None, Some(0.0), Some(0.0), None, None, None]);
let start = set(&mut m, "start", &[1]);
hold(&mut m, "push", start, [Some(v * t), None, None, None, None, None]);
let r = solve(&mut m);
let f = r.frames.last().unwrap();
// σ behind the front (x < 0.8 c t) and the front where it falls to half.
let stress: Vec<(f64, f64)> = (0..cells).map(|i| ((i as f64 + 0.5) * h, axial(&f.stress[i], [1.0, 0.0, 0.0], false))).collect();
let behind: Vec<f64> = stress.iter().filter(|(x, _)| *x > 0.1 * c * t && *x < 0.8 * c * t).map(|s| s.1).collect();
let sigma = behind.iter().sum::<f64>() / behind.len() as f64;
let front =
stress.windows(2).find(|w| w[0].1 / (-rho * c * v) >= 0.5 && w[1].1 / (-rho * c * v) < 0.5).map(|w| (w[0].0 + w[1].0) / 2.0).unwrap();
let expected_dt = if kind == ElementKind::Rod2 { 0.9 * h / c } else { r.initial_increment };
eprintln!(
"{kind:?} bar wave: σ {sigma:.6} vs −ρcv {:.6}, front {front:.4} vs c t {:.4}, Δt {:.3e}, energy error {:.1e}",
-rho * c * v,
c * t,
r.initial_increment,
r.energy.relative_error(r.initial_kinetic)
);
assert!((sigma + rho * c * v).abs() < 0.01 * rho * c * v);
assert!((front - c * t).abs() < 3.0 * h);
assert!((r.initial_increment - expected_dt).abs() < 1e-12 * expected_dt);
assert!(r.energy.relative_error(r.initial_kinetic) < 0.01, "{:?}", r.energy);
}
}
#[test]
fn what_explicit_cannot_take_is_named() {
// A node with a spring and no mass.
let mut m = model(ExplicitDynamic::new(1.0));
m.set_node(1, [0.0; 3]).unwrap();
let n = set(&mut m, "n", &[1]);
m.add_spring("k", Spring::Spring { first: n.clone(), second: None, direction: Direction::Dof(1), stiffness: 1.0 }).unwrap();
let e = solve_explicit(&m, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("node 1 has no mass"), "{e}");
// Rotary inertia, and a rotational spring.
m.add_reference_point("RP", ReferencePoint { xyz: [0.0; 3], label: 0 }).unwrap();
m.add_mass("j", PointMass { target: Target::ReferencePoint("RP".into()), mass: None, inertia: Some([1.0, 1.0, 1.0, 0.0, 0.0, 0.0]) }).unwrap();
let e = solve_explicit(&m, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("rotary inertia"), "{e}");
}
/// A mass whirling on a stiff rod2 tethered to a fixed point (nonlinear geometry): the rod turns
/// with it and keeps its length, carrying the centripetal force m v²/L. A small-displacement rod
/// would let the mass fly off along the tangent.
#[test]
fn whirling_mass_on_a_corotational_rod() {
let (l, mass, v, axial) = (1.0f64, 1.0, 1.0, 1.0e4);
let t = std::f64::consts::FRAC_PI_2 * l / v;
let mut s = step(t, 0.9, "tip", [0.0, v, 0.0]);
s.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 0.0 };
let mut m = model(s);
m.set_node(1, [0.0; 3]).unwrap();
m.set_node(2, [l, 0.0, 0.0]).unwrap();
m.set_element(1, ElementKind::Rod2, vec![1, 2]).unwrap();
m.add_set("rod", Set { elements: vec![1], ..Set::default() }).unwrap();
// A light rod (E A = 1e4, ρ A = 1e-4) with the mass at its end.
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: axial, poisson: 0.0 }), density: Some(1e-4), ..Material::default() })
.unwrap();
m.add_section(
"rod",
Section { set: "rod".into(), material: "Mat".into(), kind: SectionKind::Rod { area: 1.0 }, formulation: Formulation::default() },
)
.unwrap();
let pivot = set(&mut m, "pivot", &[1]);
m.add_boundary_condition("pivot", BoundaryCondition::Fixed { target: pivot }).unwrap();
m.set_boundary_condition_active("Dynamic", "pivot", true).unwrap();
let tip = set(&mut m, "tip", &[2]);
m.add_mass("m", PointMass { target: tip.clone(), mass: Some(Mass::Isotropic(mass)), inertia: None }).unwrap();
hold(&mut m, "plane", tip, [None, None, Some(0.0), None, None, None]);
let r = solve(&mut m);
let radius = r.history.displacement.iter().map(|d| ((l + d[0][0]).powi(2) + d[0][1].powi(2)).sqrt()).fold(0.0f64, |w, x| w.max((x - l).abs()));
let end = r.history.displacement.last().unwrap()[0];
eprintln!(
"whirling mass: largest change of radius {radius:.3e} (centripetal stretch m v²/EA = {:.1e}), end at ({:.4}, {:.4})",
mass * v * v / axial,
l + end[0],
end[1]
);
assert!(radius < 5.0 * mass * v * v / axial, "the rod keeps its length to its elastic stretch");
// A quarter turn: the mass ends near (0, L).
assert!((l + end[0]).abs() < 1e-2 && (end[1] - l).abs() < 1e-2, "{end:?}");
}