Files
Atomizer/docs/plans/DYNAMIC_RESPONSE_IMPLEMENTATION_PLAN.md
Anto01 c061146a77 docs: Final documentation polish and consistency fixes
- Update README.md with LLM assistant section
- Create optimization_memory JSONL structure
- Move implementation plans from skills/modules to docs/plans
- Verify all imports work correctly

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:07:44 -05:00

1085 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.*