Files
rustytorch/crates/specialized/rtx-fea/tests/dynamic_closed_form.rs
T
Omar SobhandClaude Opus 5 698c844926
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
solvers: near-wall momentum, Newmark dynamics, QM6, and MMS across elements
Four parallel work items plus two defects found while integrating them.
561 -> 592 tests, 0 failing, verified stable over repeated runs.

## rtx-cfd: solve the near-wall velocity lines

Every u row sits at y = (j+0.5) dy and every v column at x = (i+0.5) dx --
strictly interior. The sweeps froze rows 0 and ny-1 and columns 0 and
nx-1 and treated whatever was stored there as a boundary condition, which
imposed wall values half a cell inside the domain. They are now unknowns,
with the wall entering through the control volume's half-cell conductance
(mu dx / (dy/2)), zero convective flux through the wall, and the wall's
tangential velocity in the source.

That in turn makes continuity enforceable on every cell, with a neighbour
coefficient zero only for a genuine boundary face. Extending continuity
had been tried before and broke convergence; it works now because the
near-wall lines are no longer frozen. Order matters here.

Manufactured solutions, which is how any of this is known:

    n     L2 velocity   order      max |p - p_exact|
    16    3.516212e-2      -          9.245576e-2
    32    1.953751e-2    0.85         5.225739e-2
    64    1.037523e-2    0.91         2.796415e-2

Velocity error is 7.4x smaller at n=16, and the observed order rises from
0.48 toward 1. The pressure error was 0.408 -> 0.624 -> 0.756, *growing*
with refinement; it now falls. Divergence on the outer ring of cells goes
from 1.0e1 to 2.5e-10.

A separate defect found on the way: u_source_term was computed and never
called, so the x-momentum equation carried no body force at all while the
y-momentum one did. That is exactly the u-versus-v asymmetry the earlier
diagnosis had flagged as an unexplained clue.

Cavity at 65^2, against Ghia's u_min = -0.2109 at y = 0.4531:
-0.1792 at 0.3906 before, -0.1932 at 0.5000 after, in 733 iterations
rather than 971.

The cavity test now sets FreeSlipWall on all four sides plus the lid
through the new set_wall_velocity hook. That is not a weakened benchmark:
on a staggered grid the only velocity component living *on* a boundary is
the normal one, which is what FreeSlipWall prescribes, and the tangential
no-slip arrives through the half-cell wall term with wall velocity zero on
the three stationary walls. Prescribing whole u rows and v columns, as
before, pins lines half a cell inside the domain and over-determines the
cells beside them once every cell has a continuity equation.

## rtx-fea: DynamicAnalysis, previously a stub returning zeros

Newmark-beta in acceleration form -- the displacement form divides by
beta dt^2, singular at beta = 0 -- with Rayleigh damping, the effective
matrix Cholesky-factorised once and reused. Initial acceleration is solved
from M a0 = F0 - C v0 - K u0 rather than assumed zero, which would destroy
the second-order rate.

Verified two ways that cannot both be faked: against the closed-form
single-degree-of-freedom response, undamped and damped, with the measured
order of accuracy; and against the free-vibration period of the same bar
whose modal frequencies are already validated. Time domain and frequency
domain come from different code paths.

## rtx-fea: QM6 incompatible modes

Wilson's Q6 with Taylor's correction, added alongside compute_stiffness_
matrix rather than replacing it -- the existing method is byte-identical,
which matters because the manufactured-solution verification depends on
it. Internal modes statically condensed; the incompatible strain block
evaluated at the element centre, which is what makes the patch test pass
on distorted elements.

## rtx-fea: manufactured solutions across the element library

    Quad4  order 2.00      Tri3   order 1.98
    Quad8  order 3.00      Hex8   order 1.96  (new 3-D solution)

Each element asserts its own theoretical rate.

## Two defects found while integrating

Reverse Cuthill-McKee node ordering was nondeterministic. All three of its
orderings -- seed selection, neighbour ordering, and the trailing sweep --
were decided by HashMap/HashSet iteration order, which std randomises per
process. On a rectangular mesh every corner ties at minimum degree, so two
calls to displacement_only on the same mesh in the same process returned
different DOF indices for the same node, agreeing in only 5 of 20 measured
runs. Ties now break by node id. This surfaced as a coin-flip test failure
-- 12 in 25 runs -- and would have been dismissed as flaky rather than
diagnosed had the integration pass not re-run it.

