# Dynamic Response Processor - Master Implementation Plan ## Atomizer Feature: Modal Superposition Engine for Dynamic Optimization **Document Version**: 1.0 **Created**: 2025-12-22 **Author**: Antoine + Claude **Status**: Implementation Ready **Priority**: High - Differentiating Feature --- ## Executive Summary This document provides the complete implementation roadmap for adding **analytical dynamic response processing** to Atomizer. This feature enables optimization of structures under random vibration, sine sweep, and shock environments — **without running expensive frequency response FEA solutions for each design iteration**. ### Value Proposition | Traditional Workflow | Atomizer Dynamic Workflow | |---------------------|---------------------------| | Run SOL 103 (modal) | Run SOL 103 (modal) | | Run SOL 111 (frequency response) | **Skip** - compute analytically | | Run SOL 112 (random) | **Skip** - compute analytically | | ~15-30 min per iteration | ~30 sec per iteration | | 100 iterations = 25-50 hours | 100 iterations = **50 minutes** | **Key Differentiator**: SATK is a post-processor. Atomizer becomes an **optimization engine with built-in dynamic response intelligence**. --- ## Table of Contents 1. [Technical Foundation](#1-technical-foundation) 2. [Architecture Design](#2-architecture-design) 3. [Implementation Phases](#3-implementation-phases) 4. [Module Specifications](#4-module-specifications) 5. [Integration Points](#5-integration-points) 6. [Validation Strategy](#6-validation-strategy) 7. [Performance Targets](#7-performance-targets) 8. [Risk Mitigation](#8-risk-mitigation) 9. [Future Extensions](#9-future-extensions) --- ## 1. Technical Foundation ### 1.1 The Physics: Why This Works The fundamental insight is that **dynamic response can be computed from modal data alone**. For a structure with N degrees of freedom, the equation of motion is: ``` [M]{ü} + [C]{u̇} + [K]{u} = {F(t)} ``` Modal analysis (SOL 103) decomposes this into N independent single-DOF equations: ``` q̈ᵢ + 2ζᵢωᵢq̇ᵢ + ωᵢ²qᵢ = Lᵢ·a_base / mᵢ ``` Where: - `qᵢ` = modal coordinate (how much mode i is excited) - `ωᵢ` = natural frequency of mode i (rad/s) - `ζᵢ` = damping ratio of mode i - `Lᵢ` = modal participation factor (how mode i couples to base motion) - `mᵢ` = generalized (modal) mass **The key**: Once we extract (ωᵢ, ζᵢ, Lᵢ, φᵢ) from SOL 103, we can compute the response to ANY excitation profile analytically — no more FEA needed. ### 1.2 Transfer Function Mathematics #### Single Mode Transfer Function For base acceleration input, the displacement transfer function of mode i is: ```python def H_displacement(f, f_n, zeta): """ Displacement per unit base acceleration. H(f) = 1 / [(ωₙ² - ω²) + j·2·ζ·ωₙ·ω] In dimensionless form with r = f/fₙ: H(r) = 1 / [(1 - r²) + j·2·ζ·r] """ r = f / f_n return 1 / ((1 - r**2) + 2j * zeta * r) ``` #### Acceleration Transmissibility For base excitation, the ratio of response acceleration to input acceleration: ```python def transmissibility(f, f_n, zeta): """ T(f) = (1 + j·2·ζ·r) / [(1 - r²) + j·2·ζ·r] |T| = 1 at low frequency (rigid body) |T| = Q at resonance (amplification) |T| → 0 at high frequency (isolation) """ r = f / f_n return (1 + 2j * zeta * r) / ((1 - r**2) + 2j * zeta * r) ``` #### Multi-Mode Combination Total response sums all modal contributions: ```python def total_response(frequencies, modes, participation_factors): """ H_total(f) = Σᵢ Lᵢ · φᵢ · Hᵢ(f) / mᵢ Where: - Lᵢ = participation factor - φᵢ = mode shape at point of interest - Hᵢ = modal transfer function - mᵢ = generalized mass """ H_total = np.zeros(len(frequencies), dtype=complex) for mode in modes: H_mode = H_displacement(frequencies, mode.frequency, mode.damping) H_total += mode.participation * mode.shape * H_mode / mode.gen_mass return H_total ``` ### 1.3 Random Vibration Response Random vibration is characterized by Power Spectral Density (PSD), typically in G²/Hz. #### Response PSD ```python S_response(f) = |H(f)|² × S_input(f) ``` #### RMS (Root Mean Square) ```python σ² = ∫ S_response(f) df # Mean square RMS = √σ² # Root mean square ``` #### Peak Response For Gaussian random processes: ```python Peak = k × RMS ``` Where k (crest factor) depends on: - **k = 3.0**: Standard 3-sigma (99.7% probability not exceeded) - **k = 3.5-4.0**: Von Mises stress (non-Gaussian distribution) - **k = 4.0-4.5**: Fatigue-critical applications #### Miles' Equation (SDOF Approximation) For quick estimates with white noise PSD: ```python Grms = √(π/2 × fₙ × Q × PSD_level) ``` Where Q = 1/(2ζ) is the quality factor. ### 1.4 Stress Recovery from Modal Data Element stresses can also be computed via modal superposition: ```python σ(t) = Σᵢ qᵢ(t) × σᵢ_modal ``` Where `σᵢ_modal` is the stress pattern when mode i has unit amplitude. For random vibration, stress PSD: ```python S_σ(f) = |Σᵢ Lᵢ × σᵢ_modal × Hᵢ(f)|² × S_input(f) ``` ### 1.5 What We Extract from SOL 103 | Data | Source | Used For | |------|--------|----------| | Natural frequencies (fₙ) | OP2: eigenvalues | Transfer function poles | | Mode shapes (φ) | OP2: eigenvectors | Response at specific nodes | | Generalized mass (m) | OP2/F06: eigenvalues | Modal scaling | | Modal effective mass | F06: MEFFMASS | Participation estimation | | Participation factors (L) | F06: MEFFMASS | Base excitation coupling | | Modal stress shapes | OP2: modal stress | Stress recovery | --- ## 2. Architecture Design ### 2.1 Module Structure ``` optimization_engine/ ├── processors/ │ └── dynamic_response/ │ ├── __init__.py # Public API exports │ ├── modal_database.py # ModalDatabase, ModeShape classes │ ├── transfer_functions.py # FRF computation engine │ ├── random_vibration.py # Random response processor │ ├── sine_sweep.py # Harmonic response processor │ ├── shock_response.py # SRS computation (Phase 5) │ ├── stress_recovery.py # Modal stress combination │ ├── psd_profiles.py # Standard PSD definitions │ └── utils/ │ ├── __init__.py │ ├── frequency_utils.py # Frequency array generation │ ├── integration.py # Spectral integration methods │ └── statistics.py # Peak factors, distributions │ ├── extractors/ │ ├── extract_modal_database.py # Full modal data extraction │ ├── extract_random_response.py # Random vibration objectives │ ├── extract_sine_response.py # Sine sweep objectives │ └── extract_dynamic_stress.py # Dynamic stress extraction │ ├── hooks/ │ └── dynamic_response/ │ ├── pre_dynamic_analysis.py # Validate modal data │ └── post_dynamic_analysis.py # Cache results, logging │ └── templates/ ├── random_vibration_optimization.json ├── sine_sweep_optimization.json └── multi_environment_optimization.json ``` ### 2.2 Class Hierarchy ``` ┌─────────────────────────────────────────────────────────────┐ │ DynamicResponseProcessor │ │ (Abstract Base) │ ├─────────────────────────────────────────────────────────────┤ │ + modal_db: ModalDatabase │ │ + frf_engine: TransferFunctionEngine │ │ + compute_response() → ResponseResult │ │ + compute_at_node(node_id) → float │ │ + compute_stress(element_ids) → Dict │ └─────────────────────────────────────────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ RandomVibration │ │ SineSweep │ │ ShockResponse │ │ Processor │ │ Processor │ │ Processor │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ + psd_profile │ │ + sweep_rate │ │ + srs_damping │ │ + compute_grms()│ │ + dwell_freqs │ │ + compute_srs() │ │ + compute_peak()│ │ + compute_peak()│ │ + compute_pseudo│ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` ### 2.3 Data Flow ``` ┌──────────────────────────────────────────────────────────────────┐ │ OPTIMIZATION LOOP │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 1. UPDATE DESIGN PARAMETERS │ │ NX Expression Manager → thickness=2.5mm, rib_height=12mm │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 2. RUN SOL 103 (Modal Analysis) │ │ NX Solver → model.op2, model.f06 │ │ Time: ~20-60 seconds │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 3. EXTRACT MODAL DATABASE │ │ pyNastran → ModalDatabase object │ │ - Frequencies, mode shapes, participation factors │ │ - Modal stress shapes (if needed) │ │ Time: ~0.5-2 seconds │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 4. COMPUTE DYNAMIC RESPONSE (Analytical) │ │ RandomVibrationProcessor.compute_response() │ │ - Build transfer functions: ~5ms │ │ - Compute response PSD: ~10ms │ │ - Integrate for RMS: ~5ms │ │ - Stress recovery: ~50ms │ │ Time: ~50-100 milliseconds │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 5. RETURN OBJECTIVES & CONSTRAINTS │ │ { │ │ 'grms': 8.4, │ │ 'peak_acceleration': 25.2, │ │ 'peak_stress': 145.6, │ │ 'first_frequency': 127.3 │ │ } │ └──────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 6. OPTUNA SUGGESTS NEXT DESIGN │ │ TPE/CMA-ES/NSGA-II → next trial parameters │ └──────────────────────────────────────────────────────────────────┘ │ ▼ [REPEAT 1-6] ``` ### 2.4 Memory Management For large models with many modes/elements, we need smart memory handling: ```python class ModalDatabase: """ Memory-efficient modal data storage. Strategies: 1. Lazy loading - only load mode shapes when needed 2. Sparse storage - only store non-zero eigenvector components 3. Node filtering - only extract nodes of interest 4. Disk caching - cache to HDF5 for reuse across trials """ def __init__(self, op2_file, lazy_load=True): self._op2_path = op2_file self._modes_loaded = False self._mode_shapes = None # Load on demand @property def mode_shapes(self): if not self._modes_loaded: self._load_mode_shapes() return self._mode_shapes def get_modes_for_nodes(self, node_ids: List[int]) -> np.ndarray: """Extract mode shapes only for specified nodes.""" # Efficient partial loading pass def cache_to_hdf5(self, cache_path: Path): """Cache modal database for fast reload.""" pass @classmethod def from_cache(cls, cache_path: Path) -> 'ModalDatabase': """Load from HDF5 cache (10x faster than OP2).""" pass ``` --- ## 3. Implementation Phases ### Phase 1: Core Infrastructure (Week 1) **Goal**: Build the foundational classes and basic functionality. #### Tasks | Task | Description | Effort | Deliverable | |------|-------------|--------|-------------| | 1.1 | Create `ModeShape` dataclass | 2h | `modal_database.py` | | 1.2 | Create `ModalDatabase` class | 4h | `modal_database.py` | | 1.3 | Implement OP2 extraction | 4h | `ModalDatabase.from_op2_f06()` | | 1.4 | Implement F06 MEFFMASS parsing | 3h | Modal effective mass extraction | | 1.5 | Create `TransferFunctionEngine` | 4h | `transfer_functions.py` | | 1.6 | Single mode FRF | 2h | `single_mode_frf()` | | 1.7 | Multi-mode combination | 2h | `base_excitation_frf()` | | 1.8 | Unit tests for FRF | 3h | `tests/test_transfer_functions.py` | **Validation Checkpoint**: - FRF matches closed-form SDOF solution - Modal database correctly extracts from sample OP2 #### Code: ModeShape Dataclass ```python # optimization_engine/processors/dynamic_response/modal_database.py from dataclasses import dataclass, field from typing import Dict, Optional, List import numpy as np @dataclass class ModeShape: """ Single mode shape data container. Stores all information needed for modal superposition of one natural mode. """ # Identification mode_number: int # Dynamic properties frequency: float # Hz damping_ratio: float = 0.02 # Dimensionless (2% default) # Modal mass/stiffness generalized_mass: float = 1.0 # kg (normalized) generalized_stiffness: float = 0.0 # N/m # Participation factors (for base excitation) participation_x: float = 0.0 participation_y: float = 0.0 participation_z: float = 0.0 # Modal effective mass (absolute contribution) effective_mass_x: float = 0.0 effective_mass_y: float = 0.0 effective_mass_z: float = 0.0 # Eigenvector at specific nodes # Dict[node_id, np.array([tx, ty, tz, rx, ry, rz])] eigenvector: Dict[int, np.ndarray] = field(default_factory=dict) # Modal stress shapes 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) # Derived properties @property def omega(self) -> float: """Angular frequency (rad/s).""" return 2 * np.pi * self.frequency @property def omega_squared(self) -> float: """ω² for efficiency.""" return self.omega ** 2 @property def quality_factor(self) -> float: """Q = 1/(2ζ) - amplification at resonance.""" if self.damping_ratio > 0: return 1 / (2 * self.damping_ratio) return np.inf @property def half_power_bandwidth(self) -> float: """Δf = fₙ/Q - bandwidth at -3dB.""" return self.frequency / self.quality_factor 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: """Get effective mass for direction.""" return getattr(self, f'effective_mass_{direction}', 0.0) def get_eigenvector_component( self, node_id: int, direction: str ) -> float: """Get eigenvector component at node in direction.""" if node_id not in self.eigenvector: return 0.0 dir_map = {'x': 0, 'y': 1, 'z': 2, 'rx': 3, 'ry': 4, 'rz': 5} idx = dir_map.get(direction, 0) return self.eigenvector[node_id][idx] ``` ### Phase 2: Random Vibration Processor (Week 2) **Goal**: Full random vibration response calculation with stress recovery. #### Tasks | Task | Description | Effort | Deliverable | |------|-------------|--------|-------------| | 2.1 | Create `PSDProfile` class | 2h | `psd_profiles.py` | | 2.2 | Standard profiles (GEVS, MIL-STD) | 2h | `STANDARD_PROFILES` dict | | 2.3 | PSD interpolation (log-log) | 2h | `PSDProfile.interpolate()` | | 2.4 | Create `RandomVibrationProcessor` | 4h | `random_vibration.py` | | 2.5 | Response PSD calculation | 3h | `compute_response_psd()` | | 2.6 | Spectral integration | 2h | `compute_rms()` | | 2.7 | Peak factor calculation | 2h | Gaussian + non-Gaussian | | 2.8 | Modal contribution analysis | 2h | Identify dominant modes | | 2.9 | Stress recovery | 4h | `compute_stress_response()` | | 2.10 | Result dataclass | 2h | `RandomVibrationResult` | | 2.11 | Integration tests | 4h | Miles' equation validation | **Validation Checkpoint**: - Grms matches Miles' equation for SDOF - Multi-mode response reasonable for sample model - Stress recovery gives correct distribution #### Code: PSD Profiles ```python # optimization_engine/processors/dynamic_response/psd_profiles.py import numpy as np from scipy import interpolate from dataclasses import dataclass from typing import List, Tuple, Optional from pathlib import Path @dataclass class PSDProfile: """ Power Spectral Density profile for random vibration. Stores PSD as G²/Hz vs frequency, with log-log interpolation. """ name: str frequencies: np.ndarray # Hz psd_values: np.ndarray # G²/Hz description: str = "" source: str = "" # Standard reference @property def freq_min(self) -> float: return float(self.frequencies.min()) @property def freq_max(self) -> float: return float(self.frequencies.max()) @property def grms(self) -> float: """Overall Grms level.""" from scipy.integrate import trapezoid return np.sqrt(trapezoid(self.psd_values, self.frequencies)) def interpolate(self, frequencies: np.ndarray) -> np.ndarray: """ Interpolate PSD to arbitrary frequencies. Uses log-log interpolation (standard for PSD). Clamps to edge values outside defined range. """ # Safety: ensure positive frequencies frequencies = np.maximum(frequencies, 1e-6) log_f = np.log10(self.frequencies) log_psd = np.log10(np.maximum(self.psd_values, 1e-20)) interp_func = interpolate.interp1d( log_f, log_psd, kind='linear', bounds_error=False, fill_value=(log_psd[0], log_psd[-1]) ) return 10 ** interp_func(np.log10(frequencies)) def plot(self, ax=None, **kwargs): """Plot PSD profile.""" import matplotlib.pyplot as plt if ax is None: fig, ax = plt.subplots() ax.loglog(self.frequencies, self.psd_values, **kwargs) ax.set_xlabel('Frequency (Hz)') ax.set_ylabel('PSD (G²/Hz)') ax.set_title(f'{self.name} - {self.grms:.2f} Grms') ax.grid(True, which='both', alpha=0.3) return ax @classmethod def from_breakpoints( cls, breakpoints: List[Tuple[float, float]], name: str = "Custom", description: str = "" ) -> 'PSDProfile': """ Create PSD from breakpoint specification. Args: breakpoints: List of (frequency_hz, psd_g2_hz) tuples name: Profile name description: Description text Example: psd = PSDProfile.from_breakpoints([ (20, 0.01), # 20 Hz: 0.01 G²/Hz (80, 0.04), # 80 Hz: 0.04 G²/Hz (ramp up) (500, 0.04), # 500 Hz: 0.04 G²/Hz (flat) (2000, 0.007) # 2000 Hz: 0.007 G²/Hz (ramp down) ], name='MIL-STD-810') """ breakpoints = sorted(breakpoints, key=lambda x: x[0]) frequencies = np.array([bp[0] for bp in breakpoints]) psd_values = np.array([bp[1] for bp in breakpoints]) return cls( name=name, frequencies=frequencies, psd_values=psd_values, description=description ) @classmethod def from_csv(cls, filepath: Path, name: str = None) -> 'PSDProfile': """Load PSD from CSV file with freq,psd columns.""" data = np.loadtxt(filepath, delimiter=',', skiprows=1) return cls( name=name or filepath.stem, frequencies=data[:, 0], psd_values=data[:, 1] ) @classmethod def flat( cls, level: float, freq_min: float = 20, freq_max: float = 2000, name: str = "Flat" ) -> 'PSDProfile': """Create flat (white noise) PSD profile.""" return cls( name=name, frequencies=np.array([freq_min, freq_max]), psd_values=np.array([level, level]), description=f"Flat PSD at {level} G²/Hz" ) # ============================================================================ # STANDARD PSD PROFILES # ============================================================================ STANDARD_PROFILES = { # NASA GEVS (General Environmental Verification Standard) 'GEVS': PSDProfile.from_breakpoints([ (20, 0.026), (50, 0.16), (800, 0.16), (2000, 0.026), ], name='NASA GEVS', description='NASA General Environmental Verification Standard'), # MIL-STD-810G Category 1 (Basic) 'MIL-STD-810G_CAT1': PSDProfile.from_breakpoints([ (10, 0.04), (40, 0.04), (500, 0.04), (2000, 0.01), ], name='MIL-STD-810G Cat 1', description='Basic transportation vibration'), # MIL-STD-810G Category 4 (Truck) 'MIL-STD-810G_CAT4': PSDProfile.from_breakpoints([ (5, 0.001), (10, 0.015), (40, 0.015), (500, 0.015), (2000, 0.003), ], name='MIL-STD-810G Cat 4', description='Truck transportation'), # MIL-STD-810G Category 7 (Jet Aircraft) 'MIL-STD-810G_CAT7': PSDProfile.from_breakpoints([ (15, 0.015), (80, 0.015), (350, 0.015), (2000, 0.006), ], name='MIL-STD-810G Cat 7', description='Jet aircraft vibration'), # MIL-STD-810G Category 24 (Helicopter) 'MIL-STD-810G_CAT24': PSDProfile.from_breakpoints([ (5, 0.001), (20, 0.01), (80, 0.04), (350, 0.04), (2000, 0.007), ], name='MIL-STD-810G Cat 24', description='Helicopter vibration'), # NAVMAT P-9492 'NAVMAT': PSDProfile.from_breakpoints([ (20, 0.04), (80, 0.04), (350, 0.04), (2000, 0.007), ], name='NAVMAT P-9492', description='Navy shipboard equipment'), # ESA PSS-01-401 Qualification 'ESA_QUAL': PSDProfile.from_breakpoints([ (20, 0.01), (100, 0.08), (300, 0.08), (2000, 0.008), ], name='ESA PSS-01-401', description='ESA qualification level'), # ESA PSS-01-401 Acceptance 'ESA_ACCEPT': PSDProfile.from_breakpoints([ (20, 0.005), (100, 0.04), (300, 0.04), (2000, 0.004), ], name='ESA PSS-01-401 Accept', description='ESA acceptance level'), # SpaceX Falcon 9 (approximate public data) 'FALCON9': PSDProfile.from_breakpoints([ (20, 0.01), (50, 0.08), (800, 0.08), (2000, 0.02), ], name='Falcon 9 (approx)', description='SpaceX Falcon 9 approximate envelope'), # Ariane 5 (approximate) 'ARIANE5': PSDProfile.from_breakpoints([ (20, 0.015), (100, 0.09), (400, 0.09), (2000, 0.01), ], name='Ariane 5 (approx)', description='Ariane 5 approximate envelope'), } def get_psd_profile(name: str) -> PSDProfile: """ Get a standard PSD profile by name. Args: name: Profile name (case-insensitive, underscores/hyphens flexible) Returns: PSDProfile instance Raises: ValueError: If profile not found Example: >>> psd = get_psd_profile('GEVS') >>> psd = get_psd_profile('mil-std-810g-cat7') """ # Normalize name normalized = name.upper().replace('-', '_').replace(' ', '_') if normalized in STANDARD_PROFILES: return STANDARD_PROFILES[normalized] # Try partial match for key, profile in STANDARD_PROFILES.items(): if normalized in key or key in normalized: return profile available = ', '.join(STANDARD_PROFILES.keys()) raise ValueError(f"Unknown PSD profile: '{name}'. Available: {available}") def list_available_profiles() -> List[str]: """Return list of available standard PSD profile names.""" return list(STANDARD_PROFILES.keys()) ``` ### Phase 3: Atomizer Integration (Week 3) **Goal**: Connect dynamic response processor to Atomizer's extractor and optimization systems. #### Tasks | Task | Description | Effort | Deliverable | |------|-------------|--------|-------------| | 3.1 | Create `extract_modal_database` | 3h | New extractor | | 3.2 | Create `extract_random_response` | 3h | New extractor | | 3.3 | Create `extract_dynamic_stress` | 3h | New extractor | | 3.4 | Optimization template | 2h | `random_vibration_optimization.json` | | 3.5 | Runner integration | 4h | Hook into `OptimizationRunner` | | 3.6 | Dashboard widgets | 4h | Response PSD plot, Grms history | | 3.7 | Result caching | 3h | Cache modal DB between similar designs | | 3.8 | Documentation | 3h | User guide, API docs | | 3.9 | End-to-end test | 4h | Full optimization run | **Validation Checkpoint**: - Complete optimization runs successfully - Dashboard shows dynamic response metrics - Results match manual SATK/Nastran verification #### Code: Random Response Extractor ```python # optimization_engine/extractors/extract_random_response.py """ Random Vibration Response Extractor for Atomizer Optimization ============================================================= Extracts random vibration metrics (Grms, peak acceleration, peak stress) from SOL 103 modal analysis results using analytical modal superposition. This extractor enables dynamic optimization without running SOL 111. Example: >>> result = extract_random_response( ... op2_file='model.op2', ... psd_profile='GEVS', ... direction='z' ... ) >>> print(f"Grms: {result['grms']:.2f} G") >>> print(f"Peak: {result['peak_acceleration']:.2f} G") """ from pathlib import Path from typing import Dict, Any, Optional, Union, List import logging logger = logging.getLogger(__name__) def extract_random_response( op2_file: Union[str, Path], psd_profile: str = 'GEVS', direction: str = 'z', damping: float = 0.02, node_id: Optional[int] = None, peak_sigma: float = 3.0, f06_file: Optional[Union[str, Path]] = None, n_modes: Optional[int] = None, frequency_resolution: int = 500, ) -> Dict[str, Any]: """ Extract random vibration response for optimization objective/constraint. This is the main interface for dynamic optimization. It reads SOL 103 results and computes random vibration response analytically. Args: op2_file: Path to OP2 file from SOL 103 analysis psd_profile: PSD specification - name ('GEVS', 'MIL-STD-810G_CAT7', etc.) or path to CSV file direction: Excitation direction ('x', 'y', 'z') damping: Modal damping ratio (default 2%) node_id: Specific node for response (None = use effective mass) peak_sigma: Sigma level for peak (3.0 = 3-sigma) f06_file: Optional F06 for modal effective mass table n_modes: Number of modes to include (None = auto 95% mass) frequency_resolution: Points in frequency array Returns: Dict containing: - 'grms': RMS acceleration (G) - 'peak_acceleration': Peak acceleration (G) - 'rms_displacement': RMS displacement (mm) - 'peak_displacement': Peak displacement (mm) - 'dominant_mode': Mode number with highest contribution - 'dominant_frequency': Frequency of dominant mode (Hz) - 'n_modes_used': Number of modes in calculation - 'mass_fraction': Cumulative mass fraction of included modes - 'psd_profile': Name of PSD profile used - 'direction': Excitation direction - 'success': True if extraction succeeded - 'error': Error message if failed Example: # For optimization objective >>> result = extract_random_response('bracket.op2', psd_profile='GEVS') >>> objective = result['peak_acceleration'] # For constraint checking >>> result = extract_random_response('bracket.op2', psd_profile='GEVS') >>> if result['peak_acceleration'] > 25.0: ... print("Constraint violated!") """ from optimization_engine.processors.dynamic_response import ( ModalDatabase, RandomVibrationProcessor, get_psd_profile ) op2_path = Path(op2_file) f06_path = Path(f06_file) if f06_file else None # Auto-detect F06 if not provided if f06_path is None: possible_f06 = op2_path.with_suffix('.f06') if possible_f06.exists(): f06_path = possible_f06 logger.info(f"Auto-detected F06: {f06_path}") try: # Build modal database logger.info(f"Extracting modal database from {op2_path}") modal_db = ModalDatabase.from_op2_f06( op2_file=op2_path, f06_file=f06_path, damping=damping, node_ids=[node_id] if node_id else None, ) logger.info(f"Extracted {modal_db.n_modes} modes, " f"freq range: {modal_db.frequency_range[0]:.1f}-" f"{modal_db.frequency_range[1]:.1f} Hz") # Load PSD profile if Path(psd_profile).exists(): from optimization_engine.processors.dynamic_response import PSDProfile psd = PSDProfile.from_csv(Path(psd_profile)) else: psd = get_psd_profile(psd_profile) logger.info(f"Using PSD profile: {psd.name} ({psd.grms:.2f} Grms)") # Create processor processor = RandomVibrationProcessor( modal_db=modal_db, default_damping=damping ) # Compute response result = processor.compute_response( psd_profile=psd, direction=direction, node_id=node_id, n_modes=n_modes, frequency_resolution=frequency_resolution, peak_sigma=peak_sigma, output_type='acceleration' ) # Get mass fraction for included modes n_modes_used = n_modes or modal_db.modes_for_mass_fraction(0.95, direction) mass_fraction = modal_db.get_cumulative_mass_fraction(direction)[n_modes_used - 1] # Dominant mode info dominant_freq = modal_db.modes[result.dominant_mode - 1].frequency return { 'grms': result.rms_acceleration, 'peak_acceleration': result.peak_acceleration, 'rms_displacement': result.rms_displacement, 'peak_displacement': result.peak_displacement, 'dominant_mode': result.dominant_mode, 'dominant_frequency': dominant_freq, 'n_modes_used': n_modes_used, 'mass_fraction': mass_fraction, 'modal_contributions': result.modal_contributions, 'psd_profile': psd.name, 'psd_grms': psd.grms, 'direction': direction, 'peak_sigma': peak_sigma, 'success': True, 'error': None, } except Exception as e: logger.exception(f"Random response extraction failed: {e}") return { 'grms': None, 'peak_acceleration': None, 'rms_displacement': None, 'peak_displacement': None, 'dominant_mode': None, 'dominant_frequency': None, 'success': False, 'error': str(e), } def extract_random_stress( op2_file: Union[str, Path], psd_profile: str = 'GEVS', direction: str = 'z', damping: float = 0.02, peak_sigma: float = 3.0, element_ids: Optional[List[int]] = None, non_gaussian_correction: bool = True, f06_file: Optional[Union[str, Path]] = None, ) -> Dict[str, Any]: """ Extract peak Von Mises stress from random vibration. Args: op2_file: Path to OP2 with modal stress results psd_profile: PSD specification direction: Excitation direction damping: Modal damping ratio peak_sigma: Sigma level element_ids: Specific elements (None = all) non_gaussian_correction: Apply VM stress distribution correction f06_file: Optional F06 for effective mass Returns: Dict with: - 'max_rms_von_mises': Maximum RMS VM stress (MPa) - 'max_peak_von_mises': Maximum peak VM stress (MPa) - 'critical_element': Element ID with max stress - 'margin_of_safety': (allowable - peak) / peak - 'n_elements': Number of elements analyzed """ from optimization_engine.processors.dynamic_response import ( ModalDatabase, RandomVibrationProcessor, get_psd_profile ) op2_path = Path(op2_file) f06_path = Path(f06_file) if f06_file else None try: # Build modal database with stress shapes modal_db = ModalDatabase.from_op2_f06( op2_file=op2_path, f06_file=f06_path, damping=damping, element_ids=element_ids, # Request stress shapes ) # Load PSD psd = get_psd_profile(psd_profile) if not Path(psd_profile).exists() \ else PSDProfile.from_csv(Path(psd_profile)) # Process processor = RandomVibrationProcessor(modal_db, damping) result = processor.compute_stress_response( psd_profile=psd, direction=direction, element_ids=element_ids, peak_sigma=peak_sigma, non_gaussian_correction=non_gaussian_correction, ) return { 'max_rms_von_mises': result['max_rms_von_mises'], 'max_peak_von_mises': result['max_peak_von_mises'], 'critical_element': result['critical_element'], 'n_elements': result['n_elements'], 'success': True, 'error': None, } except Exception as e: logger.exception(f"Stress extraction failed: {e}") return { 'max_rms_von_mises': None, 'max_peak_von_mises': None, 'critical_element': None, 'success': False, 'error': str(e), } # Convenience functions for optimization objectives def get_grms(op2_file, psd='GEVS', direction='z', damping=0.02) -> float: """Quick Grms extraction for objective function.""" result = extract_random_response(op2_file, psd, direction, damping) return result['grms'] if result['success'] else float('inf') def get_peak_g(op2_file, psd='GEVS', direction='z', damping=0.02, sigma=3.0) -> float: """Quick peak acceleration extraction for constraint.""" result = extract_random_response(op2_file, psd, direction, damping, peak_sigma=sigma) return result['peak_acceleration'] if result['success'] else float('inf') def get_peak_stress(op2_file, psd='GEVS', direction='z', damping=0.02, sigma=3.0) -> float: """Quick peak stress extraction for constraint.""" result = extract_random_stress(op2_file, psd, direction, damping, sigma) return result['max_peak_von_mises'] if result['success'] else float('inf') ``` ### Phase 4: Sine Sweep Processor (Week 4) **Goal**: Add harmonic/sine sweep response capability. #### Tasks | Task | Description | Effort | Deliverable | |------|-------------|--------|-------------| | 4.1 | Sine sweep transfer function | 3h | Magnitude/phase at frequencies | | 4.2 | Resonance search | 2h | Find peak frequencies | | 4.3 | Dwell analysis | 3h | Response at specific frequencies | | 4.4 | Sweep rate effects | 2h | Transient buildup correction | | 4.5 | Create `SineSweepProcessor` | 4h | `sine_sweep.py` | | 4.6 | Create extractor | 2h | `extract_sine_response.py` | | 4.7 | Optimization template | 2h | `sine_sweep_optimization.json` | | 4.8 | Tests | 3h | Validate against analytical | ### Phase 5: Advanced Features (Future) **Goal**: Extended capabilities for complete dynamic qualification. #### Shock Response Spectrum (SRS) - Compute SRS from transient pulse or base motion - Pseudo-velocity and pseudo-acceleration - Compare against equipment SRS limits #### Multi-Axis Excitation - SRSS (Square Root Sum of Squares) combination - CQC (Complete Quadratic Combination) for correlated inputs - Full 6-DOF base motion #### Fatigue from Random Vibration - Narrow-band fatigue using Miner's rule - Dirlik method for wide-band random - Cycle counting from PSD #### Neural Surrogate for Modal Database - Train GNN to predict (frequencies, mode shapes) from geometry - Skip SOL 103 entirely for similar designs - 1000x additional speedup potential --- ## 4. Module Specifications ### 4.1 ModalDatabase ```python class ModalDatabase: """ Central storage for all modal analysis data. Responsibilities: - Extract data from OP2/F06 files - Store mode shapes, frequencies, participation factors - Provide efficient access patterns for dynamic calculations - Support caching and serialization Thread Safety: Read-only after construction Memory: O(n_modes × n_nodes × 6) for full eigenvectors """ # Class attributes SUPPORTED_ELEMENTS = ['CQUAD4', 'CTRIA3', 'CHEXA', 'CPENTA', 'CTETRA'] # Required data model_name: str total_mass: float center_of_gravity: np.ndarray modes: List[ModeShape] # Optional data node_coordinates: Dict[int, np.ndarray] element_connectivity: Dict[int, List[int]] # Metadata source_op2: Path source_f06: Path extraction_timestamp: datetime nastran_version: str # Methods @classmethod def from_op2_f06(cls, op2_file, f06_file, **options) -> 'ModalDatabase' def get_mode(self, mode_number: int) -> ModeShape def get_frequencies(self) -> np.ndarray def get_participation_factors(self, direction: str) -> np.ndarray def get_cumulative_mass_fraction(self, direction: str) -> np.ndarray def modes_for_mass_fraction(self, fraction: float, direction: str) -> int def to_hdf5(self, filepath: Path) @classmethod def from_hdf5(cls, filepath: Path) -> 'ModalDatabase' def to_dict(self) -> Dict def summary(self) -> str ``` ### 4.2 TransferFunctionEngine ```python class TransferFunctionEngine: """ Computes frequency response functions from modal data. Responsibilities: - Single mode FRF calculation - Multi-mode combination - Base excitation transmissibility - Point excitation mobility (future) Performance: Vectorized numpy operations Accuracy: Double precision complex arithmetic """ def __init__(self, modal_db: ModalDatabase) # Core FRF methods def single_mode_frf( self, frequencies: np.ndarray, mode: ModeShape, output_type: str = 'displacement' ) -> np.ndarray # Complex FRF def base_excitation_frf( self, frequencies: np.ndarray, direction: str, output_type: str = 'acceleration', node_id: int = None, n_modes: int = None ) -> np.ndarray # Complex transmissibility def point_excitation_frf( self, frequencies: np.ndarray, input_node: int, input_direction: str, output_node: int, output_direction: str ) -> np.ndarray # Complex FRF # Stress FRF def stress_frf( self, frequencies: np.ndarray, element_id: int, direction: str ) -> np.ndarray # Complex stress transfer function ``` ### 4.3 RandomVibrationProcessor ```python class RandomVibrationProcessor: """ Computes random vibration response using modal superposition. This is the SATK-equivalent processor. Capabilities: - Grms and peak response calculation - Response PSD computation - Modal contribution analysis - Stress recovery with non-Gaussian correction - Multiple response locations Performance Target: <100ms for typical aerospace model """ def __init__(self, modal_db: ModalDatabase, default_damping: float = 0.02) # Main computation methods def compute_response( self, psd_profile: PSDProfile, direction: str = 'z', node_id: int = None, n_modes: int = None, frequency_resolution: int = 500, peak_sigma: float = 3.0, output_type: str = 'acceleration' ) -> RandomVibrationResult def compute_stress_response( self, psd_profile: PSDProfile, direction: str = 'z', element_ids: List[int] = None, peak_sigma: float = 3.0, non_gaussian_correction: bool = True ) -> Dict[str, Any] # Quick objective/constraint methods def compute_grms(self, psd_profile, direction, node_id=None) -> float def compute_peak_g(self, psd_profile, direction, sigma=3.0) -> float def compute_peak_stress(self, psd_profile, direction, sigma=3.0) -> float # Analysis methods def get_modal_contributions(self, psd_profile, direction) -> Dict[int, float] def get_response_psd(self, psd_profile, direction) -> Tuple[np.ndarray, np.ndarray] def identify_critical_modes(self, psd_profile, direction, threshold=0.1) -> List[int] ``` --- ## 5. Integration Points ### 5.1 Extractor Registry Add to `optimization_engine/extractors/__init__.py`: ```python # Dynamic response extractors from .extract_modal_database import extract_modal_database from .extract_random_response import ( extract_random_response, extract_random_stress, get_grms, get_peak_g, get_peak_stress ) from .extract_sine_response import extract_sine_response __all__ = [ # ... existing extractors ... # Dynamic response 'extract_modal_database', 'extract_random_response', 'extract_random_stress', 'get_grms', 'get_peak_g', 'get_peak_stress', 'extract_sine_response', ] ``` ### 5.2 Feature Registry Add to `optimization_engine/feature_registry.json`: ```json { "dynamic_response": { "random_vibration_processor": { "feature_id": "random_vibration_processor", "name": "Random Vibration Processor", "description": "Modal superposition-based random vibration response calculation", "category": "dynamics", "implementation": { "file_path": "optimization_engine/processors/dynamic_response/random_vibration.py", "class_name": "RandomVibrationProcessor" }, "interface": { "inputs": [ {"name": "modal_database", "type": "ModalDatabase"}, {"name": "psd_profile", "type": "PSDProfile"}, {"name": "direction", "type": "str"} ], "outputs": [ {"name": "grms", "type": "float"}, {"name": "peak_acceleration", "type": "float"}, {"name": "response_psd", "type": "np.ndarray"} ] } } } } ``` ### 5.3 Dashboard Integration New dashboard components for dynamic response: ```typescript // atomizer-dashboard/src/components/DynamicResponse/ ├── ResponsePSDPlot.tsx // Log-log PSD plot with input/output ├── TransmissibilityPlot.tsx // FRF magnitude vs frequency ├── ModalContributionBar.tsx // Bar chart of mode contributions ├── GrmsHistoryChart.tsx // Grms vs trial number ├── CampbellDiagram.tsx // For rotating machinery (future) └── DynamicResponseCard.tsx // Summary card for dashboard ``` ### 5.4 CLI Integration ```bash # New CLI commands atomizer dynamic extract model.op2 --psd GEVS --direction z atomizer dynamic psd-list atomizer dynamic psd-plot GEVS --output psd.png atomizer optimize --template random_vibration_optimization.json ``` --- ## 6. Validation Strategy ### 6.1 Unit Tests ```python # tests/test_dynamic_response/ test_modal_database.py ├── test_mode_shape_creation ├── test_op2_extraction ├── test_f06_parsing ├── test_mass_fraction_calculation └── test_hdf5_caching test_transfer_functions.py ├── test_sdof_frf_analytical ├── test_resonance_amplification ├── test_phase_at_resonance ├── test_multi_mode_combination └── test_base_excitation_transmissibility test_random_vibration.py ├── test_miles_equation_sdof ├── test_grms_calculation ├── test_peak_factor ├── test_non_gaussian_correction ├── test_modal_contributions └── test_stress_recovery test_psd_profiles.py ├── test_interpolation ├── test_grms_integration ├── test_standard_profiles └── test_csv_loading ``` ### 6.2 Integration Tests ```python # tests/integration/ test_dynamic_optimization.py ├── test_full_random_vib_optimization ├── test_multi_objective_dynamic ├── test_constraint_handling └── test_dashboard_updates test_nastran_comparison.py ├── test_vs_sol111_response ├── test_vs_sol112_random └── test_stress_recovery_accuracy ``` ### 6.3 Validation Cases | Case | Model | Validation Against | Acceptance | |------|-------|-------------------|------------| | SDOF | Single mass-spring | Miles' equation | <1% error | | Cantilever beam | Simple beam | SOL 111 FRF | <5% error | | Plate | Rectangular plate | SOL 112 random | <5% error | | Bracket | Real component | SATK results | <10% error | ### 6.4 Benchmark Against SATK If possible, run same model through both Atomizer and SATK: ``` Model: CubeSat bracket PSD: GEVS Direction: Z-axis Damping: 2% Compare: - Grms - Peak acceleration - Dominant mode - Response PSD shape - Peak stress location ``` --- ## 7. Performance Targets ### 7.1 Timing Budgets | Operation | Target | Notes | |-----------|--------|-------| | OP2 read (50 modes) | <2s | pyNastran | | F06 parse (MEFFMASS) | <0.5s | Regex parsing | | Modal DB construction | <1s | Data structuring | | FRF calculation (1000 freq) | <20ms | Vectorized | | Response PSD | <20ms | Element-wise | | Spectral integration | <10ms | Trapezoidal | | Stress recovery (1000 elements) | <200ms | Parallel possible | | **Total per trial** | **<3s** | Excluding SOL 103 | ### 7.2 Memory Budgets | Data | Size Estimate | Notes | |------|---------------|-------| | Modal DB (100 modes, 10k nodes) | ~50 MB | Full eigenvectors | | Modal DB (100 modes, 100 nodes) | ~5 MB | Sparse storage | | Response PSD (1000 freq) | <100 KB | Just arrays | | Stress shapes (100 modes, 1k elements) | ~50 MB | If needed | ### 7.3 Scalability Tested configurations: | Model Size | Modes | Nodes | Elements | Expected Time | |------------|-------|-------|----------|---------------| | Small | 50 | 5,000 | 10,000 | <2s | | Medium | 100 | 50,000 | 100,000 | <5s | | Large | 200 | 200,000 | 500,000 | <15s | | Very Large | 500 | 1,000,000 | 2,000,000 | <60s | --- ## 8. Risk Mitigation ### 8.1 Technical Risks | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| | pyNastran can't read NX OP2 | Low | High | Test early, have F06 fallback | | Modal stress not in OP2 | Medium | Medium | Request STRESS(MODAL) output | | Poor accuracy vs SOL 111 | Low | High | Validate thoroughly, document assumptions | | Memory issues large models | Medium | Medium | Lazy loading, sparse storage | | Complex modes (damped) | Low | Low | Detect and warn, use magnitude | ### 8.2 Mitigation Actions 1. **Early OP2 Testing**: Test pyNastran with NX 2412 OP2 files in week 1 2. **F06 Fallback**: Implement F06 parsing for critical data 3. **Validation Suite**: Build comprehensive test cases against SOL 111 4. **Memory Profiling**: Profile with large models, implement streaming if needed 5. **Documentation**: Clear documentation of assumptions and limitations --- ## 9. Future Extensions ### 9.1 Short-Term (3-6 months) - Sine sweep processor - Shock response spectrum - Multi-axis excitation - Response at multiple nodes - Automatic mode selection ### 9.2 Medium-Term (6-12 months) - Fatigue from random vibration - Acoustic loading (diffuse field) - Coupled structural-acoustic - Time-domain transient from PSD - Design sensitivity (∂response/∂parameter) ### 9.3 Long-Term (12+ months) - Neural surrogate for modal database - Real-time response visualization - Uncertainty quantification - Multi-physics coupling (thermal + dynamic) - Foundation model for structural dynamics --- ## 10. Success Metrics ### 10.1 Technical Metrics - [ ] Grms accuracy <5% vs SOL 111 - [ ] Peak stress accuracy <10% vs SOL 112 - [ ] Processing time <5s per trial - [ ] Memory <500MB for large models - [ ] 100% test coverage for core modules ### 10.2 User Metrics - [ ] Complete optimization in <1 hour (100 trials) - [ ] Dashboard shows real-time dynamic metrics - [ ] Documentation enables self-service usage - [ ] Template covers 80% of use cases ### 10.3 Business Metrics - [ ] Feature demo ready for marketing video - [ ] 3+ real-world optimization case studies - [ ] Positive feedback from beta users - [ ] Competitive differentiation documented --- ## Appendix A: Reference Equations ### A.1 Modal Superposition ``` Physical response: u(x,t) = Σᵢ φᵢ(x) · qᵢ(t) Modal equation of motion: q̈ᵢ + 2ζᵢωᵢq̇ᵢ + ωᵢ²qᵢ = Lᵢ·a_base(t) / mᵢ Frequency response function: Hᵢ(ω) = 1 / (ωᵢ² - ω² + j·2ζᵢωᵢω) ``` ### A.2 Random Vibration ``` Response PSD: Sᵧ(f) = |H(f)|² · Sₓ(f) Mean square: σ² = ∫₀^∞ Sᵧ(f) df Peak (Gaussian): peak = k · σ, k ≈ 3 for 3-sigma ``` ### A.3 Miles' Equation ``` For SDOF with white noise input: Grms = √(π/2 · fₙ · Q · PSD) Where: - fₙ = natural frequency (Hz) - Q = 1/(2ζ) = quality factor - PSD = input PSD level (G²/Hz) ``` --- ## Appendix B: File Formats ### B.1 OP2 Data Blocks | Table | Contains | Used For | |-------|----------|----------| | OUGV1 | Eigenvectors | Mode shapes | | LAMA | Eigenvalues | Frequencies | | OES1 | Element stress | Modal stress shapes | | OGPFB1 | Grid point forces | Participation | | OQMG1 | Modal participation | Effective mass | ### B.2 F06 Tables | Section | Contains | Parsing Pattern | |---------|----------|-----------------| | REAL EIGENVALUES | Frequencies, gen mass | Regex table | | MODAL EFFECTIVE MASS | Participation | Regex table | | MODAL PARTICIPATION FACTORS | L factors | Regex table | ### B.3 PSD CSV Format ```csv frequency_hz,psd_g2_hz 20,0.026 50,0.16 800,0.16 2000,0.026 ``` --- *Document End - Dynamic Response Processor Master Plan*