Files
Atomizer/atomizer-field/tests/test_physics.py
Antoine d5ffba099e feat: Merge Atomizer-Field neural network module into main repository
Permanently integrates the Atomizer-Field GNN surrogate system:
- neural_models/: Graph Neural Network for FEA field prediction
- batch_parser.py: Parse training data from FEA exports
- train.py: Neural network training pipeline
- predict.py: Inference engine for fast predictions

This enables 600x-2200x speedup over traditional FEA by replacing
expensive simulations with millisecond neural network predictions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 15:31:33 -05:00

386 lines
11 KiB
Python
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.
"""
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.")