Files
rustytorch/crates/specialized/rtx-fea/src/analysis/dynamic_analysis.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

725 lines
26 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.
// Copyright (c) 2024 RustyTorch++ Team
// Licensed under the Apache License, Version 2.0
//! Transient (time-domain) finite element analysis.
//!
//! Integrates `M ü + C u̇ + K u = F(t)` with the Newmark-β family, using
//! Rayleigh damping `C = α M + β K`.
//!
//! This previously returned `DVector::zeros(num_dofs)` and had no way to
//! accept boundary conditions at all, so it could not have worked even in
//! principle. Everything it needs already existed —
//! [`crate::assembly::GlobalAssembler`] produces `K` and `M`, and
//! [`crate::assembly::AdvancedDofNumbering`] applies the constraints — the
//! pieces were simply never connected, exactly as in
//! [`crate::analysis::modal_analysis`].
//!
//! # Which Newmark form is used, and why it matters
//!
//! The scheme is written in **acceleration form**:
//!
//! ```text
//! ũ = u_n + Δt v_n + Δt²(½ - β) a_n
//! ṽ = v_n + Δt(1 - γ) a_n
//! (M + γ Δt C + β Δt² K) a_{n+1} = F_{n+1} - C ṽ - K ũ
//! u_{n+1} = ũ + β Δt² a_{n+1}
//! v_{n+1} = ṽ + γ Δt a_{n+1}
//! ```
//!
//! The algebraically equivalent *displacement* form divides through by
//! `β Δt²`, which is singular for `β = 0` and badly scaled for small `β`. The
//! acceleration form degrades gracefully all the way to the explicit central
//! difference scheme (`β = 0`, `γ = ½`), and its effective matrix
//! `M + γΔt C + βΔt² K` is symmetric positive definite whenever `M` is, which
//! is what lets it be factorised once and reused for every step.
//!
//! With `γ = ½, β = ¼` (average acceleration) the resulting one-step map is
//! exactly orthogonal in the energy inner product, so
//! `E = ½ v·Mv + ½ u·Ku` is conserved to round-off for an undamped system.
//! That is an invariant no plausible-but-wrong implementation satisfies, and
//! it is asserted in `tests/dynamic_closed_form.rs`.
use super::{
Analysis, AnalysisConfig, AnalysisData, AnalysisResults, AnalysisTiming, TimeConfig,
TimeIntegrationScheme,
};
use crate::assembly::{
AdvancedDofNumbering, DofComponent, DofMappingStrategy, GlobalAssembler, SparseMatrix,
};
use crate::boundary::{BoundaryCondition, BoundaryConditionSet};
use crate::error::{AnalysisError, FeaResult};
use crate::materials::MaterialDatabase;
use crate::mesh::{Mesh, NodeId};
use nalgebra::{Cholesky, DMatrix, DVector, Dyn};
use std::time::Instant;
/// Largest free-DOF count this integrator will accept.
///
/// The effective matrix is factorised densely, which costs `n²` doubles. This
/// is an honest limitation rather than a tuning knob: refusing loudly at 3000
/// DOFs (72 MB) is better than silently allocating gigabytes.
const MAX_DENSE_DOFS: usize = 3000;
/// State of a Newmark march at one instant.
#[derive(Debug, Clone, PartialEq)]
pub struct NewmarkState {
/// Displacement `u`.
pub displacement: DVector<f64>,
/// Velocity `u̇`.
pub velocity: DVector<f64>,
/// Acceleration `ü`.
pub acceleration: DVector<f64>,
}
/// A pre-factorised Newmark-β time stepper for a constant linear system.
///
/// Exposed so the scheme can be validated directly against the single-degree-
/// of-freedom closed form without dragging a mesh through the test.
#[derive(Debug)]
pub struct NewmarkStepper {
gamma: f64,
beta: f64,
dt: f64,
mass: DMatrix<f64>,
damping: DMatrix<f64>,
stiffness: DMatrix<f64>,
/// Factorisation of `M + γΔt C + βΔt² K`, reused for every step.
effective: Cholesky<f64, Dyn>,
/// Factorisation of `M`, used only for the initial acceleration.
mass_factor: Cholesky<f64, Dyn>,
}
impl NewmarkStepper {
/// Build a stepper for `M ü + C u̇ + K u = F`.
///
/// Fails rather than returning plausible nonsense when the matrices are
/// not square and equally sized, when `Δt <= 0`, when the Newmark
/// parameters are outside the range where the scheme is defined, or when
/// `M` or the effective matrix is not positive definite.
pub fn new(
mass: DMatrix<f64>,
damping: DMatrix<f64>,
stiffness: DMatrix<f64>,
gamma: f64,
beta: f64,
dt: f64,
) -> FeaResult<Self> {
let n = mass.nrows();
for (name, m) in [
("mass", &mass),
("damping", &damping),
("stiffness", &stiffness),
] {
if m.nrows() != m.ncols() || m.nrows() != n {
return Err(AnalysisError::InvalidConfiguration(format!(
"{name} matrix is {}x{}; expected {n}x{n}",
m.nrows(),
m.ncols()
))
.into());
}
}
if n == 0 {
return Err(AnalysisError::InvalidConfiguration(
"cannot integrate a system with no degrees of freedom".to_string(),
)
.into());
}
if !(dt.is_finite() && dt > 0.0) {
return Err(AnalysisError::InvalidConfiguration(format!(
"time step must be finite and positive, got {dt}"
))
.into());
}
if !(0.0..=1.0).contains(&gamma) || !(0.0..=0.5).contains(&beta) {
return Err(AnalysisError::InvalidConfiguration(format!(
"Newmark parameters out of range: gamma={gamma} (need 0..=1), \
beta={beta} (need 0..=0.5)"
))
.into());
}
let effective_matrix = &mass + gamma * dt * &damping + beta * dt * dt * &stiffness;
let effective = Cholesky::new(effective_matrix).ok_or_else(|| {
AnalysisError::InvalidConfiguration(
"effective matrix M + gamma*dt*C + beta*dt^2*K is not positive definite; \
the mass matrix is singular or the damping coefficients are negative"
.to_string(),
)
})?;
let mass_factor = Cholesky::new(mass.clone()).ok_or_else(|| {
AnalysisError::InvalidConfiguration(
"mass matrix is not positive definite; the initial acceleration \
M a0 = F - C v0 - K u0 cannot be formed"
.to_string(),
)
})?;
Ok(Self {
gamma,
beta,
dt,
mass,
damping,
stiffness,
effective,
mass_factor,
})
}
/// Average-acceleration (trapezoidal) scheme: `γ = ½, β = ¼`.
///
/// Unconditionally stable, second-order accurate, and energy conserving.
pub fn average_acceleration(
mass: DMatrix<f64>,
damping: DMatrix<f64>,
stiffness: DMatrix<f64>,
dt: f64,
) -> FeaResult<Self> {
Self::new(mass, damping, stiffness, 0.5, 0.25, dt)
}
/// Newmark `γ`.
pub fn gamma(&self) -> f64 {
self.gamma
}
/// Newmark `β`.
pub fn beta(&self) -> f64 {
self.beta
}
/// Time step.
pub fn dt(&self) -> f64 {
self.dt
}
/// Number of degrees of freedom.
pub fn num_dofs(&self) -> usize {
self.mass.nrows()
}
/// Consistent initial state: `a0` solves `M a0 = F0 - C v0 - K u0`.
///
/// Assuming `a0 = 0` instead is a classic silent error — it makes the
/// first step wrong by `O(Δt²)` and quietly destroys the second-order
/// convergence rate.
pub fn initial_state(
&self,
displacement: DVector<f64>,
velocity: DVector<f64>,
force: &DVector<f64>,
) -> FeaResult<NewmarkState> {
self.check_len("initial displacement", displacement.len())?;
self.check_len("initial velocity", velocity.len())?;
self.check_len("initial force", force.len())?;
let rhs = force - &self.damping * &velocity - &self.stiffness * &displacement;
let acceleration = self.mass_factor.solve(&rhs);
Ok(NewmarkState {
displacement,
velocity,
acceleration,
})
}
/// Advance one step to the state at `t + Δt`, given the force there.
pub fn step(&self, state: &NewmarkState, force_next: &DVector<f64>) -> FeaResult<NewmarkState> {
self.check_len("force", force_next.len())?;
let dt = self.dt;
let predicted_displacement = &state.displacement
+ dt * &state.velocity
+ (0.5 - self.beta) * dt * dt * &state.acceleration;
let predicted_velocity = &state.velocity + (1.0 - self.gamma) * dt * &state.acceleration;
let rhs = force_next
- &self.damping * &predicted_velocity
- &self.stiffness * &predicted_displacement;
let acceleration = self.effective.solve(&rhs);
Ok(NewmarkState {
displacement: predicted_displacement + (self.beta * dt * dt) * &acceleration,
velocity: predicted_velocity + (self.gamma * dt) * &acceleration,
acceleration,
})
}
/// Kinetic energy `½ v·Mv`.
pub fn kinetic_energy(&self, velocity: &DVector<f64>) -> f64 {
0.5 * velocity.dot(&(&self.mass * velocity))
}
/// Strain energy `½ u·Ku`.
pub fn strain_energy(&self, displacement: &DVector<f64>) -> f64 {
0.5 * displacement.dot(&(&self.stiffness * displacement))
}
/// Total mechanical energy, conserved exactly by `γ = ½, β = ¼` when
/// `C = 0`.
pub fn total_energy(&self, state: &NewmarkState) -> f64 {
self.kinetic_energy(&state.velocity) + self.strain_energy(&state.displacement)
}
fn check_len(&self, what: &str, len: usize) -> FeaResult<()> {
if len == self.num_dofs() {
return Ok(());
}
Err(AnalysisError::InvalidConfiguration(format!(
"{what} has {len} entries; the system has {} degrees of freedom",
self.num_dofs()
))
.into())
}
}
/// Transient dynamic finite element analysis.
#[derive(Debug)]
pub struct DynamicAnalysis {
mesh: Mesh,
materials: MaterialDatabase,
boundary_conditions: BoundaryConditionSet,
time_config: TimeConfig,
config: AnalysisConfig,
/// Mass-proportional Rayleigh coefficient `α` in `C = αM + βK`.
rayleigh_alpha: f64,
/// Stiffness-proportional Rayleigh coefficient `β` in `C = αM + βK`.
rayleigh_beta: f64,
/// Explicit Newmark `(γ, β)`, overriding the time configuration's scheme.
newmark: Option<(f64, f64)>,
initial_displacements: Vec<(NodeId, DofComponent, f64)>,
initial_velocities: Vec<(NodeId, DofComponent, f64)>,
progress: f64,
complete: bool,
}
impl DynamicAnalysis {
pub fn new(
mesh: Mesh,
materials: MaterialDatabase,
boundary_conditions: BoundaryConditionSet,
time_config: TimeConfig,
config: AnalysisConfig,
) -> Self {
Self {
mesh,
materials,
boundary_conditions,
time_config,
config,
rayleigh_alpha: 0.0,
rayleigh_beta: 0.0,
newmark: None,
initial_displacements: Vec::new(),
initial_velocities: Vec::new(),
progress: 0.0,
complete: false,
}
}
/// Replace the boundary conditions.
///
/// Mirrors [`crate::analysis::ModalAnalysis::with_boundary_conditions`] so
/// the two analyses can be handed the same constraint set.
pub fn with_boundary_conditions(mut self, boundary_conditions: BoundaryConditionSet) -> Self {
self.boundary_conditions = boundary_conditions;
self
}
/// Rayleigh damping `C = alpha*M + beta*K`.
///
/// Mode `n` then carries the damping ratio
/// `ζ_n = α/(2 ω_n) + β ω_n/2`, which is how a target ratio is dialled in
/// and how the assembled `C` is checked in the tests.
pub fn with_rayleigh_damping(mut self, alpha: f64, beta: f64) -> Self {
self.rayleigh_alpha = alpha;
self.rayleigh_beta = beta;
self
}
/// Mass-proportional Rayleigh coefficient.
pub fn rayleigh_alpha(&self) -> f64 {
self.rayleigh_alpha
}
/// Stiffness-proportional Rayleigh coefficient.
pub fn rayleigh_beta(&self) -> f64 {
self.rayleigh_beta
}
/// Override the Newmark parameters.
///
/// The default comes from the time configuration's integration scheme;
/// `NewmarkAverage` gives `γ = ½, β = ¼`.
pub fn with_newmark_parameters(mut self, gamma: f64, beta: f64) -> Self {
self.newmark = Some((gamma, beta));
self
}
/// The `(γ, β)` pair that will be used.
pub fn newmark_parameters(&self) -> (f64, f64) {
self.newmark
.unwrap_or(match self.time_config.integration_scheme {
TimeIntegrationScheme::NewmarkAverage => (0.5, 0.25),
TimeIntegrationScheme::NewmarkLinear => (0.5, 1.0 / 6.0),
// Explicit central difference is the beta = 0 member of the
// family; the acceleration form handles it without dividing by
// beta, though it is only conditionally stable.
TimeIntegrationScheme::CentralDifference => (0.5, 0.0),
// Newmark's first-order, unconditionally stable, dissipative
// member, equivalent to backward Euler on the first-order form.
TimeIntegrationScheme::BackwardEuler => (1.0, 0.5),
})
}
/// Set the time step, keeping the number of steps unchanged.
pub fn with_time_step(mut self, time_step: f64) -> Self {
let steps = self.num_steps();
self.time_config.time_step = time_step;
self.time_config.end_time = self.time_config.start_time + steps as f64 * time_step;
self
}
/// Set the number of steps, keeping the time step unchanged.
pub fn with_num_steps(mut self, num_steps: usize) -> Self {
self.time_config.end_time =
self.time_config.start_time + num_steps as f64 * self.time_config.time_step;
self
}
/// Time step.
pub fn time_step(&self) -> f64 {
self.time_config.time_step
}
/// Number of steps the march will take.
pub fn num_steps(&self) -> usize {
let dt = self.time_config.time_step;
if !(dt.is_finite() && dt > 0.0) {
return 0;
}
let span = self.time_config.end_time - self.time_config.start_time;
if !span.is_finite() || span <= 0.0 {
return 0;
}
(span / dt).round().max(0.0) as usize
}
/// Prescribe an initial displacement at individual nodal components.
///
/// Entries at constrained degrees of freedom are ignored, since those
/// DOFs are not part of the integrated system.
pub fn with_initial_displacements(
mut self,
values: impl IntoIterator<Item = (NodeId, DofComponent, f64)>,
) -> Self {
self.initial_displacements = values.into_iter().collect();
self
}
/// Prescribe an initial velocity at individual nodal components.
pub fn with_initial_velocities(
mut self,
values: impl IntoIterator<Item = (NodeId, DofComponent, f64)>,
) -> Self {
self.initial_velocities = values.into_iter().collect();
self
}
/// Global DOF index for a nodal component, matching the numbering the
/// response history is written in.
///
/// Deterministic, so it can be called before or after [`Self::run`].
pub fn dof_index(&self, node: NodeId, component: DofComponent) -> FeaResult<Option<usize>> {
Ok(self.base_dof_numbering()?.get_dof(node, component))
}
fn base_dof_numbering(&self) -> FeaResult<AdvancedDofNumbering> {
AdvancedDofNumbering::displacement_only(&self.mesh, DofMappingStrategy::BandwidthOptimized)
}
/// Number the degrees of freedom and apply the Dirichlet constraints.
fn setup_dof_numbering(&self) -> FeaResult<AdvancedDofNumbering> {
let mut dof_numbering = self.base_dof_numbering()?;
for bc in self.boundary_conditions.conditions() {
if let BoundaryCondition::Dirichlet(dirichlet) = bc {
for &node_id in &dirichlet.nodes {
for &component in &dirichlet.components {
if let Some(dof) = dof_numbering.get_dof(node_id, component) {
dof_numbering.constrain_dof(dof)?;
}
}
}
}
}
Ok(dof_numbering)
}
/// Nodal load vector over the free DOFs at one instant.
///
/// Only `Neumann` conditions contribute. `assembled` is the constant load
/// the element assembly produced (body forces), added at every step.
fn force_at(
&self,
time: f64,
dof_numbering: &AdvancedDofNumbering,
free_dofs: &[usize],
assembled: &DVector<f64>,
) -> FeaResult<DVector<f64>> {
let mut full = assembled.clone();
for bc in self.boundary_conditions.conditions() {
let BoundaryCondition::Neumann(neumann) = bc else {
continue;
};
if !neumann.is_active(time) {
continue;
}
for &node_id in &neumann.nodes {
let Some(node) = self.mesh.get_node(node_id) else {
continue;
};
let value = neumann.get_force(time, &node.position());
for &component in &neumann.components {
if let Some(dof) = dof_numbering.get_dof(node_id, component) {
full[dof] += value;
}
}
}
}
Ok(DVector::from_iterator(
free_dofs.len(),
free_dofs.iter().map(|&d| full[d]),
))
}
/// Scatter nodal initial conditions into a free-DOF vector.
fn initial_vector(
&self,
entries: &[(NodeId, DofComponent, f64)],
dof_numbering: &AdvancedDofNumbering,
free_index: &std::collections::HashMap<usize, usize>,
) -> DVector<f64> {
let mut vector = DVector::zeros(free_index.len());
for &(node, component, value) in entries {
if let Some(dof) = dof_numbering.get_dof(node, component)
&& let Some(&i) = free_index.get(&dof)
{
vector[i] = value;
}
}
vector
}
fn dense_free(matrix: &SparseMatrix, free_dofs: &[usize]) -> FeaResult<DMatrix<f64>> {
Ok(matrix.extract_submatrix(free_dofs, free_dofs)?.to_dense())
}
}
impl Analysis for DynamicAnalysis {
fn run(&mut self) -> FeaResult<AnalysisResults> {
let total_start = Instant::now();
let mut timing = AnalysisTiming::default();
if self.mesh.num_elements() == 0 {
return Err(AnalysisError::InvalidConfiguration(
"mesh contains no elements".to_string(),
)
.into());
}
let num_steps = self.num_steps();
if num_steps == 0 {
return Err(AnalysisError::InvalidConfiguration(format!(
"the time configuration produces no steps: start={}, end={}, dt={}",
self.time_config.start_time, self.time_config.end_time, self.time_config.time_step
))
.into());
}
let dt = self.time_config.time_step;
let (gamma, beta) = self.newmark_parameters();
let mesh_start = Instant::now();
let dof_numbering = self.setup_dof_numbering()?;
timing.mesh_time = mesh_start.elapsed();
self.progress = 0.1;
let assembly_start = Instant::now();
let assembler =
GlobalAssembler::new(self.config.assembly_options.clone(), self.materials.clone());
let system =
assembler.assemble_system(&self.mesh, &dof_numbering.to_dof_numbering(), 0.0)?;
timing.assembly_time = assembly_start.elapsed();
self.progress = 0.3;
let mass = system.mass_matrix.as_ref().ok_or_else(|| {
AnalysisError::InvalidConfiguration(
"assembly produced no mass matrix; transient dynamics needs one".to_string(),
)
})?;
let free_dofs = system.dof_numbering.free_dofs.clone();
if free_dofs.is_empty() {
return Err(AnalysisError::InvalidConfiguration(
"every degree of freedom is constrained; there is nothing to move".to_string(),
)
.into());
}
if free_dofs.len() > MAX_DENSE_DOFS {
return Err(AnalysisError::InvalidConfiguration(format!(
"transient dynamics factorises the effective matrix densely and is \
limited to {MAX_DENSE_DOFS} free degrees of freedom; this model has {}",
free_dofs.len()
))
.into());
}
let free_mass = Self::dense_free(mass, &free_dofs)?;
let free_stiffness = Self::dense_free(&system.stiffness_matrix, &free_dofs)?;
let free_damping = self.rayleigh_alpha * &free_mass + self.rayleigh_beta * &free_stiffness;
let stepper =
NewmarkStepper::new(free_mass, free_damping, free_stiffness, gamma, beta, dt)?;
self.progress = 0.4;
let free_index: std::collections::HashMap<usize, usize> =
free_dofs.iter().enumerate().map(|(i, &d)| (d, i)).collect();
let u0 = self.initial_vector(&self.initial_displacements, &dof_numbering, &free_index);
let v0 = self.initial_vector(&self.initial_velocities, &dof_numbering, &free_index);
let solver_start = Instant::now();
let t0 = self.time_config.start_time;
let f0 = self.force_at(t0, &dof_numbering, &free_dofs, &system.force_vector)?;
let mut state = stepper.initial_state(u0, v0, &f0)?;
let total_dofs = system.dof_numbering.total_dofs;
let columns = num_steps + 1;
let mut displacement_history = DMatrix::zeros(total_dofs, columns);
let mut velocity_history = DMatrix::zeros(total_dofs, columns);
let mut acceleration_history = DMatrix::zeros(total_dofs, columns);
let mut times = Vec::with_capacity(columns);
let mut kinetic = Vec::with_capacity(columns);
let mut strain = Vec::with_capacity(columns);
let mut total = Vec::with_capacity(columns);
let mut record = |column: usize,
time: f64,
state: &NewmarkState,
displacement_history: &mut DMatrix<f64>,
velocity_history: &mut DMatrix<f64>,
acceleration_history: &mut DMatrix<f64>| {
for (i, &global_dof) in free_dofs.iter().enumerate() {
displacement_history[(global_dof, column)] = state.displacement[i];
velocity_history[(global_dof, column)] = state.velocity[i];
acceleration_history[(global_dof, column)] = state.acceleration[i];
}
times.push(time);
let k = stepper.kinetic_energy(&state.velocity);
let s = stepper.strain_energy(&state.displacement);
kinetic.push(k);
strain.push(s);
total.push(k + s);
};
record(
0,
t0,
&state,
&mut displacement_history,
&mut velocity_history,
&mut acceleration_history,
);
for step in 1..=num_steps {
let time = t0 + step as f64 * dt;
let force = self.force_at(time, &dof_numbering, &free_dofs, &system.force_vector)?;
state = stepper.step(&state, &force)?;
record(
step,
time,
&state,
&mut displacement_history,
&mut velocity_history,
&mut acceleration_history,
);
self.progress = 0.4 + 0.55 * (step as f64 / num_steps as f64);
}
timing.solver_time = solver_start.elapsed();
let solution = DVector::from_iterator(
total_dofs,
(0..total_dofs).map(|d| displacement_history[(d, num_steps)]),
);
let mut results = AnalysisResults::new("Dynamic".to_string(), solution);
results.add_data("time".to_string(), AnalysisData::TimeSeries(times));
results.add_data(
"displacement_history".to_string(),
AnalysisData::Matrix(displacement_history),
);
results.add_data(
"velocity_history".to_string(),
AnalysisData::Matrix(velocity_history),
);
results.add_data(
"acceleration_history".to_string(),
AnalysisData::Matrix(acceleration_history),
);
results.add_data(
"kinetic_energy".to_string(),
AnalysisData::TimeSeries(kinetic),
);
results.add_data(
"strain_energy".to_string(),
AnalysisData::TimeSeries(strain),
);
results.add_data("total_energy".to_string(), AnalysisData::TimeSeries(total));
results.add_data("time_step".to_string(), AnalysisData::Scalar(dt));
results.add_data(
"num_steps".to_string(),
AnalysisData::Scalar(num_steps as f64),
);
results.add_data("newmark_gamma".to_string(), AnalysisData::Scalar(gamma));
results.add_data("newmark_beta".to_string(), AnalysisData::Scalar(beta));
results.add_data(
"rayleigh_alpha".to_string(),
AnalysisData::Scalar(self.rayleigh_alpha),
);
results.add_data(
"rayleigh_beta".to_string(),
AnalysisData::Scalar(self.rayleigh_beta),
);
timing.total_time = total_start.elapsed();
results.timing = timing;
self.progress = 1.0;
self.complete = true;
Ok(results)
}
fn analysis_type(&self) -> &'static str {
"Dynamic"
}
fn set_solver_options(&mut self, options: crate::solvers::SolverOptions) {
self.config.solver_options = options;
}
fn set_assembly_options(&mut self, options: crate::assembly::AssemblyOptions) {
self.config.assembly_options = options;
}
fn progress(&self) -> f64 {
self.progress
}
fn is_complete(&self) -> bool {
self.complete
}
}