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

35 KiB
Raw Blame History

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

# 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

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

"""
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

"""
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

"""
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

"""
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

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)

# 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

"""
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:

# 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.*