Files
Atomizer/optimization_engine/insights/modal_analysis.py
Anto01 f13563d7ab feat: Major update - Physics docs, Zernike OPD, insights, NX journals, tools
Documentation:
- Add docs/06_PHYSICS/ with Zernike fundamentals and OPD method docs
- Add docs/guides/CMA-ES_EXPLAINED.md optimization guide
- Update CLAUDE.md and ATOMIZER_CONTEXT.md with current architecture
- Update OP_01_CREATE_STUDY protocol

Planning:
- Add DYNAMIC_RESPONSE plans for random vibration/PSD support
- Add OPTIMIZATION_ENGINE_MIGRATION_PLAN for code reorganization

Insights System:
- Update design_space, modal_analysis, stress_field, thermal_field insights
- Improve error handling and data validation

NX Journals:
- Add analyze_wfe_zernike.py for Zernike WFE analysis
- Add capture_study_images.py for automated screenshots
- Add extract_expressions.py and introspect_part.py utilities
- Add user_generated_journals/journal_top_view_image_taking.py

Tests & Tools:
- Add comprehensive Zernike OPD test suite
- Add audit_v10 tests for WFE validation
- Add tools for Pareto graphs and mirror data extraction
- Add migrate_studies_to_topics.py utility

Knowledge Base:
- Initialize LAC (Learning Atomizer Core) with failure/success patterns

Dashboard:
- Update Setup.tsx and launch_dashboard.py
- Add restart-dev.bat helper script

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 19:47:37 -05:00

349 lines
12 KiB
Python

"""
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"
category = "structural_modal"
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,
}
)