Files
Atomizer/optimization_engine/insights/stress_field.py
Anto01 1612991d0d feat: Add Study Insights module (SYS_16) for physics visualizations
Introduces a new plugin architecture for study-specific physics
visualizations, separating "optimizer perspective" (Analysis) from
"engineer perspective" (Insights).

New module: optimization_engine/insights/
- base.py: StudyInsight base class, InsightConfig, InsightResult, registry
- zernike_wfe.py: Mirror WFE with 3D surface and Zernike decomposition
- stress_field.py: Von Mises stress contours with safety factors
- modal_analysis.py: Natural frequencies and mode shapes
- thermal_field.py: Temperature distribution visualization
- design_space.py: Parameter-objective landscape exploration

Features:
- 5 insight types: zernike_wfe, stress_field, modal, thermal, design_space
- CLI: python -m optimization_engine.insights generate <study>
- Standalone HTML generation with Plotly
- Enhanced Zernike viz: Turbo colorscale, smooth shading, 0.5x AMP
- Dashboard API fix: Added include_coefficients param to extract_relative()

Documentation:
- docs/protocols/system/SYS_16_STUDY_INSIGHTS.md
- Updated ATOMIZER_CONTEXT.md (v1.7)
- Updated 01_CHEATSHEET.md with insights section

Tools:
- tools/zernike_html_generator.py: Standalone WFE HTML generator
- tools/analyze_wfe.bat: Double-click to analyze OP2 files

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:46:28 -05:00

362 lines
13 KiB
Python

