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>
This commit is contained in:
347
optimization_engine/insights/modal_analysis.py
Normal file
347
optimization_engine/insights/modal_analysis.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Modal Analysis Insight
|
||||
|
||||
Provides visualization of natural frequencies and mode shapes from FEA results.
|
||||
Shows animated mode shapes, frequency spectrum, and modal participation factors.
|
||||
|
||||
Applicable to: Dynamic/vibration optimization studies.
|
||||
"""
|
||||
|
||||
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 ModalInsight(StudyInsight):
|
||||
"""
|
||||
Modal analysis visualization.
|
||||
|
||||
Shows:
|
||||
- Natural frequency spectrum (bar chart)
|
||||
- Mode shape visualization (3D deformed mesh)
|
||||
- Mode description table
|
||||
- Frequency vs mode number plot
|
||||
"""
|
||||
|
||||
insight_type = "modal"
|
||||
name = "Modal Analysis"
|
||||
description = "Natural frequencies and mode shapes visualization"
|
||||
applicable_to = ["modal", "vibration", "dynamic", "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._eigenvectors: Optional[Dict] = None
|
||||
self._frequencies: Optional[List] = None
|
||||
|
||||
def can_generate(self) -> bool:
|
||||
"""Check if OP2 file with eigenvalue/eigenvector 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 modal data exists
|
||||
try:
|
||||
_load_dependencies()
|
||||
op2 = _OP2()
|
||||
op2.read_op2(str(self.op2_path))
|
||||
return bool(op2.eigenvectors)
|
||||
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 modal data from OP2."""
|
||||
if self._eigenvectors 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 modal data
|
||||
op2 = _OP2()
|
||||
op2.read_op2(str(self.op2_path))
|
||||
|
||||
self._eigenvectors = {}
|
||||
self._frequencies = []
|
||||
|
||||
for key, eig in op2.eigenvectors.items():
|
||||
# Get frequencies
|
||||
if hasattr(eig, 'modes') and hasattr(eig, 'cycles'):
|
||||
modes = eig.modes
|
||||
freqs = eig.cycles # Frequencies in Hz
|
||||
elif hasattr(eig, 'eigrs'):
|
||||
# Eigenvalues (radians/sec)^2
|
||||
eigrs = eig.eigrs
|
||||
freqs = np.sqrt(np.abs(eigrs)) / (2 * np.pi)
|
||||
modes = list(range(1, len(freqs) + 1))
|
||||
else:
|
||||
continue
|
||||
|
||||
for i, (mode, freq) in enumerate(zip(modes, freqs)):
|
||||
self._frequencies.append({
|
||||
'mode': int(mode),
|
||||
'frequency_hz': float(freq),
|
||||
})
|
||||
|
||||
# Get mode shapes
|
||||
if hasattr(eig, 'data'):
|
||||
data = eig.data
|
||||
ngt = eig.node_gridtype.astype(int)
|
||||
node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]
|
||||
|
||||
for mode_idx, mode_num in enumerate(modes):
|
||||
if data.ndim == 3:
|
||||
mode_data = data[mode_idx]
|
||||
else:
|
||||
mode_data = data
|
||||
|
||||
self._eigenvectors[int(mode_num)] = {
|
||||
'node_ids': node_ids,
|
||||
'displacements': mode_data.copy(),
|
||||
}
|
||||
|
||||
# Sort frequencies by mode number
|
||||
self._frequencies.sort(key=lambda x: x['mode'])
|
||||
|
||||
def _generate(self, config: InsightConfig) -> InsightResult:
|
||||
"""Generate modal analysis visualization."""
|
||||
self._load_data()
|
||||
|
||||
if not self._frequencies:
|
||||
return InsightResult(success=False, error="No modal data found in OP2")
|
||||
|
||||
_load_dependencies()
|
||||
|
||||
# Configuration
|
||||
n_modes_show = config.extra.get('n_modes', 20)
|
||||
mode_to_show = config.extra.get('show_mode', 1) # Which mode shape to display
|
||||
deform_scale = config.amplification if config.amplification != 1.0 else 50.0
|
||||
|
||||
# Limit to available modes
|
||||
freq_data = self._frequencies[:n_modes_show]
|
||||
modes = [f['mode'] for f in freq_data]
|
||||
frequencies = [f['frequency_hz'] for f in freq_data]
|
||||
|
||||
# Build visualization
|
||||
fig = _make_subplots(
|
||||
rows=2, cols=2,
|
||||
specs=[
|
||||
[{"type": "scene"}, {"type": "xy"}],
|
||||
[{"type": "xy"}, {"type": "table"}]
|
||||
],
|
||||
subplot_titles=[
|
||||
f"<b>Mode {mode_to_show} Shape</b>",
|
||||
"<b>Natural Frequencies</b>",
|
||||
"<b>Frequency Spectrum</b>",
|
||||
"<b>Mode Summary</b>"
|
||||
]
|
||||
)
|
||||
|
||||
# Mode shape (3D)
|
||||
if self._node_geo and mode_to_show in self._eigenvectors:
|
||||
mode_data = self._eigenvectors[mode_to_show]
|
||||
node_ids = mode_data['node_ids']
|
||||
disps = mode_data['displacements']
|
||||
|
||||
X, Y, Z = [], [], []
|
||||
Xd, Yd, Zd = [], [], []
|
||||
colors = []
|
||||
|
||||
for nid, disp in zip(node_ids, disps):
|
||||
geo = self._node_geo.get(int(nid))
|
||||
if geo is None:
|
||||
continue
|
||||
|
||||
X.append(geo[0])
|
||||
Y.append(geo[1])
|
||||
Z.append(geo[2])
|
||||
|
||||
# Deformed position
|
||||
Xd.append(geo[0] + deform_scale * disp[0])
|
||||
Yd.append(geo[1] + deform_scale * disp[1])
|
||||
Zd.append(geo[2] + deform_scale * disp[2])
|
||||
|
||||
# Color by displacement magnitude
|
||||
mag = np.sqrt(disp[0]**2 + disp[1]**2 + disp[2]**2)
|
||||
colors.append(mag)
|
||||
|
||||
X, Y, Z = np.array(X), np.array(Y), np.array(Z)
|
||||
Xd, Yd, Zd = np.array(Xd), np.array(Yd), np.array(Zd)
|
||||
colors = np.array(colors)
|
||||
|
||||
# Try to create mesh
|
||||
try:
|
||||
tri = _Triangulation(Xd, Yd)
|
||||
if tri.triangles is not None and len(tri.triangles) > 0:
|
||||
i, j, k = tri.triangles.T
|
||||
fig.add_trace(_go.Mesh3d(
|
||||
x=Xd, y=Yd, z=Zd,
|
||||
i=i, j=j, k=k,
|
||||
intensity=colors,
|
||||
colorscale='Viridis',
|
||||
opacity=0.9,
|
||||
flatshading=False,
|
||||
showscale=True,
|
||||
colorbar=dict(title="Disp. Mag.", thickness=10, len=0.4)
|
||||
), row=1, col=1)
|
||||
except Exception:
|
||||
# Fallback: scatter
|
||||
fig.add_trace(_go.Scatter3d(
|
||||
x=Xd, y=Yd, z=Zd,
|
||||
mode='markers',
|
||||
marker=dict(size=3, color=colors, colorscale='Viridis', showscale=True),
|
||||
), row=1, col=1)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# Frequency bar chart
|
||||
fig.add_trace(_go.Bar(
|
||||
x=modes,
|
||||
y=frequencies,
|
||||
marker_color='#3b82f6',
|
||||
text=[f"{f:.1f} Hz" for f in frequencies],
|
||||
textposition='outside',
|
||||
name='Frequency'
|
||||
), row=1, col=2)
|
||||
|
||||
fig.update_xaxes(title_text="Mode Number", row=1, col=2)
|
||||
fig.update_yaxes(title_text="Frequency (Hz)", row=1, col=2)
|
||||
|
||||
# Frequency spectrum (log scale)
|
||||
fig.add_trace(_go.Scatter(
|
||||
x=modes,
|
||||
y=frequencies,
|
||||
mode='lines+markers',
|
||||
marker=dict(size=8, color='#22c55e'),
|
||||
line=dict(width=2, color='#22c55e'),
|
||||
name='Frequency'
|
||||
), row=2, col=1)
|
||||
|
||||
fig.update_xaxes(title_text="Mode Number", row=2, col=1)
|
||||
fig.update_yaxes(title_text="Frequency (Hz)", type='log', row=2, col=1)
|
||||
|
||||
# Summary table
|
||||
mode_labels = [f"Mode {m}" for m in modes[:10]]
|
||||
freq_labels = [f"{f:.2f} Hz" for f in frequencies[:10]]
|
||||
|
||||
fig.add_trace(_go.Table(
|
||||
header=dict(values=["<b>Mode</b>", "<b>Frequency</b>"],
|
||||
fill_color='#1f2937', font=dict(color='white')),
|
||||
cells=dict(values=[mode_labels, freq_labels],
|
||||
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 Modal 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"modal_{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={
|
||||
'n_modes': len(self._frequencies),
|
||||
'frequencies_hz': frequencies,
|
||||
'first_frequency_hz': frequencies[0] if frequencies else None,
|
||||
'shown_mode': mode_to_show,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user