# Dynamic Response Processor - Implementation Plan ## Atomizer Integration Assessment & Revised Roadmap **Document Version**: 1.1 **Created**: 2025-12-22 **Revised**: 2025-12-22 **Status**: Ready for Implementation **Based On**: DYNAMIC_RESPONSE_MASTER_PLAN.md --- ## Executive Summary After analyzing the Master Plan against Atomizer's current architecture, this document provides a **refined implementation plan** that: 1. Aligns with established patterns (extractors, insights, registry) 2. Leverages existing code (modal_mass extractor, modal_analysis insight) 3. Creates the new `processors/` layer for algorithm code 4. Integrates seamlessly with dashboard and protocols **Key Finding**: The Master Plan is well-designed. This document reorganizes it to fit Atomizer's conventions and identifies reuse opportunities. --- ## 1. Architecture Alignment ### Current Atomizer Layers ``` ┌─────────────────────────────────────────────────────────────┐ │ USER INTERFACE │ │ (Dashboard / CLI / run_optimization.py) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ EXTRACTORS │ │ optimization_engine/extractors/extract_*.py │ │ - Simple function interface: extract_X(file) → dict │ │ - Called from objective functions │ │ - Example: extract_frequency(), extract_modal_mass() │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ PROCESSORS (NEW) │ │ optimization_engine/processors/dynamic_response/ │ │ - Algorithm implementations (modal superposition, FRF) │ │ - Class-based with rich state │ │ - Called by extractors │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ INSIGHTS │ │ optimization_engine/insights/*.py │ │ - Visualization classes with @register_insight │ │ - Generate HTML/Plotly for dashboard │ │ - Example: ModalInsight, StressFieldInsight │ └─────────────────────────────────────────────────────────────┘ ``` ### Proposed Integration ``` optimization_engine/ ├── extractors/ │ ├── __init__.py # Add new exports │ ├── extract_modal_mass.py # EXISTING - F06 parsing ✓ │ ├── extract_random_response.py # NEW - High-level random vib interface │ ├── extract_sine_response.py # NEW - Sine sweep interface │ └── extract_dynamic_stress.py # NEW - Dynamic stress interface │ ├── processors/ # NEW DIRECTORY │ ├── __init__.py │ └── dynamic_response/ │ ├── __init__.py # Public API │ ├── modal_database.py # ModeShape, ModalDatabase │ ├── transfer_functions.py # TransferFunctionEngine │ ├── random_vibration.py # RandomVibrationProcessor │ ├── sine_sweep.py # SineSweepProcessor (Phase 4) │ ├── psd_profiles.py # PSD definitions │ └── utils/ │ ├── __init__.py │ ├── frequency.py # Frequency array utilities │ ├── integration.py # Spectral integration │ └── statistics.py # Peak factors │ ├── insights/ │ ├── __init__.py # Add new registration │ ├── modal_analysis.py # EXISTING - Mode shapes ✓ │ └── dynamic_response.py # NEW - Grms, PSD plots │ └── hooks/ └── dynamic_response/ # NEW ├── pre_analysis.py # Validate modal data └── post_analysis.py # Cache results ``` --- ## 2. Code Reuse Opportunities ### Existing Assets to Leverage | Asset | Location | Reuse Strategy | |-------|----------|----------------| | Modal mass extraction | `extract_modal_mass.py` | Call from `ModalDatabase.from_op2_f06()` | | F06 MEFFMASS parsing | `_parse_modal_effective_mass()` | Import directly | | Participation factors | `_parse_participation_factors()` | Import directly | | OP2 reading | `pyNastran` (installed) | Use for eigenvectors | | Modal insight base | `modal_analysis.py` | Extend or reference patterns | | Insight registry | `@register_insight` decorator | Use for DynamicResponseInsight | | Extractor patterns | All `extract_*.py` | Follow return dict pattern | ### Key Code Connections ```python # In processors/dynamic_response/modal_database.py from optimization_engine.extractors.extract_modal_mass import ( extract_modal_mass, _parse_modal_effective_mass, # Internal but useful ) class ModalDatabase: @classmethod def from_op2_f06(cls, op2_file, f06_file, **options): # Use existing extractor for participation factors modal_data = extract_modal_mass(f06_file, mode=None) # Extract eigenvectors from OP2 from pyNastran.op2.op2 import OP2 op2 = OP2() op2.read_op2(str(op2_file)) # Build ModeShape objects combining both sources ... ``` --- ## 3. Implementation Phases (Revised) ### Phase 1: Core Infrastructure (3-4 days) **Goal**: Create processor foundation with test-validated components. #### 1.1 Create Directory Structure ```bash mkdir -p optimization_engine/processors/dynamic_response/utils touch optimization_engine/processors/__init__.py touch optimization_engine/processors/dynamic_response/__init__.py touch optimization_engine/processors/dynamic_response/utils/__init__.py ``` #### 1.2 Implement Data Classes **File**: `optimization_engine/processors/dynamic_response/modal_database.py` ```python """ Modal Database - Central storage for modal analysis data. Combines: - Eigenvectors from OP2 (pyNastran) - Participation factors from F06 (existing extract_modal_mass) - User-specified damping Provides efficient access for dynamic calculations. """ from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple from pathlib import Path import numpy as np import logging logger = logging.getLogger(__name__) @dataclass class ModeShape: """Single mode shape container.""" mode_number: int frequency: float # Hz damping_ratio: float = 0.02 # Modal properties (from F06) generalized_mass: float = 1.0 participation_x: float = 0.0 participation_y: float = 0.0 participation_z: float = 0.0 effective_mass_x: float = 0.0 effective_mass_y: float = 0.0 effective_mass_z: float = 0.0 # Eigenvector at nodes (from OP2) # Dict[node_id, np.array([tx, ty, tz, rx, ry, rz])] eigenvector: Dict[int, np.ndarray] = field(default_factory=dict) # Modal stress shapes (optional, for stress recovery) # Dict[element_id, np.array([sxx, syy, szz, sxy, syz, sxz, vm])] stress_shapes: Dict[int, np.ndarray] = field(default_factory=dict) @property def omega(self) -> float: """Angular frequency (rad/s).""" return 2 * np.pi * self.frequency @property def omega_squared(self) -> float: return self.omega ** 2 @property def quality_factor(self) -> float: """Q = 1/(2ζ)""" return 1 / (2 * self.damping_ratio) if self.damping_ratio > 0 else np.inf def get_participation(self, direction: str) -> float: """Get participation factor for direction ('x', 'y', 'z').""" return getattr(self, f'participation_{direction}', 0.0) def get_effective_mass(self, direction: str) -> float: return getattr(self, f'effective_mass_{direction}', 0.0) class ModalDatabase: """ Central modal data storage with lazy loading. Combines OP2 (eigenvectors) and F06 (participation) data. Provides caching via HDF5 for optimization loops. """ def __init__( self, modes: List[ModeShape], total_mass: float, model_name: str = "unnamed" ): self.modes = modes self.total_mass = total_mass self.model_name = model_name self._cache = {} @property def n_modes(self) -> int: return len(self.modes) @property def frequency_range(self) -> Tuple[float, float]: if not self.modes: return (0.0, 0.0) freqs = [m.frequency for m in self.modes] return (min(freqs), max(freqs)) def get_frequencies(self) -> np.ndarray: return np.array([m.frequency for m in self.modes]) def get_participation_factors(self, direction: str) -> np.ndarray: return np.array([m.get_participation(direction) for m in self.modes]) def get_effective_masses(self, direction: str) -> np.ndarray: return np.array([m.get_effective_mass(direction) for m in self.modes]) def get_cumulative_mass_fraction(self, direction: str) -> np.ndarray: """Cumulative effective mass fraction for mode selection.""" eff_masses = self.get_effective_masses(direction) return np.cumsum(eff_masses) / self.total_mass def modes_for_mass_fraction(self, fraction: float, direction: str) -> int: """Number of modes needed to capture given mass fraction.""" cumulative = self.get_cumulative_mass_fraction(direction) indices = np.where(cumulative >= fraction)[0] return int(indices[0] + 1) if len(indices) > 0 else self.n_modes @classmethod def from_op2_f06( cls, op2_file: Path, f06_file: Optional[Path] = None, damping: float = 0.02, node_ids: Optional[List[int]] = None, element_ids: Optional[List[int]] = None, ) -> 'ModalDatabase': """ Build ModalDatabase from NX Nastran results. Args: op2_file: Path to OP2 with eigenvectors f06_file: Path to F06 with MEFFMASS (auto-detected if None) damping: Default damping ratio for all modes node_ids: Only extract eigenvectors at these nodes element_ids: Only extract stress shapes for these elements """ from pyNastran.op2.op2 import OP2 from optimization_engine.extractors.extract_modal_mass import extract_modal_mass op2_path = Path(op2_file) # Auto-detect F06 if f06_file is None: f06_path = op2_path.with_suffix('.f06') if not f06_path.exists(): f06_path = None else: f06_path = Path(f06_file) # Extract participation factors from F06 modal_mass_data = None if f06_path and f06_path.exists(): result = extract_modal_mass(f06_path, mode=None) if result['success']: modal_mass_data = result.get('modes', []) # Read OP2 for eigenvectors logger.info(f"Reading OP2: {op2_path}") op2 = OP2() op2.read_op2(str(op2_path)) if not op2.eigenvectors: raise ValueError(f"No eigenvectors found in {op2_path}") # Get total mass total_mass = 1.0 # Default if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight: # Try to get from GPWG for gpwg in op2.grid_point_weight.values(): total_mass = float(gpwg.mass[0]) break # Build ModeShape objects modes = [] for key, eig in op2.eigenvectors.items(): n_modes = len(eig.modes) if hasattr(eig, 'modes') else 0 for i in range(n_modes): mode_num = int(eig.modes[i]) freq = float(eig.cycles[i]) if hasattr(eig, 'cycles') else 0.0 # Get eigenvector data eigenvector = {} if node_ids is not None: for nid in node_ids: if nid in eig.node_gridtype: idx = np.where(eig.node_gridtype[:, 0] == nid)[0] if len(idx) > 0: eigenvector[nid] = eig.data[i, idx[0], :] else: # Store all nodes (memory intensive for large models) for j, (nid, _) in enumerate(eig.node_gridtype): eigenvector[int(nid)] = eig.data[i, j, :] # Merge with F06 data participation = {'x': 0.0, 'y': 0.0, 'z': 0.0} effective = {'x': 0.0, 'y': 0.0, 'z': 0.0} gen_mass = 1.0 if modal_mass_data and mode_num <= len(modal_mass_data): mm = modal_mass_data[mode_num - 1] participation = { 'x': mm.get('participation_x', 0.0) or 0.0, 'y': mm.get('participation_y', 0.0) or 0.0, 'z': mm.get('participation_z', 0.0) or 0.0, } effective = { 'x': mm.get('mass_x', 0.0) or 0.0, 'y': mm.get('mass_y', 0.0) or 0.0, 'z': mm.get('mass_z', 0.0) or 0.0, } mode_shape = ModeShape( mode_number=mode_num, frequency=freq, damping_ratio=damping, generalized_mass=gen_mass, participation_x=participation['x'], participation_y=participation['y'], participation_z=participation['z'], effective_mass_x=effective['x'], effective_mass_y=effective['y'], effective_mass_z=effective['z'], eigenvector=eigenvector, ) modes.append(mode_shape) break # Only process first subcase logger.info(f"Built ModalDatabase: {len(modes)} modes, " f"freq range {modes[0].frequency:.1f}-{modes[-1].frequency:.1f} Hz") return cls(modes=modes, total_mass=total_mass, model_name=op2_path.stem) def summary(self) -> str: """Human-readable summary.""" lines = [ f"ModalDatabase: {self.model_name}", f" Modes: {self.n_modes}", f" Frequency range: {self.frequency_range[0]:.1f} - {self.frequency_range[1]:.1f} Hz", f" Total mass: {self.total_mass:.3f} kg", ] return "\n".join(lines) ``` #### 1.3 Implement Transfer Functions **File**: `optimization_engine/processors/dynamic_response/transfer_functions.py` ```python """ Transfer Function Engine - FRF computation from modal data. Computes: - Single-mode FRF (SDOF transfer function) - Multi-mode combination (modal superposition) - Base excitation transmissibility """ import numpy as np from typing import Optional, Tuple from .modal_database import ModalDatabase, ModeShape class TransferFunctionEngine: """ Frequency response function computation engine. Uses modal superposition to compute dynamic response transfer functions from modal analysis data. """ def __init__(self, modal_db: ModalDatabase): self.modal_db = modal_db def single_mode_frf( self, frequencies: np.ndarray, mode: ModeShape, output_type: str = 'displacement' ) -> np.ndarray: """ Compute FRF for a single mode. H(f) = 1 / [(ω_n² - ω²) + j·2·ζ·ω_n·ω] Args: frequencies: Frequency array (Hz) mode: ModeShape object output_type: 'displacement', 'velocity', or 'acceleration' Returns: Complex FRF array """ omega = 2 * np.pi * frequencies omega_n = mode.omega zeta = mode.damping_ratio # Base displacement FRF H = 1.0 / ((omega_n**2 - omega**2) + 2j * zeta * omega_n * omega) if output_type == 'velocity': H = H * (1j * omega) elif output_type == 'acceleration': H = H * (-omega**2) return H def base_excitation_frf( self, frequencies: np.ndarray, direction: str = 'z', output_type: str = 'acceleration', node_id: Optional[int] = None, n_modes: Optional[int] = None, ) -> np.ndarray: """ Compute FRF for base excitation (seismic/vibration). Combines modal contributions: H_total(f) = Σᵢ Lᵢ · φᵢ · Hᵢ(f) / mᵢ Args: frequencies: Frequency array (Hz) direction: Excitation direction ('x', 'y', 'z') output_type: 'displacement', 'velocity', 'acceleration' node_id: Specific output node (None = use participation only) n_modes: Number of modes (None = 95% mass fraction) Returns: Complex FRF (transmissibility for acceleration output) """ # Determine number of modes if n_modes is None: n_modes = self.modal_db.modes_for_mass_fraction(0.95, direction) n_modes = min(n_modes, self.modal_db.n_modes) omega = 2 * np.pi * frequencies H_total = np.zeros(len(frequencies), dtype=complex) for i in range(n_modes): mode = self.modal_db.modes[i] # Participation factor for this direction L = mode.get_participation(direction) if L == 0: continue # Mode shape at output location if node_id is not None and node_id in mode.eigenvector: dir_idx = {'x': 0, 'y': 1, 'z': 2}.get(direction, 2) phi = mode.eigenvector[node_id][dir_idx] else: # Use participation as proxy for overall response phi = 1.0 # Single mode FRF H_mode = self.single_mode_frf(frequencies, mode, 'displacement') # Add modal contribution H_total += L * phi * H_mode / mode.generalized_mass # Convert to requested output type if output_type == 'velocity': H_total = H_total * (1j * omega) elif output_type == 'acceleration': # For base excitation: transmissibility = 1 + ω²·H_disp H_total = 1.0 + (-omega**2) * H_total return H_total def transmissibility( self, frequencies: np.ndarray, direction: str = 'z', n_modes: Optional[int] = None, ) -> Tuple[np.ndarray, np.ndarray]: """ Compute acceleration transmissibility magnitude and phase. Returns: (magnitude, phase_degrees) """ H = self.base_excitation_frf( frequencies, direction, 'acceleration', n_modes=n_modes ) return np.abs(H), np.angle(H, deg=True) ``` #### 1.4 Implement PSD Profiles **File**: `optimization_engine/processors/dynamic_response/psd_profiles.py` (Use content from Master Plan Section 2 - already well-designed) #### 1.5 Create Tests **File**: `tests/test_dynamic_response/test_transfer_functions.py` ```python """ Transfer Function Tests Validates FRF computations against analytical solutions. """ import numpy as np import pytest from optimization_engine.processors.dynamic_response.modal_database import ModeShape, ModalDatabase from optimization_engine.processors.dynamic_response.transfer_functions import TransferFunctionEngine def test_sdof_resonance_amplification(): """At resonance, |H| ≈ Q for lightly damped SDOF.""" mode = ModeShape( mode_number=1, frequency=100.0, # Hz damping_ratio=0.02, # 2% damping → Q = 25 ) db = ModalDatabase(modes=[mode], total_mass=1.0) engine = TransferFunctionEngine(db) # Evaluate at resonance f_res = np.array([100.0]) H = engine.single_mode_frf(f_res, mode, 'displacement') # Expected: |H| = 1/(2*zeta*omega_n^2) at resonance omega_n = 2 * np.pi * 100 expected_mag = 1 / (2 * 0.02 * omega_n**2) assert np.isclose(np.abs(H[0]), expected_mag, rtol=0.01) def test_transmissibility_unity_low_freq(): """Transmissibility → 1 at low frequencies (rigid body).""" mode = ModeShape(mode_number=1, frequency=100.0, damping_ratio=0.02) db = ModalDatabase(modes=[mode], total_mass=1.0) engine = TransferFunctionEngine(db) low_freq = np.array([1.0]) # Well below resonance mag, _ = engine.transmissibility(low_freq) assert np.isclose(mag[0], 1.0, rtol=0.1) def test_transmissibility_isolation_high_freq(): """Transmissibility → 0 at high frequencies (isolation).""" mode = ModeShape(mode_number=1, frequency=100.0, damping_ratio=0.02) mode.participation_z = 1.0 mode.effective_mass_z = 1.0 db = ModalDatabase(modes=[mode], total_mass=1.0) engine = TransferFunctionEngine(db) high_freq = np.array([1000.0]) # Well above resonance mag, _ = engine.transmissibility(high_freq, direction='z') # Should be much less than 1 assert mag[0] < 0.1 ``` --- ### Phase 2: Random Vibration Processor (3-4 days) **Goal**: Complete random vibration response computation with Miles' equation validation. #### 2.1 RandomVibrationProcessor **File**: `optimization_engine/processors/dynamic_response/random_vibration.py` ```python """ Random Vibration Processor Computes Grms, peak response, and stress from PSD input using modal superposition. """ from dataclasses import dataclass from typing import Dict, Any, Optional, List, Tuple import numpy as np from scipy.integrate import trapezoid from .modal_database import ModalDatabase from .transfer_functions import TransferFunctionEngine from .psd_profiles import PSDProfile @dataclass class RandomVibrationResult: """Container for random vibration results.""" rms_acceleration: float # Grms peak_acceleration: float # G (k-sigma) rms_displacement: float # mm peak_displacement: float # mm dominant_mode: int # Mode with highest contribution modal_contributions: Dict[int, float] # Mode → % contribution response_psd: Tuple[np.ndarray, np.ndarray] # (frequencies, psd) class RandomVibrationProcessor: """ Random vibration response via modal superposition. Replaces SOL 111/112 with analytical computation. """ def __init__( self, modal_db: ModalDatabase, default_damping: float = 0.02 ): self.modal_db = modal_db self.default_damping = default_damping self.frf_engine = TransferFunctionEngine(modal_db) def compute_response( self, psd_profile: PSDProfile, direction: str = 'z', node_id: Optional[int] = None, n_modes: Optional[int] = None, frequency_resolution: int = 500, peak_sigma: float = 3.0, output_type: str = 'acceleration' ) -> RandomVibrationResult: """ Compute full random vibration response. Args: psd_profile: Input PSD specification direction: Excitation direction ('x', 'y', 'z') node_id: Output location (None = overall) n_modes: Number of modes (None = auto 95% mass) frequency_resolution: Points in frequency array peak_sigma: Sigma level for peak (3.0 = 3-sigma) output_type: 'acceleration' or 'displacement' Returns: RandomVibrationResult with all metrics """ # Generate frequency array (log spacing recommended for PSD) f_min = max(psd_profile.freq_min, 1.0) f_max = psd_profile.freq_max frequencies = np.logspace( np.log10(f_min), np.log10(f_max), frequency_resolution ) # Compute transfer function H = self.frf_engine.base_excitation_frf( frequencies, direction, output_type, node_id, n_modes ) # Interpolate input PSD to our frequency array input_psd = psd_profile.interpolate(frequencies) # Response PSD: S_y = |H|² × S_x response_psd = np.abs(H)**2 * input_psd # RMS via integration mean_square = trapezoid(response_psd, frequencies) rms = np.sqrt(mean_square) peak = peak_sigma * rms # Also compute displacement if acceleration was requested if output_type == 'acceleration': H_disp = self.frf_engine.base_excitation_frf( frequencies, direction, 'displacement', node_id, n_modes ) resp_psd_disp = np.abs(H_disp)**2 * input_psd rms_disp = np.sqrt(trapezoid(resp_psd_disp, frequencies)) peak_disp = peak_sigma * rms_disp rms_accel = rms peak_accel = peak else: rms_disp = rms peak_disp = peak rms_accel = 0.0 peak_accel = 0.0 # Modal contribution analysis contributions = self._compute_modal_contributions( frequencies, input_psd, direction, n_modes ) dominant_mode = max(contributions, key=contributions.get) if contributions else 1 return RandomVibrationResult( rms_acceleration=rms_accel, peak_acceleration=peak_accel, rms_displacement=rms_disp * 1000, # Convert to mm peak_displacement=peak_disp * 1000, dominant_mode=dominant_mode, modal_contributions=contributions, response_psd=(frequencies, response_psd), ) def _compute_modal_contributions( self, frequencies: np.ndarray, input_psd: np.ndarray, direction: str, n_modes: Optional[int] ) -> Dict[int, float]: """Compute contribution of each mode to total response.""" if n_modes is None: n_modes = self.modal_db.modes_for_mass_fraction(0.95, direction) n_modes = min(n_modes, self.modal_db.n_modes) contributions = {} total = 0.0 for i in range(n_modes): mode = self.modal_db.modes[i] L = mode.get_participation(direction) # Single mode response H_mode = self.frf_engine.single_mode_frf(frequencies, mode, 'acceleration') resp_psd = np.abs(H_mode * L)**2 * input_psd mode_ms = trapezoid(resp_psd, frequencies) contributions[mode.mode_number] = mode_ms total += mode_ms # Normalize to percentages if total > 0: contributions = {k: v/total * 100 for k, v in contributions.items()} return contributions def compute_grms( self, psd_profile: PSDProfile, direction: str = 'z', node_id: Optional[int] = None ) -> float: """Quick Grms extraction for optimization objective.""" result = self.compute_response(psd_profile, direction, node_id) return result.rms_acceleration def compute_peak_g( self, psd_profile: PSDProfile, direction: str = 'z', sigma: float = 3.0 ) -> float: """Quick peak G extraction for constraint.""" result = self.compute_response(psd_profile, direction, peak_sigma=sigma) return result.peak_acceleration def miles_equation(f_n: float, zeta: float, psd_level: float) -> float: """ Miles' equation for SDOF with white noise. Grms = sqrt(π/2 × f_n × Q × PSD) Args: f_n: Natural frequency (Hz) zeta: Damping ratio psd_level: PSD level (G²/Hz) Returns: Grms (G) """ Q = 1 / (2 * zeta) return np.sqrt(np.pi / 2 * f_n * Q * psd_level) ``` #### 2.2 Tests for Random Vibration ```python def test_miles_equation_validation(): """Random vibration Grms matches Miles' equation for SDOF.""" f_n = 100.0 zeta = 0.02 psd_level = 0.04 # G²/Hz # Analytical grms_miles = miles_equation(f_n, zeta, psd_level) # Via processor mode = ModeShape( mode_number=1, frequency=f_n, damping_ratio=zeta, participation_z=1.0, effective_mass_z=1.0, ) db = ModalDatabase(modes=[mode], total_mass=1.0) psd = PSDProfile.flat(psd_level, freq_min=10, freq_max=1000) processor = RandomVibrationProcessor(db) grms_processor = processor.compute_grms(psd, 'z') assert np.isclose(grms_processor, grms_miles, rtol=0.05) # 5% tolerance ``` --- ### Phase 3: Atomizer Integration (2-3 days) **Goal**: Connect to extractors, insights, and dashboard. #### 3.1 Create High-Level Extractor **File**: `optimization_engine/extractors/extract_random_response.py` (Use content from Master Plan with adjustments to import from processors/) #### 3.2 Update Extractor Registry **File**: `optimization_engine/extractors/__init__.py` (add to existing) ```python # Phase 5: Dynamic Response (2025-12-XX) from .extract_random_response import ( extract_random_response, extract_random_stress, get_grms, get_peak_g, get_peak_stress, ) # Add to __all__ __all__ += [ 'extract_random_response', 'extract_random_stress', 'get_grms', 'get_peak_g', 'get_peak_stress', ] ``` #### 3.3 Create Dynamic Response Insight **File**: `optimization_engine/insights/dynamic_response.py` ```python """ Dynamic Response Insight Visualizes random vibration and sine sweep response data. """ from pathlib import Path from typing import Optional import numpy as np from .base import StudyInsight, InsightConfig, InsightResult, register_insight @register_insight class DynamicResponseInsight(StudyInsight): """ Dynamic response visualization. Shows: - Input vs Output PSD (log-log) - Transmissibility magnitude/phase - Modal contribution bar chart - Grms history across trials """ insight_type = "dynamic_response" name = "Dynamic Response Analysis" description = "Random vibration and frequency response visualization" category = "dynamics" applicable_to = ["random_vib", "dynamics", "modal", "vibration"] required_files = ["*.op2"] # ... implementation following existing insight patterns ``` --- ### Phase 4: Sine Sweep & Advanced Features (Future) Defer to after Phase 3 is validated: - Sine sweep processor - Shock response spectrum - Multi-axis excitation - Fatigue from random --- ## 4. Protocol Documentation ### New Protocol: SYS_17_DYNAMIC_RESPONSE.md Create `docs/protocols/system/SYS_17_DYNAMIC_RESPONSE.md`: ```markdown # SYS_17: Dynamic Response Processor ## Overview Modal superposition-based dynamic response for optimization. ## Capabilities - Random vibration (Grms, peak acceleration, stress) - Sine sweep (magnitude, phase, resonance search) - Shock response spectrum (future) ## Quick Reference | Function | Description | Output | |----------|-------------|--------| | `extract_random_response()` | Full random vib metrics | dict | | `get_grms()` | Quick Grms for objective | float | | `get_peak_g()` | Quick peak G for constraint | float | ## Example Usage ```python from optimization_engine.extractors import extract_random_response # In optimization objective result = extract_random_response( op2_file='model.op2', psd_profile='GEVS', direction='z', damping=0.02 ) objective_value = result['grms'] constraint_value = result['peak_acceleration'] ``` ## PSD Profiles Available - GEVS (NASA standard) - MIL-STD-810G variants (CAT1, CAT4, CAT7, CAT24) - ESA PSS-01-401 (qualification, acceptance) - Custom from CSV ## Validation - Miles' equation (SDOF) - <1% error - SOL 111 comparison - <5% error target ``` ### Update SYS_12 Extractor Library Add entries for new extractors: | ID | Name | Function | Input | Output | |----|------|----------|-------|--------| | E23 | Random Response | `extract_random_response()` | .op2/.f06 + PSD | dict | | E24 | Dynamic Stress | `extract_random_stress()` | .op2/.f06 + PSD | MPa | | E25 | Quick Grms | `get_grms()` | .op2 + PSD | G | --- ## 5. Implementation Checklist ### Phase 1 Checklist - [ ] Create `optimization_engine/processors/` directory structure - [ ] Implement `modal_database.py` with ModeShape + ModalDatabase - [ ] Implement `transfer_functions.py` with TransferFunctionEngine - [ ] Implement `psd_profiles.py` with PSDProfile + STANDARD_PROFILES - [ ] Create `utils/` with frequency, integration, statistics helpers - [ ] Write unit tests for FRF (SDOF validation) - [ ] Test ModalDatabase.from_op2_f06() with real NX data ### Phase 2 Checklist - [ ] Implement `random_vibration.py` with RandomVibrationProcessor - [ ] Implement modal contribution analysis - [ ] Write Miles' equation validation test - [ ] Add stress recovery (optional for Phase 2) - [ ] Integration test with sample study ### Phase 3 Checklist - [ ] Create `extract_random_response.py` in extractors - [ ] Update `extractors/__init__.py` with exports - [ ] Create `DynamicResponseInsight` in insights - [ ] Register insight with @register_insight - [ ] Create optimization template JSON - [ ] Write `SYS_17_DYNAMIC_RESPONSE.md` protocol - [ ] Update `SYS_12_EXTRACTOR_LIBRARY.md` - [ ] End-to-end test: optimization with random vib objective ### Phase 4+ (Future) - [ ] Sine sweep processor - [ ] Shock response spectrum - [ ] Multi-axis SRSS/CQC - [ ] Fatigue from random - [ ] Neural surrogate for modal database --- ## 6. Success Criteria ### Technical - [ ] Grms matches Miles' equation within 1% for SDOF - [ ] Multi-mode response matches SOL 111 within 5% - [ ] Processing time <5s per trial (excluding SOL 103) - [ ] Memory <500MB for large models ### Integration - [ ] `extract_random_response()` works in `run_optimization.py` - [ ] DynamicResponseInsight appears in dashboard - [ ] Protocol SYS_17 is complete and accurate - [ ] Extractor library updated with E23-E25 ### User Experience - [ ] User can optimize for Grms using natural language - [ ] Dashboard shows response PSD and Grms history - [ ] Error messages are clear and actionable --- ## 7. Recommended Implementation Order 1. **Day 1-2**: Phase 1.1-1.4 (data classes, transfer functions, PSD) 2. **Day 3**: Phase 1.5 (tests) + Phase 2.1 (random processor core) 3. **Day 4**: Phase 2.2 (tests) + Phase 3.1 (extractor) 4. **Day 5**: Phase 3.2-3.4 (registry, insight, docs) 5. **Day 6**: End-to-end testing, validation, refinement --- *This plan aligns the Master Plan with Atomizer's architecture for smooth implementation.*