""" Modal Mass Extractor for Dynamic Analysis Results ================================================== Phase 3 Task 3.4 - NX Open Automation Roadmap Extracts modal effective mass and participation factors from Nastran F06 files. This is essential for dynamic optimization where mode shapes affect system response. Usage: from optimization_engine.extractors.extract_modal_mass import extract_modal_mass result = extract_modal_mass("path/to/modal.f06", mode=1) print(f"Modal mass X: {result['modal_mass_x']} kg") Supports: - SOL 103 (Normal Modes / Eigenvalue Analysis) - SOL 111 (Modal Frequency Response) - Modal effective mass table parsing - Participation factor extraction """ import re import numpy as np from pathlib import Path from typing import Dict, Any, Optional, List, Union, Tuple import logging logger = logging.getLogger(__name__) def extract_modal_mass( f06_file: Union[str, Path], mode: Optional[int] = None, direction: str = 'all' ) -> Dict[str, Any]: """ Extract modal effective mass from F06 file. Modal effective mass indicates how much of the total mass participates in each mode for each direction. Important for: - Base excitation problems - Seismic analysis - Random vibration Args: f06_file: Path to the F06 results file mode: Mode number to extract (1-indexed). If None, returns all modes. direction: Direction(s) to extract: 'x', 'y', 'z' - single direction 'all' - all directions (default) 'total' - sum of all directions Returns: dict: { 'success': bool, 'modes': list of mode data (if mode=None), 'modal_mass_x': float (kg) - effective mass in X, 'modal_mass_y': float (kg) - effective mass in Y, 'modal_mass_z': float (kg) - effective mass in Z, 'modal_mass_rx': float (kg·m²) - rotational mass about X, 'modal_mass_ry': float (kg·m²) - rotational mass about Y, 'modal_mass_rz': float (kg·m²) - rotational mass about Z, 'participation_x': float (0-1) - participation factor X, 'participation_y': float (0-1) - participation factor Y, 'participation_z': float (0-1) - participation factor Z, 'frequency': float (Hz) - natural frequency, 'cumulative_mass_x': float - cumulative mass fraction X, 'cumulative_mass_y': float - cumulative mass fraction Y, 'cumulative_mass_z': float - cumulative mass fraction Z, 'total_mass': float (kg) - total model mass, 'error': str or None } Example: >>> result = extract_modal_mass("modal_analysis.f06", mode=1) >>> if result['success']: ... print(f"Mode 1 frequency: {result['frequency']:.2f} Hz") ... print(f"X participation: {result['participation_x']*100:.1f}%") """ f06_path = Path(f06_file) if not f06_path.exists(): return { 'success': False, 'error': f"F06 file not found: {f06_path}", 'modes': [], 'modal_mass_x': None, 'modal_mass_y': None, 'modal_mass_z': None, 'frequency': None } try: with open(f06_path, 'r', errors='ignore') as f: content = f.read() # Parse modal effective mass table modes_data = _parse_modal_effective_mass(content) if not modes_data: # Try alternative format (participation factors) modes_data = _parse_participation_factors(content) if not modes_data: return { 'success': False, 'error': "No modal effective mass or participation factor table found in F06. " "Ensure MEFFMASS case control is present.", 'modes': [], 'modal_mass_x': None, 'modal_mass_y': None, 'modal_mass_z': None, 'frequency': None } # Extract total mass from F06 total_mass = _extract_total_mass(content) if mode is not None: # Return specific mode if mode < 1 or mode > len(modes_data): return { 'success': False, 'error': f"Mode {mode} not found. Available modes: 1-{len(modes_data)}", 'modes': modes_data, 'modal_mass_x': None, 'modal_mass_y': None, 'modal_mass_z': None, 'frequency': None } mode_data = modes_data[mode - 1] return { 'success': True, 'error': None, 'mode_number': mode, 'frequency': mode_data.get('frequency'), 'modal_mass_x': mode_data.get('mass_x'), 'modal_mass_y': mode_data.get('mass_y'), 'modal_mass_z': mode_data.get('mass_z'), 'modal_mass_rx': mode_data.get('mass_rx'), 'modal_mass_ry': mode_data.get('mass_ry'), 'modal_mass_rz': mode_data.get('mass_rz'), 'participation_x': mode_data.get('participation_x'), 'participation_y': mode_data.get('participation_y'), 'participation_z': mode_data.get('participation_z'), 'cumulative_mass_x': mode_data.get('cumulative_x'), 'cumulative_mass_y': mode_data.get('cumulative_y'), 'cumulative_mass_z': mode_data.get('cumulative_z'), 'total_mass': total_mass, 'modes': modes_data } else: # Return all modes summary return { 'success': True, 'error': None, 'mode_count': len(modes_data), 'modes': modes_data, 'total_mass': total_mass, 'frequencies': [m.get('frequency') for m in modes_data], 'dominant_mode_x': _find_dominant_mode(modes_data, 'x'), 'dominant_mode_y': _find_dominant_mode(modes_data, 'y'), 'dominant_mode_z': _find_dominant_mode(modes_data, 'z'), } except Exception as e: logger.exception(f"Error extracting modal mass from {f06_path}") return { 'success': False, 'error': str(e), 'modes': [], 'modal_mass_x': None, 'modal_mass_y': None, 'modal_mass_z': None, 'frequency': None } def _parse_modal_effective_mass(content: str) -> List[Dict[str, Any]]: """Parse modal effective mass table from F06 content.""" modes = [] # Pattern for modal effective mass table header # This varies by Nastran version, so we try multiple patterns # Pattern 1: Standard MEFFMASS output # MODE FREQUENCY T1 T2 T3 R1 R2 R3 pattern1 = re.compile( r'MODAL\s+EFFECTIVE\s+MASS.*?' r'MODE\s+FREQUENCY.*?' r'((?:\s*\d+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)', re.IGNORECASE | re.DOTALL ) # Pattern 2: Alternative format pattern2 = re.compile( r'EFFECTIVE\s+MASS\s+FRACTION.*?' r'((?:\s*\d+\s+[\d.E+-]+.*?\n)+)', re.IGNORECASE | re.DOTALL ) # Try to find modal effective mass table match = pattern1.search(content) if not match: match = pattern2.search(content) if match: table_text = match.group(1) lines = table_text.strip().split('\n') for line in lines: # Parse each mode line # Expected format: MODE FREQ T1 T2 T3 R1 R2 R3 (or subset) parts = line.split() if len(parts) >= 3: try: mode_num = int(parts[0]) frequency = float(parts[1]) mode_data = { 'mode': mode_num, 'frequency': frequency, 'mass_x': float(parts[2]) if len(parts) > 2 else 0.0, 'mass_y': float(parts[3]) if len(parts) > 3 else 0.0, 'mass_z': float(parts[4]) if len(parts) > 4 else 0.0, 'mass_rx': float(parts[5]) if len(parts) > 5 else 0.0, 'mass_ry': float(parts[6]) if len(parts) > 6 else 0.0, 'mass_rz': float(parts[7]) if len(parts) > 7 else 0.0, } modes.append(mode_data) except (ValueError, IndexError): continue return modes def _parse_participation_factors(content: str) -> List[Dict[str, Any]]: """Parse modal participation factors from F06 content.""" modes = [] # Pattern for eigenvalue/frequency table with participation # This is the more common output format eigenvalue_pattern = re.compile( r'R E A L\s+E I G E N V A L U E S.*?' r'MODE\s+NO\.\s+EXTRACTION\s+ORDER\s+EIGENVALUE\s+RADIANS\s+CYCLES\s+GENERALIZED\s+GENERALIZED\s*\n' r'\s+MASS\s+STIFFNESS\s*\n' r'((?:\s*\d+\s+\d+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)', re.IGNORECASE | re.DOTALL ) match = eigenvalue_pattern.search(content) if match: table_text = match.group(1) lines = table_text.strip().split('\n') for line in lines: parts = line.split() if len(parts) >= 5: try: mode_num = int(parts[0]) # parts[1] = extraction order # parts[2] = eigenvalue # parts[3] = radians frequency = float(parts[4]) # cycles (Hz) gen_mass = float(parts[5]) if len(parts) > 5 else 1.0 gen_stiff = float(parts[6]) if len(parts) > 6 else 0.0 mode_data = { 'mode': mode_num, 'frequency': frequency, 'generalized_mass': gen_mass, 'generalized_stiffness': gen_stiff, # Participation factors may need separate parsing 'mass_x': 0.0, 'mass_y': 0.0, 'mass_z': 0.0, } modes.append(mode_data) except (ValueError, IndexError): continue # Also try to find participation factor table participation_pattern = re.compile( r'MODAL\s+PARTICIPATION\s+FACTORS.*?' r'((?:\s*\d+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)', re.IGNORECASE | re.DOTALL ) match = participation_pattern.search(content) if match and modes: table_text = match.group(1) lines = table_text.strip().split('\n') for i, line in enumerate(lines): parts = line.split() if len(parts) >= 4 and i < len(modes): try: modes[i]['participation_x'] = float(parts[1]) modes[i]['participation_y'] = float(parts[2]) modes[i]['participation_z'] = float(parts[3]) except (ValueError, IndexError): pass return modes def _extract_total_mass(content: str) -> Optional[float]: """Extract total model mass from F06 content.""" # Pattern for total mass in OLOAD resultant or GPWG patterns = [ r'TOTAL\s+MASS\s*=\s*([\d.E+-]+)', r'MASS\s+TOTAL\s*:\s*([\d.E+-]+)', r'GPWG.*?MASS\s*=\s*([\d.E+-]+)', r'MASS\s+CENTER\s+OF\s+GRAVITY.*?MASS\s*=\s*([\d.E+-]+)', ] for pattern in patterns: match = re.search(pattern, content, re.IGNORECASE) if match: try: return float(match.group(1)) except ValueError: continue return None def _find_dominant_mode(modes: List[Dict], direction: str) -> Dict[str, Any]: """Find the mode with highest participation in given direction.""" if not modes: return {'mode': None, 'participation': 0.0} key = f'mass_{direction}' if direction in 'xyz' else f'participation_{direction}' max_participation = 0.0 dominant_mode = None for mode in modes: participation = mode.get(key, 0.0) or 0.0 if abs(participation) > max_participation: max_participation = abs(participation) dominant_mode = mode.get('mode') return { 'mode': dominant_mode, 'participation': max_participation, 'frequency': modes[dominant_mode - 1].get('frequency') if dominant_mode else None } def extract_frequencies( f06_file: Union[str, Path], n_modes: Optional[int] = None ) -> Dict[str, Any]: """ Extract natural frequencies from modal analysis F06 file. Simpler version of extract_modal_mass that just gets frequencies. Args: f06_file: Path to F06 file n_modes: Number of modes to extract (default: all) Returns: dict: { 'success': bool, 'frequencies': list of frequencies in Hz, 'mode_count': int, 'first_frequency': float, 'error': str or None } """ result = extract_modal_mass(f06_file, mode=None) if not result['success']: return { 'success': False, 'error': result['error'], 'frequencies': [], 'mode_count': 0, 'first_frequency': None } frequencies = result.get('frequencies', []) if n_modes is not None: frequencies = frequencies[:n_modes] return { 'success': True, 'error': None, 'frequencies': frequencies, 'mode_count': len(frequencies), 'first_frequency': frequencies[0] if frequencies else None } def get_first_frequency(f06_file: Union[str, Path]) -> float: """ Get first natural frequency from F06 file. Convenience function for optimization constraints. Returns 0 if extraction fails. Args: f06_file: Path to F06 file Returns: float: First natural frequency in Hz, or 0 on failure """ result = extract_modal_mass(f06_file, mode=1) if result['success'] and result.get('frequency'): return result['frequency'] else: logger.warning(f"Frequency extraction failed: {result.get('error')}") return 0.0 def get_modal_mass_ratio( f06_file: Union[str, Path], direction: str = 'z', n_modes: int = 10 ) -> float: """ Get cumulative modal mass ratio for first n modes. This indicates what fraction of total mass participates in the first n modes. Important for determining if enough modes are included. Args: f06_file: Path to F06 file direction: Direction ('x', 'y', or 'z') n_modes: Number of modes to include Returns: float: Cumulative mass ratio (0-1), or 0 on failure """ result = extract_modal_mass(f06_file, mode=None) if not result['success']: return 0.0 modes = result.get('modes', [])[:n_modes] key = f'mass_{direction}' total_participation = sum(abs(m.get(key, 0.0) or 0.0) for m in modes) total_mass = result.get('total_mass') if total_mass and total_mass > 0: return total_participation / total_mass else: return total_participation if __name__ == "__main__": import sys if len(sys.argv) > 1: f06_file = sys.argv[1] mode = int(sys.argv[2]) if len(sys.argv) > 2 else None print(f"Extracting modal mass from: {f06_file}") if mode: result = extract_modal_mass(f06_file, mode=mode) if result['success']: print(f"\n=== Mode {mode} Results ===") print(f"Frequency: {result['frequency']:.4f} Hz") print(f"Modal mass X: {result['modal_mass_x']}") print(f"Modal mass Y: {result['modal_mass_y']}") print(f"Modal mass Z: {result['modal_mass_z']}") else: print(f"Error: {result['error']}") else: result = extract_modal_mass(f06_file) if result['success']: print(f"\n=== Modal Analysis Results ===") print(f"Mode count: {result['mode_count']}") print(f"Total mass: {result['total_mass']}") print(f"\nFrequencies (Hz):") for i, freq in enumerate(result['frequencies'][:10], 1): print(f" Mode {i}: {freq:.4f} Hz") if len(result['frequencies']) > 10: print(f" ... and {len(result['frequencies']) - 10} more modes") else: print(f"Error: {result['error']}") else: print("Usage: python extract_modal_mass.py [mode_number]")