Phase 2 - Structural Analysis: - extract_principal_stress: σ1, σ2, σ3 principal stresses from OP2 - extract_strain_energy: Element and total strain energy - extract_spc_forces: Reaction forces at boundary conditions Phase 3 - Multi-Physics: - extract_temperature: Nodal temperatures from thermal OP2 (SOL 153/159) - extract_temperature_gradient: Thermal gradient approximation - extract_heat_flux: Element heat flux from thermal analysis - extract_modal_mass: Modal effective mass from F06 (SOL 103) - get_first_frequency: Convenience function for first natural frequency Documentation: - Updated SYS_12_EXTRACTOR_LIBRARY.md with E12-E18 specifications - Updated NX_OPEN_AUTOMATION_ROADMAP.md marking Phase 3 complete - Added test_phase3_extractors.py for validation All extractors follow consistent API pattern returning Dict with success, data, and error fields for robust error handling. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
492 lines
17 KiB
Python
492 lines
17 KiB
Python
"""
|
|
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 <f06_file> [mode_number]")
|