""" 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"Mode {mode_to_show} Shape", "Natural Frequencies", "Frequency Spectrum", "Mode Summary" ] ) # 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=["Mode", "Frequency"], 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="Atomizer Modal Analysis", 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, } )