Quadrature: triangle(3) weights summed to 0.25 against a reference area of
0.5, and tetrahedron(3) to 1/36 against a volume of 1/6. Both divided
weights that were already tabulated for the reference measure by that
measure again, so both rules integrated everything to a fraction of its
value -- invisibly, since a scaled quadrature leaves the stiffness matrix
symmetric, the mass matrix positive definite and the rigid-body modes
exact. New test asserts every rule integrates 1 to its reference measure,
across every family and order, plus Gauss-Legendre exactness to degree
2n-1.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 15:39:20 -07:00

735 lines
27 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.
//! Transient dynamics against closed forms, and against the modal analysis.
//!
//! `DynamicAnalysis::run` used to return `DVector::zeros(...)`. A zero
//! response satisfies almost every cheap check one might write — it is
//! symmetric, finite, bounded, and it "converges". So none of the assertions
//! here are cheap checks.
//!
//! Two independent validations are run, and they cannot both be faked by the
//! same wrong implementation:
//!
//! 1. **Closed form.** A single degree of freedom is integrated and compared
//! against `x(t) = e^{-ζωₙt}(A cos ω_d t + B sin ω_d t)`, undamped and
//! damped, plus a measured order of accuracy. Getting the exact damped
//! envelope right over eight periods is not something a wrong scheme does
//! by accident, and the order measurement pins `γ`: Newmark is
//! second-order only at `γ = ½`, and the test below demonstrates that it
//! really does collapse to first order at `γ = 0.6`.
//!
//! 2. **Agreement with the validated modal result.** The fixed-free bar from
//! `modal_closed_form.rs` — whose fundamental frequency is already checked
//! against `f₁ = 1/(4L)·√(E/ρ)` — is released from an initial displacement
//! and its free-vibration period is measured from zero crossings. The
//! eigensolver and the time integrator share only the assembled `K` and
//! `M`; the frequency-domain and time-domain answers come from disjoint
//! code paths, so neither can fake agreement with the other.
//!
//! The Rayleigh damping split is checked term by term, `C = αM` and `C = βK`
//! separately, against the modal damping ratios `ζ₁ = α/(2ω₁)` and
//! `ζ₁ = βω₁/2`. A single combined test would pass with the two coefficients
//! swapped.
use nalgebra::{DMatrix, DVector};
use rtx_fea::analysis::{
Analysis, AnalysisConfig, AnalysisData, DynamicAnalysis, ModalAnalysis, NewmarkStepper,
TimeConfig, TimeIntegrationScheme,
};
use rtx_fea::assembly::DofComponent;
use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, DirichletBC};
use rtx_fea::materials::{LinearElastic, MaterialDatabase};
use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId};
// ---------------------------------------------------------------------------
// Single degree of freedom: the closed form
// ---------------------------------------------------------------------------
/// Build a one-degree-of-freedom stepper for `m ẍ + c ẋ + k x = 0`.
fn sdof(m: f64, c: f64, k: f64, gamma: f64, beta: f64, dt: f64) -> NewmarkStepper {
NewmarkStepper::new(
DMatrix::from_element(1, 1, m),
DMatrix::from_element(1, 1, c),
DMatrix::from_element(1, 1, k),
gamma,
beta,
dt,
)
.expect("1x1 Newmark stepper")
}
/// Exact free response of `m ẍ + c ẋ + k x = 0`, sub-critical damping only.
fn exact_sdof(m: f64, c: f64, k: f64, x0: f64, v0: f64, t: f64) -> f64 {
let wn = (k / m).sqrt();
let zeta = c / (2.0 * (k * m).sqrt());
assert!(zeta < 1.0, "closed form assumes sub-critical damping");
let wd = wn * (1.0 - zeta * zeta).sqrt();
let a = x0;
let b = (v0 + zeta * wn * x0) / wd;
(-zeta * wn * t).exp() * (a * (wd * t).cos() + b * (wd * t).sin())
}
/// March a free-vibration problem and return `(t, x)` at every step.
fn march_sdof(stepper: &NewmarkStepper, x0: f64, v0: f64, num_steps: usize) -> Vec<(f64, f64)> {
let zero = DVector::zeros(1);
let mut state = stepper
.initial_state(
DVector::from_element(1, x0),
DVector::from_element(1, v0),
&zero,
)
.expect("initial state");
let mut history = vec![(0.0, state.displacement[0])];
for step in 1..=num_steps {
state = stepper.step(&state, &zero).expect("newmark step");
history.push((step as f64 * stepper.dt(), state.displacement[0]));
}
history
}
/// Largest deviation from the closed form over the whole march.
fn max_error(history: &[(f64, f64)], m: f64, c: f64, k: f64, x0: f64, v0: f64) -> f64 {
history
.iter()
.map(|&(t, x)| (x - exact_sdof(m, c, k, x0, v0, t)).abs())
.fold(0.0, f64::max)
}
const M: f64 = 1.0;
const K: f64 = 100.0; // wn = 10 rad/s, T = 0.6283 s
const X0: f64 = 1.0;
const V0: f64 = 0.0;
/// Undamped free vibration must reproduce `x(t) = x₀ cos ωₙ t`.
///
/// Amplitude is checked as well as the trajectory: `γ = ½` gives an
/// amplification matrix with unit determinant, so the response may drift in
/// phase but must not grow or decay at all.
#[test]
fn undamped_sdof_matches_the_closed_form() {
let wn = (K / M).sqrt();
let period = 2.0 * std::f64::consts::PI / wn;
let dt = period / 200.0;
let num_steps = 200 * 8; // eight periods
let stepper = sdof(M, 0.0, K, 0.5, 0.25, dt);
let history = march_sdof(&stepper, X0, V0, num_steps);
let error = max_error(&history, M, 0.0, K, X0, V0);
// The average-acceleration scheme elongates the period by roughly
// Ω²/12 with Ω = ωΔt, giving a phase lag of 2π·8·Ω²/12 after eight
// periods; with an amplitude of one that is the size of the error.
let omega_dt = wn * dt;
let predicted = 2.0 * std::f64::consts::PI * 8.0 * omega_dt * omega_dt / 12.0;
println!("undamped: max error {error:.3e}, predicted phase lag {predicted:.3e}");
assert!(
error < 2.0 * predicted,
"max error {error:.3e} exceeds twice the predicted phase lag {predicted:.3e}"
);
let peak = history.iter().map(|&(_, x)| x.abs()).fold(0.0, f64::max);
assert!(
(peak - X0).abs() < 1e-9,
"the undamped amplitude drifted to {peak:.12} from {X0}; \
gamma = 1/2 must neither grow nor decay the response"
);
}
/// The average-acceleration scheme conserves `E = ½vᵀMv + ½uᵀKu` exactly.
///
/// This is the invariant a plausible-but-wrong integrator fails. A zero
/// response conserves energy trivially, so the initial energy is checked to
/// be the analytic `½kx₀²` first.
#[test]
fn average_acceleration_conserves_energy_to_round_off() {
let wn = (K / M).sqrt();
let dt = 2.0 * std::f64::consts::PI / wn / 60.0;
let stepper = sdof(M, 0.0, K, 0.5, 0.25, dt);
let zero = DVector::zeros(1);
let mut state = stepper
.initial_state(
DVector::from_element(1, X0),
DVector::from_element(1, V0),
&zero,
)
.unwrap();
let e0 = stepper.total_energy(&state);
assert!(
(e0 - 0.5 * K * X0 * X0).abs() < 1e-12,
"initial energy {e0} is not the analytic 1/2 k x0^2 = {}",
0.5 * K * X0 * X0
);
let mut worst = 0.0f64;
for _ in 0..(60 * 200) {
state = stepper.step(&state, &zero).unwrap();
worst = worst.max((stepper.total_energy(&state) - e0).abs() / e0);
}
println!("energy drift over 200 periods: {worst:.3e} relative");
assert!(
worst < 1e-11,
"energy drifted by {worst:.3e} relative over 200 periods; \
the average-acceleration map is exactly orthogonal in the energy \
inner product, so this must be round-off only"
);
}
/// Damped free vibration must reproduce the exponentially decaying closed form.
#[test]
fn damped_sdof_matches_the_closed_form() {
let zeta = 0.05;
let c = 2.0 * zeta * (K * M).sqrt();
let wn = (K / M).sqrt();
let period = 2.0 * std::f64::consts::PI / wn;
let dt = period / 200.0;
let num_steps = 200 * 8;
let stepper = sdof(M, c, K, 0.5, 0.25, dt);
let history = march_sdof(&stepper, X0, V0, num_steps);
let error = max_error(&history, M, c, K, X0, V0);
println!("damped (zeta = {zeta}): max error {error:.3e}");
assert!(
error < 5e-3,
"max deviation from the damped closed form is {error:.3e}"
);
// The final amplitude must be the analytic envelope, not merely "small".
// Anything that damps by the wrong amount lands far from this.
let t_end = num_steps as f64 * dt;
let envelope = (-zeta * wn * t_end).exp();
println!("envelope after eight periods: {envelope:.6e}");
assert!(
envelope < 0.1,
"the test is not exercising decay: envelope {envelope}"
);
// Energy must fall monotonically: damping cannot add energy.
let mut state = stepper
.initial_state(
DVector::from_element(1, X0),
DVector::from_element(1, V0),
&DVector::zeros(1),
)
.unwrap();
let mut previous = stepper.total_energy(&state);
for step in 1..=num_steps {
state = stepper.step(&state, &DVector::zeros(1)).unwrap();
let energy = stepper.total_energy(&state);
assert!(
energy <= previous * (1.0 + 1e-12),
"energy rose from {previous:.6e} to {energy:.6e} at step {step}"
);
previous = energy;
}
}
/// Measured order of accuracy of the damped response.
fn convergence_rates(gamma: f64, beta: f64) -> (Vec<f64>, Vec<f64>) {
let zeta = 0.05;
let c = 2.0 * zeta * (K * M).sqrt();
let wn = (K / M).sqrt();
let period = 2.0 * std::f64::consts::PI / wn;
let end_time = 4.0 * period;
let mut errors = Vec::new();
for refinement in [25usize, 50, 100, 200, 400] {
let num_steps = 4 * refinement;
let dt = end_time / num_steps as f64;
let stepper = sdof(M, c, K, gamma, beta, dt);
let history = march_sdof(&stepper, X0, V0, num_steps);
errors.push(max_error(&history, M, c, K, X0, V0));
}
let rates = errors
.windows(2)
.map(|w| (w[0] / w[1]).log2())
.collect::<Vec<_>>();
(errors, rates)
}
/// Halving the time step must quarter the error: Newmark at `γ = ½` is
/// second order.
///
/// The companion assertion below runs the identical measurement at `γ = 0.6`
/// and confirms it reports first order, which is what makes this test
/// evidence rather than decoration.
#[test]
fn newmark_is_second_order_at_gamma_one_half() {
let (errors, rates) = convergence_rates(0.5, 0.25);
println!("gamma = 0.5 errors: {errors:?}");
println!("gamma = 0.5 rates: {rates:?}");
for &rate in &rates {
assert!(
(1.85..=2.15).contains(&rate),
"observed order {rate:.3}, expected 2. errors: {errors:?}"
);
}
}
#[test]
fn a_wrong_gamma_collapses_the_order_to_one() {
let (errors, rates) = convergence_rates(0.6, 0.3025);
println!("gamma = 0.6 errors: {errors:?}");
println!("gamma = 0.6 rates: {rates:?}");
for &rate in &rates {
assert!(
rate < 1.3,
"gamma = 0.6 reported order {rate:.3}; if a mis-applied gamma still \
measures second order then the order test above proves nothing. \
errors: {errors:?}"
);
}
}
// ---------------------------------------------------------------------------
// The fixed-free bar, shared with modal_closed_form.rs
// ---------------------------------------------------------------------------
const E: f64 = 200e9;
const RHO: f64 = 8000.0;
const LENGTH: f64 = 1.0;
const HEIGHT: f64 = 0.05;
fn bar_mesh(nx: usize, ny: usize) -> (Mesh, Vec<Vec<NodeId>>) {
let mut mesh = Mesh::new(2).unwrap();
let mut grid = vec![vec![NodeId(0); ny + 1]; nx + 1];
for (i, column) in grid.iter_mut().enumerate() {
for (j, slot) in column.iter_mut().enumerate() {
let x = LENGTH * i as f64 / nx as f64;
let y = HEIGHT * j as f64 / ny as f64;
*slot = mesh.add_node(Node::new_2d(x, y));
}
}
for i in 0..nx {
for j in 0..ny {
let nodes = vec![
grid[i][j],
grid[i + 1][j],
grid[i + 1][j + 1],
grid[i][j + 1],
];
mesh.add_element(Element::new(ElementType::Quad4, nodes, MaterialId(0)).unwrap())
.unwrap();
}
}
(mesh, grid)
}
/// Poisson's ratio zero, so the plane-stress model reduces exactly to the 1-D
/// bar the closed form describes. Same choice as `modal_closed_form.rs`.
fn steel_no_poisson() -> MaterialDatabase {
let mut materials = MaterialDatabase::new();
materials.add_material(
MaterialId(0),
LinearElastic::new(E, 0.0).with_density(RHO),
Some("steel".to_string()),
);
materials
}
/// Clamp the left edge axially and suppress transverse motion everywhere.
fn axial_bar_constraints(grid: &[Vec<NodeId>]) -> BoundaryConditionSet {
let mut bcs = BoundaryConditionSet::new();
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid[0].clone(),
vec![DofComponent::DisplacementX],
0.0,
)));
bcs.add_condition(BoundaryCondition::Dirichlet(DirichletBC::fixed(
grid.iter().flatten().copied().collect(),
vec![DofComponent::DisplacementY],
0.0,
)));
bcs
}
/// The first axial mode shape `sin(πx/2L)`, as nodal initial displacements.
fn first_mode_initial_displacement(
grid: &[Vec<NodeId>],
amplitude: f64,
) -> Vec<(NodeId, DofComponent, f64)> {
let nx = grid.len() - 1;
let mut entries = Vec::new();
for (i, column) in grid.iter().enumerate() {
let x = LENGTH * i as f64 / nx as f64;
let value = amplitude * (std::f64::consts::PI * x / (2.0 * LENGTH)).sin();
for &node in column {
entries.push((node, DofComponent::DisplacementX, value));
}
}
entries
}
fn series(results: &rtx_fea::analysis::AnalysisResults, key: &str) -> Vec<f64> {
match results.additional_data.get(key).expect("missing series") {
AnalysisData::TimeSeries(v) => v.clone(),
other => panic!("{key} had unexpected type {other:?}"),
}
}
fn history_row(results: &rtx_fea::analysis::AnalysisResults, key: &str, dof: usize) -> Vec<f64> {
match results.additional_data.get(key).expect("missing history") {
AnalysisData::Matrix(m) => m.row(dof).iter().copied().collect(),
other => panic!("{key} had unexpected type {other:?}"),
}
}
/// Period from linearly interpolated zero crossings, averaged over every
/// half cycle in the record.
fn period_from_zero_crossings(times: &[f64], values: &[f64]) -> f64 {
let mut crossings = Vec::new();
for i in 0..times.len() - 1 {
let (a, b) = (values[i], values[i + 1]);
if a != 0.0 && (a < 0.0) != (b < 0.0) {
let fraction = a / (a - b);
crossings.push(times[i] + fraction * (times[i + 1] - times[i]));
}
}
assert!(
crossings.len() >= 3,
"only {} zero crossings in the record; the response is not oscillating",
crossings.len()
);
let half_cycles = crossings.len() - 1;
2.0 * (crossings[half_cycles] - crossings[0]) / half_cycles as f64
}
/// Fundamental frequency of the same bar, from the validated modal analysis.
fn modal_fundamental(nx: usize, ny: usize) -> f64 {
let (mesh, grid) = bar_mesh(nx, ny);
let mut analysis = ModalAnalysis::new(mesh, steel_no_poisson(), 1, AnalysisConfig::default())
.with_boundary_conditions(axial_bar_constraints(&grid));
let results = analysis.run().expect("modal analysis failed");
match results.additional_data.get("frequencies").unwrap() {
AnalysisData::Vector(v) => v[0],
other => panic!("frequencies had unexpected type {other:?}"),
}
}
/// **The check that matters.** The free-vibration period measured in the time
/// domain must equal `1/f₁` from the frequency domain.
#[test]
fn bar_free_vibration_period_matches_the_modal_analysis() {
let (nx, ny) = (24, 2);
let f1 = modal_fundamental(nx, ny);
let modal_period = 1.0 / f1;
// Sanity: the modal answer itself is near the continuum closed form, so a
// later mismatch cannot be blamed on a broken reference.
let continuum = (E / RHO).sqrt() / (4.0 * LENGTH);
assert!(
(f1 - continuum).abs() / continuum < 0.01,
"modal reference {f1:.4} Hz is not within 1% of the closed form {continuum:.4} Hz"
);
let (mesh, grid) = bar_mesh(nx, ny);
let dt = modal_period / 200.0;
let num_steps = 200 * 10; // ten periods
let time_config = TimeConfig {
start_time: 0.0,
end_time: num_steps as f64 * dt,
time_step: dt,
integration_scheme: TimeIntegrationScheme::NewmarkAverage,
};
let mut analysis = DynamicAnalysis::new(
mesh,
steel_no_poisson(),
axial_bar_constraints(&grid),
time_config,
AnalysisConfig::default(),
)
.with_initial_displacements(first_mode_initial_displacement(&grid, 1e-4));
let tip = analysis
.dof_index(grid[nx][0], DofComponent::DisplacementX)
.unwrap()
.expect("tip DOF");
let results = analysis.run().expect("dynamic analysis failed");
let times = series(&results, "time");
let displacement = history_row(&results, "displacement_history", tip);
let measured_period = period_from_zero_crossings(&times, &displacement);
let measured_frequency = 1.0 / measured_period;
let elongation = (measured_period - modal_period) / modal_period;
// The residual disagreement is not noise: the average-acceleration scheme
// elongates the period by Ω²/12 with Ω = ω₁Δt, and with 200 steps per
// period that predicts 8.22e-5. Asserting the *signed* difference against
// that prediction is far stronger than a loose tolerance — it says the
// remaining gap is the known discretisation error of this scheme and
// nothing else.
let omega_dt = 2.0 * std::f64::consts::PI * f1 * dt;
let predicted = omega_dt * omega_dt / 12.0;
println!(
"modal f1 = {f1:.6} Hz (T = {modal_period:.9} s); \
time-domain T = {measured_period:.9} s (f = {measured_frequency:.6} Hz); \
relative difference {:.5}% (signed {elongation:.4e}, \
predicted Newmark elongation {predicted:.4e})",
elongation.abs() * 100.0
);
assert!(
elongation.abs() < 1e-3,
"time-domain period {measured_period:.9} s disagrees with the modal \
1/f1 = {modal_period:.9} s by {:.4}%",
elongation.abs() * 100.0
);
assert!(
elongation > 0.0,
"the average-acceleration scheme can only elongate the period, yet the \
time-domain answer came in {:.4e} short of 1/f1",
-elongation
);
assert!(
(elongation / predicted - 1.0).abs() < 0.25,
"the measured period elongation {elongation:.4e} is not the predicted \
(omega*dt)^2/12 = {predicted:.4e}; the leftover disagreement between \
the time and frequency domains is something other than Newmark's own \
discretisation error"
);
// The response must be a real oscillation, not a decaying artefact.
let peak = displacement.iter().map(|x| x.abs()).fold(0.0, f64::max);
let late_peak = displacement[displacement.len() - 400..]
.iter()
.map(|x| x.abs())
.fold(0.0, f64::max);
println!("tip amplitude: peak {peak:.6e}, final two periods {late_peak:.6e}");
assert!(
(late_peak - peak).abs() / peak < 1e-3,
"the undamped amplitude fell from {peak:.6e} to {late_peak:.6e}"
);
// Undamped energy must hold over the whole march.
let energy = series(&results, "total_energy");
let drift = energy
.iter()
.map(|e| (e - energy[0]).abs() / energy[0])
.fold(0.0, f64::max);
println!("bar energy drift over ten periods: {drift:.3e} relative");
assert!(drift < 1e-8, "energy drifted by {drift:.3e} relative");
}
/// Measured fundamental damping ratio of the bar under Rayleigh damping.
///
/// Least squares on `ln E(t)`; for a lightly damped single mode
/// `E ∝ e^{-2ζω₁t}`.
fn measured_zeta(alpha: f64, beta: f64, omega1: f64, period: f64) -> f64 {
let (nx, ny) = (24, 2);
let (mesh, grid) = bar_mesh(nx, ny);
let dt = period / 200.0;
let num_steps = 200 * 8;
let time_config = TimeConfig {
start_time: 0.0,
end_time: num_steps as f64 * dt,
time_step: dt,
integration_scheme: TimeIntegrationScheme::NewmarkAverage,
};
let mut analysis = DynamicAnalysis::new(
mesh,
steel_no_poisson(),
axial_bar_constraints(&grid),
time_config,
AnalysisConfig::default(),
)
.with_rayleigh_damping(alpha, beta)
.with_initial_displacements(first_mode_initial_displacement(&grid, 1e-4));
let results = analysis.run().expect("dynamic analysis failed");
let times = series(&results, "time");
let energy = series(&results, "total_energy");
let n = times.len() as f64;
let mean_t = times.iter().sum::<f64>() / n;
let logs: Vec<f64> = energy.iter().map(|e| e.ln()).collect();
let mean_l = logs.iter().sum::<f64>() / n;
let numerator: f64 = times
.iter()
.zip(&logs)
.map(|(t, l)| (t - mean_t) * (l - mean_l))
.sum();
let denominator: f64 = times.iter().map(|t| (t - mean_t).powi(2)).sum();
-(numerator / denominator) / (2.0 * omega1)
}
/// `C = αM + βK` must be assembled term by term.
///
/// Mass-proportional damping gives `ζ₁ = α/(2ω₁)` and stiffness-proportional
/// damping gives `ζ₁ = βω₁/2`. Both are targeted at the same `ζ₁ = 0.02`, so
/// swapping the coefficients — which a single combined test would not catch —
/// misses by a factor of `ω₁² ≈ 6·10⁷`.
#[test]
fn rayleigh_damping_reproduces_the_modal_damping_ratios() {
let f1 = modal_fundamental(24, 2);
let omega1 = 2.0 * std::f64::consts::PI * f1;
let period = 1.0 / f1;
let target = 0.02;
let alpha = 2.0 * target * omega1;
let mass_zeta = measured_zeta(alpha, 0.0, omega1, period);
println!(
"mass-proportional alpha = {alpha:.4}: target zeta {target}, \
measured {mass_zeta:.5} ({:.2}% off)",
(mass_zeta - target).abs() / target * 100.0
);
assert!(
(mass_zeta - target).abs() / target < 0.05,
"mass-proportional damping gave zeta = {mass_zeta:.5}, expected {target}"
);
let beta = 2.0 * target / omega1;
let stiffness_zeta = measured_zeta(0.0, beta, omega1, period);
println!(
"stiffness-proportional beta = {beta:.6e}: target zeta {target}, \
measured {stiffness_zeta:.5} ({:.2}% off)",
(stiffness_zeta - target).abs() / target * 100.0
);
assert!(
(stiffness_zeta - target).abs() / target < 0.05,
"stiffness-proportional damping gave zeta = {stiffness_zeta:.5}, expected {target}"
);
}
/// A free-free bar given a uniform initial velocity must translate rigidly.
///
/// Rigid translation stores no strain energy. This is the assertion a zero or
/// transposed assembly fails while still looking symmetric and finite: any
/// spurious coupling turns the translation into vibration and puts energy
/// into `½uᵀKu`.
#[test]
fn rigid_translation_of_a_free_bar_stores_no_strain_energy() {
let (nx, ny) = (8, 2);
let (mesh, grid) = bar_mesh(nx, ny);
let speed = 3.0;
let velocities: Vec<_> = grid
.iter()
.flatten()
.map(|&node| (node, DofComponent::DisplacementX, speed))
.collect();
let dt = 1e-5;
let num_steps = 100;
let time_config = TimeConfig {
start_time: 0.0,
end_time: num_steps as f64 * dt,
time_step: dt,
integration_scheme: TimeIntegrationScheme::NewmarkAverage,
};
let mut analysis = DynamicAnalysis::new(
mesh,
steel_no_poisson(),
BoundaryConditionSet::new(),
time_config,
AnalysisConfig::default(),
)
.with_initial_velocities(velocities);
let tip = analysis
.dof_index(grid[nx][0], DofComponent::DisplacementX)
.unwrap()
.expect("tip DOF");
let results = analysis.run().expect("dynamic analysis failed");
let times = series(&results, "time");
let displacement = history_row(&results, "displacement_history", tip);
let strain = series(&results, "strain_energy");
let kinetic = series(&results, "kinetic_energy");
let worst_position = times
.iter()
.zip(&displacement)
.map(|(t, u)| (u - speed * t).abs())
.fold(0.0, f64::max);
// The comparator is the strain energy a *stretch* reaching the same tip
// displacement would store, `½(EA/L)u²` with unit thickness. Comparing
// against the kinetic energy instead is the wrong yardstick: the residual
// here is the floating-point cancellation floor of the quadratic form
// `½uᵀKu` itself, which scales with `‖K‖‖u‖²` — that is, with the stretch
// energy — and not with `½vᵀMv`. Measured against `‖K‖‖u‖²` the residual
// is round-off; measured against the kinetic energy it looks like `1e-12`
// for no physical reason.
let axial_stiffness = E * HEIGHT / LENGTH;
let worst_ratio = times
.iter()
.zip(&strain)
.skip(1)
.map(|(t, s)| s.abs() / (0.5 * axial_stiffness * (speed * t).powi(2)))
.fold(0.0, f64::max);
println!(
"rigid translation: worst position error {worst_position:.3e} m, \
peak strain energy {:.3e} J against the equivalent stretch energy \
{:.3e} J (ratio {worst_ratio:.3e}), kinetic energy {:.6e} J",
strain.iter().fold(0.0f64, |a, b| a.max(b.abs())),
0.5 * axial_stiffness * (speed * times[times.len() - 1]).powi(2),
kinetic[0]
);
// Analytic kinetic energy: half the total mass times v squared. The
// plane-stress model has unit thickness.
let exact_kinetic = 0.5 * RHO * LENGTH * HEIGHT * speed * speed;
assert!(
(kinetic[0] - exact_kinetic).abs() / exact_kinetic < 1e-10,
"kinetic energy {:.6e} J is not the analytic {exact_kinetic:.6e} J; \
the consistent mass matrix does not integrate to the bar's mass",
kinetic[0]
);
assert!(
worst_position < 1e-12 * speed * times[times.len() - 1],
"rigid translation drifted by {worst_position:.3e} m"
);
// Round-off in `½uᵀKu` sits near `ε‖K‖‖u‖²/(½(EA/L)u²) ≈ 1e-13`. Any real
// spurious coupling — a transposed assembly, a mis-scattered mass — turns
// the translation into vibration and lands orders of magnitude above this.
assert!(
worst_ratio < 1e-10,
"rigid translation stored strain energy at {worst_ratio:.3e} of the \
energy an equivalent stretch would store; that is far above the \
round-off floor of the quadratic form"
);
}
/// A configuration that produces no steps is an error, not an empty result.
#[test]
fn a_degenerate_time_configuration_is_rejected() {
let (mesh, grid) = bar_mesh(4, 1);
let time_config = TimeConfig {
start_time: 0.0,
end_time: 0.0,
time_step: 1e-5,
integration_scheme: TimeIntegrationScheme::NewmarkAverage,
};
let mut analysis = DynamicAnalysis::new(
mesh,
steel_no_poisson(),
axial_bar_constraints(&grid),
time_config,
AnalysisConfig::default(),
);
let error = analysis
.run()
.expect_err("a zero-length time window must not yield a response");
assert!(
error.to_string().to_lowercase().contains("no steps"),
"error should name the empty time window, got: {error}"
);
}