Files
Atomizer/optimization_engine/extractors/extract_modal_mass.py

492 lines
17 KiB
Python
Raw Normal View History

"""
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·) - rotational mass about X,
'modal_mass_ry': float (kg·) - rotational mass about Y,
'modal_mass_rz': float (kg·) - 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 <f06_file> [mode_number]")