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
|
|
|
"""
|
|
|
|
|
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"
|
2025-12-23 19:47:37 -05:00
|
|
|
category = "structural_static"
|
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
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
)
|