""" test_physics.py Physics validation tests with analytical solutions Tests that the neural network respects fundamental physics: - Cantilever beam (δ = FL³/3EI) - Simply supported beam (δ = FL³/48EI) - Equilibrium (∇·σ + f = 0) - Energy conservation (strain energy = work done) """ import torch import numpy as np import sys from pathlib import Path # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) from neural_models.field_predictor import create_model from torch_geometric.data import Data def create_cantilever_beam_graph(length=1.0, force=1000.0, E=210e9, I=1e-6): """ Create synthetic cantilever beam graph with analytical solution Analytical solution: δ_max = FL³/3EI Args: length: Beam length (m) force: Applied force (N) E: Young's modulus (Pa) I: Second moment of area (m^4) Returns: graph_data: PyG Data object analytical_displacement: Expected max displacement (m) """ # Calculate analytical solution analytical_displacement = (force * length**3) / (3 * E * I) # Create simple beam mesh (10 nodes along length) num_nodes = 10 x_coords = np.linspace(0, length, num_nodes) # Node features: [x, y, z, bc_x, bc_y, bc_z, bc_rx, bc_ry, bc_rz, load_x, load_y, load_z] node_features = np.zeros((num_nodes, 12)) node_features[:, 0] = x_coords # x coordinates # Boundary conditions at x=0 (fixed end) node_features[0, 3:9] = 1.0 # All DOF constrained # Applied force at x=length (free end) node_features[-1, 10] = force # Force in y direction # Create edges (connect adjacent nodes) edge_index = [] for i in range(num_nodes - 1): edge_index.append([i, i+1]) edge_index.append([i+1, i]) edge_index = torch.tensor(edge_index, dtype=torch.long).t() # Edge features: [E, nu, rho, G, alpha] num_edges = edge_index.shape[1] edge_features = np.zeros((num_edges, 5)) edge_features[:, 0] = E / 1e11 # Normalized Young's modulus edge_features[:, 1] = 0.3 # Poisson's ratio edge_features[:, 2] = 7850 / 10000 # Normalized density edge_features[:, 3] = E / (2 * (1 + 0.3)) / 1e11 # Normalized shear modulus edge_features[:, 4] = 1.2e-5 # Thermal expansion # Convert to tensors x = torch.tensor(node_features, dtype=torch.float32) edge_attr = torch.tensor(edge_features, dtype=torch.float32) batch = torch.zeros(num_nodes, dtype=torch.long) data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, batch=batch) return data, analytical_displacement def test_cantilever_analytical(): """ Test 1: Cantilever beam with analytical solution Expected: Neural prediction within 5% of δ = FL³/3EI Note: This test uses an untrained model, so it will fail until the model is trained on cantilever beam data. This test serves as a template for post-training validation. """ print(" Creating cantilever beam test case...") # Create test case graph_data, analytical_disp = create_cantilever_beam_graph( length=1.0, force=1000.0, E=210e9, I=1e-6 ) print(f" Analytical max displacement: {analytical_disp*1000:.6f} mm") # Create model (untrained) config = { 'node_feature_dim': 12, 'edge_feature_dim': 5, 'hidden_dim': 64, 'num_layers': 4, 'dropout': 0.1 } model = create_model(config) model.eval() # Make prediction print(" Running neural prediction...") with torch.no_grad(): results = model(graph_data, return_stress=False) # Extract max displacement (y-direction at free end) predicted_disp = torch.max(torch.abs(results['displacement'][:, 1])).item() print(f" Predicted max displacement: {predicted_disp:.6f} (arbitrary units)") # Calculate error (will be large for untrained model) # After training, this should be < 5% error = abs(predicted_disp - analytical_disp) / analytical_disp * 100 print(f" Error: {error:.1f}%") print(f" Note: Model is untrained. After training, expect < 5% error.") # For now, just check that prediction completed success = results['displacement'].shape[0] == graph_data.x.shape[0] return { 'status': 'PASS' if success else 'FAIL', 'message': f'Cantilever test completed (untrained model)', 'metrics': { 'analytical_displacement_mm': float(analytical_disp * 1000), 'predicted_displacement': float(predicted_disp), 'error_percent': float(error), 'trained': False } } def test_equilibrium(): """ Test 2: Force equilibrium check Expected: ∇·σ + f = 0 (force balance) Checks that predicted stress field satisfies equilibrium. For trained model, equilibrium residual should be < 1e-6. """ print(" Testing equilibrium constraint...") # Create simple test case num_nodes = 20 num_edges = 40 x = torch.randn(num_nodes, 12) edge_index = torch.randint(0, num_nodes, (2, num_edges)) edge_attr = torch.randn(num_edges, 5) batch = torch.zeros(num_nodes, dtype=torch.long) data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, batch=batch) # Create model config = { 'node_feature_dim': 12, 'edge_feature_dim': 5, 'hidden_dim': 64, 'num_layers': 4, 'dropout': 0.1 } model = create_model(config) model.eval() # Make prediction with torch.no_grad(): results = model(data, return_stress=True) # Compute equilibrium residual (simplified check) # In real implementation, would compute ∇·σ numerically stress = results['stress'] stress_gradient_norm = torch.mean(torch.abs(stress)).item() print(f" Stress field magnitude: {stress_gradient_norm:.6f}") print(f" Note: Full equilibrium check requires mesh connectivity.") print(f" After training with physics loss, residual should be < 1e-6.") return { 'status': 'PASS', 'message': 'Equilibrium check completed', 'metrics': { 'stress_magnitude': float(stress_gradient_norm), 'trained': False } } def test_energy_conservation(): """ Test 3: Energy conservation Expected: Strain energy = Work done by external forces U = (1/2)∫ σ:ε dV = ∫ f·u dS """ print(" Testing energy conservation...") # Create test case with known loading num_nodes = 30 num_edges = 60 x = torch.randn(num_nodes, 12) # Add known external force x[:, 9:12] = 0.0 # Clear loads x[0, 10] = 1000.0 # 1000 N in y direction at node 0 edge_index = torch.randint(0, num_nodes, (2, num_edges)) edge_attr = torch.randn(num_edges, 5) batch = torch.zeros(num_nodes, dtype=torch.long) data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, batch=batch) # Create model config = { 'node_feature_dim': 12, 'edge_feature_dim': 5, 'hidden_dim': 64, 'num_layers': 4, 'dropout': 0.1 } model = create_model(config) model.eval() # Make prediction with torch.no_grad(): results = model(data, return_stress=True) # Compute external work (simplified) displacement = results['displacement'] force = x[:, 9:12] external_work = torch.sum(force * displacement[:, :3]).item() # Compute strain energy (simplified: U ≈ (1/2) σ:ε) stress = results['stress'] # For small deformations: ε ≈ ∇u, approximate with displacement gradient strain_energy = 0.5 * torch.sum(stress * displacement).item() print(f" External work: {external_work:.6f}") print(f" Strain energy: {strain_energy:.6f}") print(f" Note: Simplified calculation. Full energy check requires") print(f" proper strain computation from displacement gradients.") return { 'status': 'PASS', 'message': 'Energy conservation check completed', 'metrics': { 'external_work': float(external_work), 'strain_energy': float(strain_energy), 'trained': False } } def test_constitutive_law(): """ Test 4: Constitutive law (Hooke's law) Expected: σ = C:ε (stress proportional to strain) For linear elastic materials: σ = E·ε for 1D """ print(" Testing constitutive law...") # Create simple uniaxial test case num_nodes = 10 # Simple bar under tension x = torch.zeros(num_nodes, 12) x[:, 0] = torch.linspace(0, 1, num_nodes) # x coordinates # Fixed at x=0 x[0, 3:9] = 1.0 # Force at x=1 x[-1, 9] = 1000.0 # Axial force # Create edges edge_index = [] for i in range(num_nodes - 1): edge_index.append([i, i+1]) edge_index.append([i+1, i]) edge_index = torch.tensor(edge_index, dtype=torch.long).t() # Material properties E = 210e9 # Young's modulus edge_attr = torch.zeros(edge_index.shape[1], 5) edge_attr[:, 0] = E / 1e11 # Normalized edge_attr[:, 1] = 0.3 # Poisson's ratio batch = torch.zeros(num_nodes, dtype=torch.long) data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, batch=batch) # Create model config = { 'node_feature_dim': 12, 'edge_feature_dim': 5, 'hidden_dim': 64, 'num_layers': 4, 'dropout': 0.1 } model = create_model(config) model.eval() # Make prediction with torch.no_grad(): results = model(data, return_stress=True) # Check stress-strain relationship displacement = results['displacement'] stress = results['stress'] # For trained model with physics loss, stress should follow σ = E·ε print(f" Displacement range: {displacement[:, 0].min():.6f} to {displacement[:, 0].max():.6f}") print(f" Stress range: {stress[:, 0].min():.6f} to {stress[:, 0].max():.6f}") print(f" Note: After training with constitutive loss, stress should") print(f" be proportional to strain (σ = E·ε).") return { 'status': 'PASS', 'message': 'Constitutive law check completed', 'metrics': { 'displacement_range': [float(displacement[:, 0].min()), float(displacement[:, 0].max())], 'stress_range': [float(stress[:, 0].min()), float(stress[:, 0].max())], 'trained': False } } if __name__ == "__main__": print("\nRunning physics validation tests...\n") tests = [ ("Cantilever Analytical", test_cantilever_analytical), ("Equilibrium Check", test_equilibrium), ("Energy Conservation", test_energy_conservation), ("Constitutive Law", test_constitutive_law) ] passed = 0 failed = 0 for name, test_func in tests: print(f"[TEST] {name}") try: result = test_func() if result['status'] == 'PASS': print(f" ✓ PASS\n") passed += 1 else: print(f" ✗ FAIL: {result['message']}\n") failed += 1 except Exception as e: print(f" ✗ FAIL: {str(e)}\n") import traceback traceback.print_exc() failed += 1 print(f"\nResults: {passed} passed, {failed} failed") print(f"\nNote: These tests use an UNTRAINED model.") print(f"After training with physics-informed losses, all tests should pass") print(f"with errors < 5% for analytical solutions.")