Files
Atomizer/atomizer-field/tests/test_physics.py

386 lines
11 KiB
Python
Raw Normal View History

"""
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.")