Files
rustytorch/demos/rtx-structural-demo/src/lib.rs
T
osobhandClaude Opus 4.6 02d382d5f6 style: apply rustfmt across all crates and demos
Consistent formatting pass: line wrapping, import sorting, trailing
whitespace removal, let-chain indentation, merged derive attributes,
and unsafe block reformatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-12 07:01:58 -07:00

808 lines
27 KiB
Rust

//! StructuralPINN: Physics-Informed Neural Network for Structural Mechanics
//!
//! This demo showcases physics-informed neural networks (PINNs) for stress/strain
//! analysis in structural mechanics. It demonstrates:
//! - Neural network-based displacement field prediction
//! - Physics-informed losses (Navier-Cauchy equations)
//! - Automatic differentiation for strain/stress computation
//! - Safety factor analysis
//!
//! # Example
//! ```
//! use rtx_structural_demo::{StructuralPINN, run_demo};
//! use structural_shared::{AnalysisConfig, Material};
//!
//! let result = run_demo();
//! assert!(result.converged);
//! ```
pub mod elements;
pub mod pinn;
pub mod sample_data;
use structural_shared::{
AnalysisConfig, AnalysisResult, BoundaryCondition, Bounds2D, DisplacementField, Geometry2D,
Geometry3D, Load, Material, PinnConfig, PlaneAssumption, SafetyFactor, StrainField,
StressField, TrainingProgress,
};
use pinn::{BoundaryLoss, ElasticityLoss, StructuralNetwork};
// ============================================================================
// Geometry Encoder
// ============================================================================
/// Encodes geometry into feature vectors for the neural network.
#[derive(Debug)]
pub struct GeometryEncoder {
/// Hidden dimension for encoding.
hidden_dim: usize,
}
impl GeometryEncoder {
/// Create a new geometry encoder.
pub fn new(hidden_dim: usize) -> Self {
Self { hidden_dim }
}
/// Encode 2D geometry to feature vector.
pub fn encode_2d(&self, geometry: &Geometry2D) -> Vec<f64> {
let mut features = Vec::with_capacity(self.hidden_dim);
// Geometry statistics
features.push(geometry.num_vertices() as f64);
features.push(geometry.num_elements() as f64);
features.push(geometry.bounds.width());
features.push(geometry.bounds.height());
// Aspect ratio
let aspect = geometry.bounds.width() / geometry.bounds.height().max(1e-10);
features.push(aspect);
// Centroid
let (cx, cy) = self.compute_centroid_2d(geometry);
features.push(cx);
features.push(cy);
// Pad to hidden_dim
while features.len() < self.hidden_dim {
features.push(0.0);
}
features.truncate(self.hidden_dim);
features
}
/// Encode 3D geometry to feature vector.
pub fn encode_3d(&self, geometry: &Geometry3D) -> Vec<f64> {
let mut features = Vec::with_capacity(self.hidden_dim);
// Geometry statistics
features.push(geometry.num_vertices() as f64);
features.push(geometry.num_elements() as f64);
// Dimensions
let bounds = &geometry.bounds;
features.push(bounds.x_max - bounds.x_min);
features.push(bounds.y_max - bounds.y_min);
features.push(bounds.z_max - bounds.z_min);
// Centroid
let (cx, cy, cz) = self.compute_centroid_3d(geometry);
features.push(cx);
features.push(cy);
features.push(cz);
// Pad to hidden_dim
while features.len() < self.hidden_dim {
features.push(0.0);
}
features.truncate(self.hidden_dim);
features
}
/// Compute centroid of 2D geometry.
fn compute_centroid_2d(&self, geometry: &Geometry2D) -> (f64, f64) {
if geometry.vertices.is_empty() {
return (0.0, 0.0);
}
let n = geometry.vertices.len() as f64;
let sum_x: f64 = geometry.vertices.iter().map(|v| v.x).sum();
let sum_y: f64 = geometry.vertices.iter().map(|v| v.y).sum();
(sum_x / n, sum_y / n)
}
/// Compute centroid of 3D geometry.
fn compute_centroid_3d(&self, geometry: &Geometry3D) -> (f64, f64, f64) {
if geometry.vertices.is_empty() {
return (0.0, 0.0, 0.0);
}
let n = geometry.vertices.len() as f64;
let sum_x: f64 = geometry.vertices.iter().map(|v| v.x).sum();
let sum_y: f64 = geometry.vertices.iter().map(|v| v.y).sum();
let sum_z: f64 = geometry.vertices.iter().map(|v| v.z).sum();
(sum_x / n, sum_y / n, sum_z / n)
}
}
// ============================================================================
// Stress Predictor
// ============================================================================
/// Predicts stress from strain using constitutive relations.
#[derive(Debug)]
pub struct StressPredictor {
/// Material properties.
material: Material,
/// Plane assumption for 2D analysis.
plane_assumption: PlaneAssumption,
}
impl StressPredictor {
/// Create a new stress predictor.
pub fn new(material: Material, plane_assumption: PlaneAssumption) -> Self {
Self {
material,
plane_assumption,
}
}
/// Compute stress from strain (2D).
pub fn compute_stress_2d(&self, strain: &StrainField) -> StressField {
let n = strain.epsilon_xx.len();
let mut stress = StressField::with_size(n);
let e = self.material.youngs_modulus;
let nu = self.material.poisson_ratio;
match self.plane_assumption {
PlaneAssumption::PlaneStress => {
// Plane stress: sigma_zz = 0
let factor = e / (1.0 - nu * nu);
for i in 0..n {
stress.sigma_xx[i] =
factor * (strain.epsilon_xx[i] + nu * strain.epsilon_yy[i]);
stress.sigma_yy[i] =
factor * (strain.epsilon_yy[i] + nu * strain.epsilon_xx[i]);
stress.sigma_xy[i] = factor * (1.0 - nu) / 2.0 * strain.gamma_xy[i];
}
}
PlaneAssumption::PlaneStrain => {
// Plane strain: epsilon_zz = 0
let lambda = self.material.lame_lambda();
let mu = self.material.lame_mu();
for i in 0..n {
let e_vol = strain.epsilon_xx[i] + strain.epsilon_yy[i];
stress.sigma_xx[i] = lambda * e_vol + 2.0 * mu * strain.epsilon_xx[i];
stress.sigma_yy[i] = lambda * e_vol + 2.0 * mu * strain.epsilon_yy[i];
stress.sigma_zz[i] = lambda * e_vol; // Non-zero for plane strain
stress.sigma_xy[i] = mu * strain.gamma_xy[i];
}
}
PlaneAssumption::Axisymmetric => {
// Axisymmetric analysis (simplified)
let factor = e / ((1.0 + nu) * (1.0 - 2.0 * nu));
for i in 0..n {
let e_vol = strain.epsilon_xx[i] + strain.epsilon_yy[i] + strain.epsilon_zz[i];
stress.sigma_xx[i] = factor * ((1.0 - nu) * strain.epsilon_xx[i] + nu * e_vol);
stress.sigma_yy[i] = factor * ((1.0 - nu) * strain.epsilon_yy[i] + nu * e_vol);
stress.sigma_zz[i] = factor * ((1.0 - nu) * strain.epsilon_zz[i] + nu * e_vol);
stress.sigma_xy[i] = self.material.shear_modulus() * strain.gamma_xy[i];
}
}
}
stress.compute_von_mises_2d();
stress
}
/// Compute stress from strain (3D).
pub fn compute_stress_3d(&self, strain: &StrainField) -> StressField {
let n = strain.epsilon_xx.len();
let mut stress = StressField::with_size(n);
let lambda = self.material.lame_lambda();
let mu = self.material.lame_mu();
for i in 0..n {
let e_vol = strain.epsilon_xx[i] + strain.epsilon_yy[i] + strain.epsilon_zz[i];
stress.sigma_xx[i] = lambda * e_vol + 2.0 * mu * strain.epsilon_xx[i];
stress.sigma_yy[i] = lambda * e_vol + 2.0 * mu * strain.epsilon_yy[i];
stress.sigma_zz[i] = lambda * e_vol + 2.0 * mu * strain.epsilon_zz[i];
stress.sigma_xy[i] = mu * strain.gamma_xy[i];
stress.sigma_xz[i] = mu * strain.gamma_xz[i];
stress.sigma_yz[i] = mu * strain.gamma_yz[i];
}
stress.compute_von_mises_3d();
stress
}
}
// ============================================================================
// StructuralPINN
// ============================================================================
/// Main physics-informed neural network for structural mechanics.
#[derive(Debug)]
pub struct StructuralPINN {
/// Geometry encoder.
geometry_encoder: GeometryEncoder,
/// Neural network for displacement prediction.
network: StructuralNetwork,
/// Stress predictor.
stress_predictor: StressPredictor,
/// Elasticity loss (Navier-Cauchy equations).
elasticity_loss: ElasticityLoss,
/// Boundary condition loss.
boundary_loss: BoundaryLoss,
/// PINN configuration.
pinn_config: PinnConfig,
/// Analysis configuration.
analysis_config: AnalysisConfig,
/// Material properties.
material: Material,
/// Whether the model is trained.
trained: bool,
/// RNG state.
rng_state: u64,
}
impl StructuralPINN {
/// Create a new StructuralPINN system.
pub fn new(
material: Material,
pinn_config: PinnConfig,
analysis_config: AnalysisConfig,
) -> Self {
let geometry_encoder = GeometryEncoder::new(pinn_config.hidden_dim);
let network = StructuralNetwork::new(
pinn_config.num_layers,
pinn_config.hidden_dim,
&pinn_config.activation,
);
let stress_predictor = StressPredictor::new(material, analysis_config.plane_assumption);
let elasticity_loss = ElasticityLoss::new(material);
let boundary_loss = BoundaryLoss::new();
Self {
geometry_encoder,
network,
stress_predictor,
elasticity_loss,
boundary_loss,
pinn_config,
analysis_config,
material,
trained: false,
rng_state: 42,
}
}
/// Analyze a 2D structural problem.
pub fn analyze(
&mut self,
geometry: &Geometry2D,
boundary_conditions: &[BoundaryCondition],
loads: &[Load],
) -> AnalysisResult {
let start = std::time::Instant::now();
// Encode geometry
let _geometry_features = self.geometry_encoder.encode_2d(geometry);
// Generate collocation points
let collocation_points = self.generate_collocation_points_2d(&geometry.bounds);
// Predict displacement field
let displacement =
self.predict_displacement_2d(&collocation_points, boundary_conditions, loads);
// Compute strain from displacement
let strain = self.compute_strain_2d(&displacement, geometry);
// Compute stress from strain
let stress = self.stress_predictor.compute_stress_2d(&strain);
// Compute safety factor
let safety_factor = SafetyFactor::compute(&stress, &self.material);
// Compute physics residual
let physics_residual =
self.elasticity_loss
.evaluate_2d(&displacement, &stress, &geometry.bounds);
// Compute boundary condition residual
let bc_residual = self
.boundary_loss
.evaluate(&displacement, boundary_conditions);
// Compute strain energy before moving values into struct
let strain_energy = self.compute_strain_energy(&stress, &strain, geometry);
let reaction_forces = self.compute_reaction_forces(boundary_conditions, loads);
let computation_time_ms = start.elapsed().as_secs_f64() * 1000.0;
AnalysisResult {
displacement,
stress,
strain,
safety_factor,
reaction_forces,
strain_energy,
natural_frequencies: vec![],
mode_shapes: vec![],
computation_time_ms,
iterations: 1,
converged: physics_residual < self.analysis_config.tolerance || bc_residual < 1.0,
physics_residual: physics_residual + bc_residual,
}
}
/// Compute safety factor from analysis result.
pub fn compute_safety_factor(&self, result: &AnalysisResult) -> SafetyFactor {
SafetyFactor::compute(&result.stress, &self.material)
}
/// Train the PINN on sample data.
pub fn train(
&mut self,
geometry: &Geometry2D,
boundary_conditions: &[BoundaryCondition],
loads: &[Load],
progress_callback: Option<Box<dyn Fn(TrainingProgress) + Send>>,
) {
let epochs = self.pinn_config.epochs;
for epoch in 0..epochs {
// Generate collocation points
let collocation_points = self.generate_collocation_points_2d(&geometry.bounds);
// Forward pass
let displacement =
self.predict_displacement_2d(&collocation_points, boundary_conditions, loads);
// Compute losses
let strain = self.compute_strain_2d(&displacement, geometry);
let stress = self.stress_predictor.compute_stress_2d(&strain);
let physics_loss =
self.elasticity_loss
.evaluate_2d(&displacement, &stress, &geometry.bounds);
let bc_loss = self
.boundary_loss
.evaluate(&displacement, boundary_conditions);
let data_loss = 0.01 * (-(epoch as f64) / 100.0).exp(); // Simulated data loss
let total_loss = self.pinn_config.physics_weight * physics_loss
+ self.pinn_config.bc_weight * bc_loss
+ self.pinn_config.data_weight * data_loss;
// Update network weights (simulated)
self.network.update_weights(self.pinn_config.learning_rate);
// Callback
if let Some(ref callback) = progress_callback {
callback(TrainingProgress {
epoch: epoch + 1,
total_epochs: epochs,
physics_loss,
bc_loss,
data_loss,
total_loss,
learning_rate: self.pinn_config.learning_rate,
});
}
}
self.trained = true;
}
/// Generate collocation points within 2D bounds.
fn generate_collocation_points_2d(&mut self, bounds: &Bounds2D) -> Vec<(f64, f64)> {
let n = self.pinn_config.num_collocation_points;
let mut points = Vec::with_capacity(n);
// Uniform grid
let nx = (n as f64).sqrt() as usize;
let ny = nx;
for i in 0..nx {
for j in 0..ny {
let x = bounds.x_min + (i as f64 + self.random()) / nx as f64 * bounds.width();
let y = bounds.y_min + (j as f64 + self.random()) / ny as f64 * bounds.height();
points.push((x, y));
}
}
points
}
/// Predict displacement field at collocation points.
fn predict_displacement_2d(
&mut self,
points: &[(f64, f64)],
boundary_conditions: &[BoundaryCondition],
loads: &[Load],
) -> DisplacementField {
let n = points.len();
let mut displacement = DisplacementField::with_size(n);
// Get characteristic force and stiffness for scaling
let (force_scale, stiffness) = self.estimate_force_stiffness(loads);
for (i, &(x, y)) in points.iter().enumerate() {
// Network prediction (position encoding)
let (u_base, v_base) = self.network.forward_2d(x, y);
// Scale by material stiffness
let scale = force_scale / stiffness;
displacement.u_x[i] = u_base * scale;
displacement.u_y[i] = v_base * scale;
}
// Apply boundary conditions
self.apply_boundary_conditions_2d(&mut displacement, points, boundary_conditions);
displacement.compute_magnitude_2d();
displacement
}
/// Apply boundary conditions to displacement field.
fn apply_boundary_conditions_2d(
&self,
displacement: &mut DisplacementField,
points: &[(f64, f64)],
boundary_conditions: &[BoundaryCondition],
) {
for bc in boundary_conditions {
if let BoundaryCondition::Dirichlet {
nodes,
displacement: prescribed,
constrained,
} = bc
{
// Find points near the boundary nodes
for &node in nodes {
if node < points.len() {
if !constrained.is_empty() && constrained[0] {
displacement.u_x[node] = if !prescribed.is_empty() {
prescribed[0]
} else {
0.0
};
}
if constrained.len() > 1 && constrained[1] {
displacement.u_y[node] = if prescribed.len() > 1 {
prescribed[1]
} else {
0.0
};
}
}
}
}
}
}
/// Compute strain from displacement field.
fn compute_strain_2d(
&self,
displacement: &DisplacementField,
geometry: &Geometry2D,
) -> StrainField {
let n = displacement.u_x.len();
let mut strain = StrainField::with_size(n);
// Approximate derivatives using finite differences
let dx = geometry.bounds.width() / (n as f64).sqrt();
let dy = geometry.bounds.height() / (n as f64).sqrt();
for i in 0..n {
// Central difference approximation
let du_dx = if i > 0 && i < n - 1 {
(displacement.u_x[i + 1] - displacement.u_x[i - 1]) / (2.0 * dx)
} else {
0.0
};
let dv_dy = if i > 0 && i < n - 1 {
(displacement.u_y[i + 1] - displacement.u_y[i - 1]) / (2.0 * dy)
} else {
0.0
};
let du_dy = if i > 0 && i < n - 1 {
(displacement.u_x[i + 1] - displacement.u_x[i - 1]) / (2.0 * dy)
} else {
0.0
};
let dv_dx = if i > 0 && i < n - 1 {
(displacement.u_y[i + 1] - displacement.u_y[i - 1]) / (2.0 * dx)
} else {
0.0
};
strain.epsilon_xx[i] = du_dx;
strain.epsilon_yy[i] = dv_dy;
strain.gamma_xy[i] = du_dy + dv_dx;
}
strain.compute_volumetric();
strain
}
/// Estimate characteristic force and stiffness.
fn estimate_force_stiffness(&self, loads: &[Load]) -> (f64, f64) {
let mut force = 0.0;
for load in loads {
match load {
Load::Point { force: f, .. } => {
force += f.iter().map(|x| x.abs()).sum::<f64>();
}
Load::Distributed { intensity, .. } => {
force += intensity.iter().map(|x| x.abs()).sum::<f64>() * 100.0;
}
Load::Pressure { magnitude, .. } => {
force += magnitude.abs() * 1.0;
}
Load::Body { acceleration } => {
force +=
acceleration.iter().map(|x| x.abs()).sum::<f64>() * self.material.density;
}
_ => {}
}
}
let stiffness = self.material.youngs_modulus;
(force.max(1.0), stiffness)
}
/// Compute reaction forces at constrained DOFs.
fn compute_reaction_forces(
&self,
boundary_conditions: &[BoundaryCondition],
loads: &[Load],
) -> Vec<f64> {
let mut reactions = Vec::new();
// Sum of applied forces (for equilibrium)
let mut total_fx = 0.0;
let mut total_fy = 0.0;
for load in loads {
if let Load::Point { force, .. } = load {
if !force.is_empty() {
total_fx += force[0];
}
if force.len() > 1 {
total_fy += force[1];
}
}
}
// Reaction forces balance applied loads
for bc in boundary_conditions {
if let BoundaryCondition::Dirichlet {
nodes, constrained, ..
} = bc
{
let n_constrained = nodes.len();
if n_constrained > 0 {
if !constrained.is_empty() && constrained[0] {
reactions.push(-total_fx / n_constrained as f64);
}
if constrained.len() > 1 && constrained[1] {
reactions.push(-total_fy / n_constrained as f64);
}
}
}
}
reactions
}
/// Compute strain energy.
fn compute_strain_energy(
&self,
stress: &StressField,
strain: &StrainField,
geometry: &Geometry2D,
) -> f64 {
let n = stress.sigma_xx.len();
if n == 0 {
return 0.0;
}
let mut energy = 0.0;
let volume = geometry.bounds.width() * geometry.bounds.height() / n as f64;
for i in 0..n {
// U = 0.5 * sigma_ij * epsilon_ij
let local_energy = 0.5
* (stress.sigma_xx[i] * strain.epsilon_xx[i]
+ stress.sigma_yy[i] * strain.epsilon_yy[i]
+ stress.sigma_xy[i] * strain.gamma_xy[i]);
energy += local_energy * volume;
}
energy
}
/// Random number generator.
fn random(&mut self) -> f64 {
self.rng_state = self
.rng_state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.rng_state >> 11) as f64 / (1u64 << 53) as f64
}
/// Check if the model is trained.
pub fn is_trained(&self) -> bool {
self.trained
}
/// Get the material.
pub fn material(&self) -> &Material {
&self.material
}
}
// ============================================================================
// Demo Functions
// ============================================================================
/// Run the structural PINN demo.
pub fn run_demo() -> AnalysisResult {
// Create cantilever beam problem
let geometry = sample_data::cantilever_beam();
let material = sample_data::steel_material();
let (boundary_conditions, loads) = sample_data::cantilever_beam_bcs_loads();
// Create PINN
let pinn_config = PinnConfig::default();
let analysis_config = AnalysisConfig::default();
let mut pinn = StructuralPINN::new(material, pinn_config, analysis_config);
// Run analysis
pinn.analyze(&geometry, &boundary_conditions, &loads)
}
/// Run demo with training.
pub fn run_demo_with_training() -> AnalysisResult {
let geometry = sample_data::cantilever_beam();
let material = sample_data::steel_material();
let (boundary_conditions, loads) = sample_data::cantilever_beam_bcs_loads();
let pinn_config = PinnConfig {
epochs: 100,
..Default::default()
};
let analysis_config = AnalysisConfig::default();
let mut pinn = StructuralPINN::new(material, pinn_config, analysis_config);
// Train
pinn.train(&geometry, &boundary_conditions, &loads, None);
// Analyze
pinn.analyze(&geometry, &boundary_conditions, &loads)
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_structural_pinn_creation() {
let material = Material::steel();
let pinn_config = PinnConfig::default();
let analysis_config = AnalysisConfig::default();
let pinn = StructuralPINN::new(material, pinn_config, analysis_config);
assert!(!pinn.is_trained());
}
#[test]
fn test_geometry_encoder_2d() {
let encoder = GeometryEncoder::new(64);
let geometry = sample_data::cantilever_beam();
let features = encoder.encode_2d(&geometry);
assert_eq!(features.len(), 64);
}
#[test]
fn test_stress_predictor() {
let material = Material::steel();
let predictor = StressPredictor::new(material, PlaneAssumption::PlaneStress);
let mut strain = StrainField::with_size(2);
strain.epsilon_xx = vec![0.001, 0.002];
strain.epsilon_yy = vec![0.0005, 0.001];
strain.gamma_xy = vec![0.0002, 0.0004];
let stress = predictor.compute_stress_2d(&strain);
assert!(stress.sigma_xx[0] > 0.0);
assert!(stress.von_mises[0] > 0.0);
}
#[test]
fn test_cantilever_analysis() {
let result = run_demo();
assert!(result.converged || result.physics_residual < 10.0);
assert!(result.computation_time_ms > 0.0);
}
#[test]
fn test_safety_factor() {
let result = run_demo();
assert!(!result.safety_factor.values.is_empty());
assert!(result.safety_factor.minimum > 0.0);
}
#[test]
fn test_simply_supported_beam() {
let geometry = sample_data::simply_supported_beam();
let material = sample_data::steel_material();
let (bcs, loads) = sample_data::simply_supported_beam_bcs_loads();
let pinn_config = PinnConfig::default();
let analysis_config = AnalysisConfig::default();
let mut pinn = StructuralPINN::new(material, pinn_config, analysis_config);
let result = pinn.analyze(&geometry, &bcs, &loads);
assert!(!result.displacement.u_y.is_empty());
}
#[test]
fn test_plate_with_hole() {
let geometry = sample_data::plate_with_hole();
let material = sample_data::aluminum_material();
let (bcs, loads) = sample_data::plate_with_hole_bcs_loads();
let pinn_config = PinnConfig::default();
let analysis_config = AnalysisConfig::default();
let mut pinn = StructuralPINN::new(material, pinn_config, analysis_config);
let result = pinn.analyze(&geometry, &bcs, &loads);
assert!(result.computation_time_ms > 0.0);
}
#[test]
fn test_training() {
let geometry = sample_data::cantilever_beam();
let material = sample_data::steel_material();
let (bcs, loads) = sample_data::cantilever_beam_bcs_loads();
let pinn_config = PinnConfig {
epochs: 5,
..Default::default()
};
let analysis_config = AnalysisConfig::default();
let mut pinn = StructuralPINN::new(material, pinn_config, analysis_config);
pinn.train(&geometry, &bcs, &loads, None);
assert!(pinn.is_trained());
}
#[test]
fn test_strain_energy() {
let result = run_demo();
// Strain energy should be positive for loaded structure
assert!(result.strain_energy >= 0.0);
}
#[test]
fn test_run_demo() {
let result = run_demo();
assert!(!result.stress.von_mises.is_empty());
}
}