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
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]>
725 lines
26 KiB
Rust
725 lines
26 KiB
Rust
// 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
|
||
}
|
||
}
|