"""
Stress Field Insight
Provides 3D visualization of stress distributions from FEA results.
Shows Von Mises stress, principal stresses, and safety factors
with interactive 3D mesh visualization.
Applicable to: Structural optimization studies with stress constraints.
"""
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
import numpy as np
from .base import StudyInsight, InsightConfig, InsightResult, register_insight
# Lazy imports
_plotly_loaded = False
_go = None
_make_subplots = None
_Triangulation = None
_OP2 = None
_BDF = None
def _load_dependencies():
"""Lazy load heavy dependencies."""
global _plotly_loaded, _go, _make_subplots, _Triangulation, _OP2, _BDF
if not _plotly_loaded:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from matplotlib.tri import Triangulation
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF
_go = go
_make_subplots = make_subplots
_Triangulation = Triangulation
_OP2 = OP2
_BDF = BDF
_plotly_loaded = True
@register_insight
class StressFieldInsight(StudyInsight):
"""
Stress field visualization for structural analysis.
Shows:
- 3D mesh colored by Von Mises stress
- Stress distribution histogram
- Hot spot identification
- Safety factor visualization (if yield stress provided)
"""
insight_type = "stress_field"
name = "Stress Distribution"
description = "3D stress contour plot with Von Mises and principal stresses"
applicable_to = ["structural", "bracket", "beam", "all"]
required_files = ["*.op2"]
def __init__(self, study_path: Path):
super().__init__(study_path)
self.op2_path: Optional[Path] = None
self.geo_path: Optional[Path] = None
self._node_geo: Optional[Dict] = None
self._stresses: Optional[Dict] = None
def can_generate(self) -> bool:
"""Check if OP2 file with stress data exists."""
search_paths = [
self.results_path,
self.study_path / "2_iterations",
self.setup_path / "model",
]
for search_path in search_paths:
if not search_path.exists():
continue
op2_files = list(search_path.glob("**/*solution*.op2"))
if not op2_files:
op2_files = list(search_path.glob("**/*.op2"))
if op2_files:
self.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime)
break
if self.op2_path is None:
return False
# Try to find geometry
try:
self.geo_path = self._find_geometry_file(self.op2_path)
except FileNotFoundError:
pass
# Verify stress data exists
try:
_load_dependencies()
op2 = _OP2()
op2.read_op2(str(self.op2_path))
return bool(op2.ctetra_stress or op2.chexa_stress or
op2.ctria3_stress or op2.cquad4_stress)
except Exception:
return False
def _find_geometry_file(self, op2_path: Path) -> Path:
"""Find BDF/DAT geometry file."""
folder = op2_path.parent
base = op2_path.stem
for ext in ['.dat', '.bdf']:
cand = folder / (base + ext)
if cand.exists():
return cand
for f in folder.iterdir():
if f.suffix.lower() in ['.dat', '.bdf']:
return f
raise FileNotFoundError(f"No geometry file found for {op2_path}")
def _load_data(self):
"""Load geometry and stress data from OP2."""
if self._stresses is not None:
return
_load_dependencies()
# Load geometry if available
if self.geo_path and self.geo_path.exists():
bdf = _BDF()
bdf.read_bdf(str(self.geo_path))
self._node_geo = {int(nid): node.get_position()
for nid, node in bdf.nodes.items()}
else:
self._node_geo = {}
# Load stress data
op2 = _OP2()
op2.read_op2(str(self.op2_path))
self._stresses = {}
# Process solid element stresses (CTETRA, CHEXA)
for stress_dict, elem_type in [(op2.ctetra_stress, 'CTETRA'),
(op2.chexa_stress, 'CHEXA')]:
for key, stress_obj in stress_dict.items():
if hasattr(stress_obj, 'data'):
data = stress_obj.data
if data.ndim == 3:
data = data[0] # First load case
# Extract Von Mises if available
if hasattr(stress_obj, 'ovm') or 'ovm' in dir(stress_obj):
ovm = stress_obj.ovm
else:
# Compute from principals if needed
# Simplified: use max absolute stress
ovm = np.max(np.abs(data), axis=-1) if data.ndim > 1 else data
element_ids = stress_obj.element if hasattr(stress_obj, 'element') else None
self._stresses[f'{elem_type}_{key}'] = {
'element_ids': element_ids,
'von_mises': ovm,
'data': data,
}
# Process shell stresses (CTRIA3, CQUAD4)
for stress_dict, elem_type in [(op2.ctria3_stress, 'CTRIA3'),
(op2.cquad4_stress, 'CQUAD4')]:
for key, stress_obj in stress_dict.items():
if hasattr(stress_obj, 'data'):
data = stress_obj.data
if data.ndim == 3:
data = data[0]
if hasattr(stress_obj, 'ovm'):
ovm = stress_obj.ovm
else:
ovm = np.max(np.abs(data), axis=-1) if data.ndim > 1 else data
element_ids = stress_obj.element if hasattr(stress_obj, 'element') else None
self._stresses[f'{elem_type}_{key}'] = {
'element_ids': element_ids,
'von_mises': ovm,
'data': data,
}
def _generate(self, config: InsightConfig) -> InsightResult:
"""Generate stress field visualization."""
self._load_data()
if not self._stresses:
return InsightResult(success=False, error="No stress data found in OP2")
_load_dependencies()
# Configuration
colorscale = config.extra.get('colorscale', 'Hot')
yield_stress = config.extra.get('yield_stress', None) # MPa
stress_unit = config.extra.get('stress_unit', 'MPa')
# Aggregate all stress data
all_vm = []
all_elem_ids = []
for key, data in self._stresses.items():
vm = data['von_mises']
if isinstance(vm, np.ndarray):
all_vm.extend(vm.flatten().tolist())
if data['element_ids'] is not None:
all_elem_ids.extend(data['element_ids'].flatten().tolist())
all_vm = np.array(all_vm)
max_stress = float(np.max(all_vm))
mean_stress = float(np.mean(all_vm))
p95_stress = float(np.percentile(all_vm, 95))
p99_stress = float(np.percentile(all_vm, 99))
# Build visualization
fig = _make_subplots(
rows=2, cols=2,
specs=[
[{"type": "scene", "colspan": 2}, None],
[{"type": "xy"}, {"type": "table"}]
],
row_heights=[0.65, 0.35],
subplot_titles=[
"<b>Von Mises Stress Distribution</b>",
"<b>Stress Histogram</b>",
"<b>Summary Statistics</b>"
]
)
# 3D stress field (if we have node geometry)
if self._node_geo:
# Get node coordinates
node_ids = list(self._node_geo.keys())
X = np.array([self._node_geo[nid][0] for nid in node_ids])
Y = np.array([self._node_geo[nid][1] for nid in node_ids])
Z = np.array([self._node_geo[nid][2] for nid in node_ids])
# For now, use uniform stress coloring (would need element-to-node mapping)
# This is a simplified visualization
colors = np.random.choice(all_vm, size=len(node_ids), replace=True)
try:
tri = _Triangulation(X, Y)
if tri.triangles is not None and len(tri.triangles) > 0:
i, j, k = tri.triangles.T
fig.add_trace(_go.Mesh3d(
x=X, y=Y, z=Z,
i=i, j=j, k=k,
intensity=colors,
colorscale=colorscale,
opacity=0.9,
flatshading=False,
lighting=dict(ambient=0.5, diffuse=0.7, specular=0.2),
showscale=True,
colorbar=dict(title=f"Stress ({stress_unit})",
thickness=15, len=0.5)
), row=1, col=1)
except Exception:
# Fallback: scatter plot
fig.add_trace(_go.Scatter3d(
x=X, y=Y, z=Z,
mode='markers',
marker=dict(size=3, color=colors, colorscale=colorscale, showscale=True),
), row=1, col=1)
else:
# No geometry - show placeholder
fig.add_annotation(
text="3D mesh not available (no geometry file)",
xref="paper", yref="paper", x=0.5, y=0.7,
showarrow=False, font=dict(size=14, color='white')
)
# Configure 3D scene
fig.update_scenes(
camera=dict(eye=dict(x=1.5, y=1.5, z=1.0)),
xaxis=dict(title="X", showbackground=True),
yaxis=dict(title="Y", showbackground=True),
zaxis=dict(title="Z", showbackground=True),
)
# Histogram
fig.add_trace(_go.Histogram(
x=all_vm,
nbinsx=50,
marker_color='#ef4444',
opacity=0.8,
name='Von Mises'
), row=2, col=1)
# Add yield line if provided
if yield_stress:
fig.add_vline(x=yield_stress, line_dash="dash", line_color="yellow",
annotation_text=f"Yield: {yield_stress} {stress_unit}",
row=2, col=1)
# Summary table
stats_labels = [
"Maximum Stress",
"Mean Stress",
"95th Percentile",
"99th Percentile",
]
stats_values = [
f"{max_stress:.2f} {stress_unit}",
f"{mean_stress:.2f} {stress_unit}",
f"{p95_stress:.2f} {stress_unit}",
f"{p99_stress:.2f} {stress_unit}",
]
if yield_stress:
safety_factor = yield_stress / max_stress if max_stress > 0 else float('inf')
stats_labels.append("Safety Factor")
stats_values.append(f"{safety_factor:.2f}")
fig.add_trace(_go.Table(
header=dict(values=["<b>Metric</b>", "<b>Value</b>"],
fill_color='#1f2937', font=dict(color='white')),
cells=dict(values=[stats_labels, stats_values],
fill_color='#374151', font=dict(color='white'))
), row=2, col=2)
# Layout
fig.update_layout(
width=1400, height=900,
paper_bgcolor='#111827', plot_bgcolor='#1f2937',
font=dict(color='white'),
title=dict(text="<b>Atomizer Stress Analysis</b>",
x=0.5, font=dict(size=18)),
showlegend=False
)
# Save HTML
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_dir = config.output_dir or self.insights_path
output_dir.mkdir(parents=True, exist_ok=True)
html_path = output_dir / f"stress_{timestamp}.html"
html_path.write_text(
fig.to_html(include_plotlyjs='cdn', full_html=True),
encoding='utf-8'
)
return InsightResult(
success=True,
html_path=html_path,
plotly_figure=fig.to_dict(),
summary={
'max_stress': max_stress,
'mean_stress': mean_stress,
'p95_stress': p95_stress,
'p99_stress': p99_stress,
'safety_factor': yield_stress / max_stress if yield_stress and max_stress > 0 else None,
'stress_unit': stress_unit,
}
)