// 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, /// Velocity `u̇`. pub velocity: DVector, /// Acceleration `ü`. pub acceleration: DVector, } /// 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, damping: DMatrix, stiffness: DMatrix, /// Factorisation of `M + γΔt C + βΔt² K`, reused for every step. effective: Cholesky, /// Factorisation of `M`, used only for the initial acceleration. mass_factor: Cholesky, } 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, damping: DMatrix, stiffness: DMatrix, gamma: f64, beta: f64, dt: f64, ) -> FeaResult { 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, damping: DMatrix, stiffness: DMatrix, dt: f64, ) -> FeaResult { 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, velocity: DVector, force: &DVector, ) -> FeaResult { 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) -> FeaResult { 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 { 0.5 * velocity.dot(&(&self.mass * velocity)) } /// Strain energy `½ u·Ku`. pub fn strain_energy(&self, displacement: &DVector) -> 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, ) -> 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, ) -> 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> { Ok(self.base_dof_numbering()?.get_dof(node, component)) } fn base_dof_numbering(&self) -> FeaResult { AdvancedDofNumbering::displacement_only(&self.mesh, DofMappingStrategy::BandwidthOptimized) } /// Number the degrees of freedom and apply the Dirichlet constraints. fn setup_dof_numbering(&self) -> FeaResult { 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, ) -> FeaResult> { 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, ) -> DVector { 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> { Ok(matrix.extract_submatrix(free_dofs, free_dofs)?.to_dense()) } } impl Analysis for DynamicAnalysis { fn run(&mut self) -> FeaResult { 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 = 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, velocity_history: &mut DMatrix, acceleration_history: &mut DMatrix| { 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 } }