forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
308 lines
15 KiB
Rust
308 lines
15 KiB
Rust
//! 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:?}");
|
||
}
|