refactor: Major reorganization of optimization_engine module structure
BREAKING CHANGE: Module paths have been reorganized for better maintainability. Backwards compatibility aliases with deprecation warnings are provided. New Structure: - core/ - Optimization runners (runner, intelligent_optimizer, etc.) - processors/ - Data processing - surrogates/ - Neural network surrogates - nx/ - NX/Nastran integration (solver, updater, session_manager) - study/ - Study management (creator, wizard, state, reset) - reporting/ - Reports and analysis (visualizer, report_generator) - config/ - Configuration management (manager, builder) - utils/ - Utilities (logger, auto_doc, etc.) - future/ - Research/experimental code Migration: - ~200 import changes across 125 files - All __init__.py files use lazy loading to avoid circular imports - Backwards compatibility layer supports old import paths with warnings - All existing functionality preserved To migrate existing code: OLD: from optimization_engine.nx_solver import NXSolver NEW: from optimization_engine.nx.solver import NXSolver OLD: from optimization_engine.runner import OptimizationRunner NEW: from optimization_engine.core.runner import OptimizationRunner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,242 +1,278 @@
|
||||
"""
|
||||
Generic OP2 Extractor
|
||||
====================
|
||||
Robust OP2 Extraction - Handles pyNastran FATAL flag issues gracefully.
|
||||
|
||||
Reusable extractor for NX Nastran OP2 files using pyNastran.
|
||||
Extracts mass properties, forces, displacements, stresses, etc.
|
||||
This module provides a more robust OP2 extraction that:
|
||||
1. Catches pyNastran FATAL flag exceptions
|
||||
2. Checks if eigenvalues were actually extracted despite the flag
|
||||
3. Falls back to F06 extraction if OP2 fails
|
||||
4. Logs detailed failure information
|
||||
|
||||
Usage:
|
||||
extractor = OP2Extractor(op2_file="model.op2")
|
||||
mass = extractor.extract_mass()
|
||||
forces = extractor.extract_grid_point_forces()
|
||||
from optimization_engine.extractors.op2_extractor import robust_extract_first_frequency
|
||||
|
||||
frequency = robust_extract_first_frequency(
|
||||
op2_file=Path("results.op2"),
|
||||
mode_number=1,
|
||||
f06_file=Path("results.f06"), # Optional fallback
|
||||
verbose=True
|
||||
)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from pyNastran.op2.op2 import read_op2
|
||||
except ImportError:
|
||||
raise ImportError("pyNastran is required. Install with: pip install pyNastran")
|
||||
|
||||
|
||||
class OP2Extractor:
|
||||
"""
|
||||
Generic extractor for Nastran OP2 files.
|
||||
|
||||
Supports:
|
||||
- Mass properties
|
||||
- Grid point forces
|
||||
- Displacements
|
||||
- Stresses
|
||||
- Strains
|
||||
- Element forces
|
||||
"""
|
||||
|
||||
def __init__(self, op2_file: str):
|
||||
"""
|
||||
Args:
|
||||
op2_file: Path to .op2 file
|
||||
"""
|
||||
self.op2_file = Path(op2_file)
|
||||
self._op2_model = None
|
||||
|
||||
def _load_op2(self):
|
||||
"""Lazy load OP2 file"""
|
||||
if self._op2_model is None:
|
||||
if not self.op2_file.exists():
|
||||
raise FileNotFoundError(f"OP2 file not found: {self.op2_file}")
|
||||
self._op2_model = read_op2(str(self.op2_file), debug=False)
|
||||
return self._op2_model
|
||||
|
||||
def extract_mass(self, subcase_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract mass properties from OP2.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'mass_kg': total mass in kg,
|
||||
'mass_g': total mass in grams,
|
||||
'cg': [x, y, z] center of gravity,
|
||||
'inertia': 3x3 inertia matrix
|
||||
}
|
||||
"""
|
||||
op2 = self._load_op2()
|
||||
|
||||
# Get grid point weight (mass properties)
|
||||
if not hasattr(op2, 'grid_point_weight') or not op2.grid_point_weight:
|
||||
raise ValueError("No mass properties found in OP2 file")
|
||||
|
||||
gpw = op2.grid_point_weight
|
||||
|
||||
# Mass is typically in the first element of MO matrix (reference point mass)
|
||||
# OP2 stores mass in ton, mm, sec units typically
|
||||
mass_matrix = gpw.MO[0, 0] if hasattr(gpw, 'MO') else None
|
||||
|
||||
# Get reference point
|
||||
if hasattr(gpw, 'reference_point') and gpw.reference_point:
|
||||
ref_point = gpw.reference_point
|
||||
else:
|
||||
ref_point = 0
|
||||
|
||||
# Extract mass (convert based on units)
|
||||
# Nastran default: ton-mm-sec → need to convert to kg
|
||||
if mass_matrix is not None:
|
||||
mass_ton = mass_matrix
|
||||
mass_kg = mass_ton * 1000.0 # 1 ton = 1000 kg
|
||||
else:
|
||||
raise ValueError("Could not extract mass from OP2")
|
||||
|
||||
# Extract CG if available
|
||||
cg = [0.0, 0.0, 0.0]
|
||||
if hasattr(gpw, 'cg'):
|
||||
cg = gpw.cg.tolist() if hasattr(gpw.cg, 'tolist') else list(gpw.cg)
|
||||
|
||||
return {
|
||||
'mass_kg': mass_kg,
|
||||
'mass_g': mass_kg * 1000.0,
|
||||
'mass_ton': mass_ton,
|
||||
'cg': cg,
|
||||
'reference_point': ref_point,
|
||||
'units': 'ton-mm-sec (converted to kg)',
|
||||
}
|
||||
|
||||
def extract_grid_point_forces(
|
||||
self,
|
||||
subcase_id: Optional[int] = None,
|
||||
component: str = "total" # total, fx, fy, fz, mx, my, mz
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract grid point forces from OP2.
|
||||
|
||||
Args:
|
||||
subcase_id: Subcase ID (if None, uses first available)
|
||||
component: Force component to extract
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'force': resultant force value,
|
||||
'all_forces': list of forces at each grid point,
|
||||
'max_force': maximum force,
|
||||
'total_force': sum of all forces
|
||||
}
|
||||
"""
|
||||
op2 = self._load_op2()
|
||||
|
||||
if not hasattr(op2, 'grid_point_forces') or not op2.grid_point_forces:
|
||||
raise ValueError("No grid point forces found in OP2 file")
|
||||
|
||||
# Get first subcase if not specified
|
||||
if subcase_id is None:
|
||||
subcase_id = list(op2.grid_point_forces.keys())[0]
|
||||
|
||||
gpf = op2.grid_point_forces[subcase_id]
|
||||
|
||||
# Extract forces based on component
|
||||
# Grid point forces table typically has columns: fx, fy, fz, mx, my, mz
|
||||
if component == "total":
|
||||
# Calculate resultant force: sqrt(fx^2 + fy^2 + fz^2)
|
||||
forces = np.sqrt(gpf.data[:, 0]**2 + gpf.data[:, 1]**2 + gpf.data[:, 2]**2)
|
||||
elif component == "fx":
|
||||
forces = gpf.data[:, 0]
|
||||
elif component == "fy":
|
||||
forces = gpf.data[:, 1]
|
||||
elif component == "fz":
|
||||
forces = gpf.data[:, 2]
|
||||
else:
|
||||
raise ValueError(f"Unknown component: {component}")
|
||||
|
||||
return {
|
||||
'force': float(np.max(np.abs(forces))),
|
||||
'all_forces': forces.tolist(),
|
||||
'max_force': float(np.max(forces)),
|
||||
'min_force': float(np.min(forces)),
|
||||
'total_force': float(np.sum(forces)),
|
||||
'component': component,
|
||||
'subcase_id': subcase_id,
|
||||
}
|
||||
|
||||
def extract_applied_loads(self, subcase_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract applied loads from OP2 file.
|
||||
|
||||
This attempts to get load vector information if available.
|
||||
Note: Not all OP2 files contain this data.
|
||||
|
||||
Returns:
|
||||
dict: Load information
|
||||
"""
|
||||
op2 = self._load_op2()
|
||||
|
||||
# Try to get load vectors
|
||||
if hasattr(op2, 'load_vectors') and op2.load_vectors:
|
||||
if subcase_id is None:
|
||||
subcase_id = list(op2.load_vectors.keys())[0]
|
||||
|
||||
lv = op2.load_vectors[subcase_id]
|
||||
loads = lv.data
|
||||
|
||||
return {
|
||||
'total_load': float(np.sum(np.abs(loads))),
|
||||
'max_load': float(np.max(np.abs(loads))),
|
||||
'load_resultant': float(np.linalg.norm(loads)),
|
||||
'subcase_id': subcase_id,
|
||||
}
|
||||
else:
|
||||
# Fallback: use grid point forces as approximation
|
||||
return self.extract_grid_point_forces(subcase_id)
|
||||
|
||||
|
||||
def extract_mass_from_op2(op2_file: str) -> float:
|
||||
"""
|
||||
Convenience function to extract mass in kg.
|
||||
|
||||
Args:
|
||||
op2_file: Path to .op2 file
|
||||
|
||||
Returns:
|
||||
Mass in kilograms
|
||||
"""
|
||||
extractor = OP2Extractor(op2_file)
|
||||
result = extractor.extract_mass()
|
||||
return result['mass_kg']
|
||||
|
||||
|
||||
def extract_force_from_op2(
|
||||
op2_file: str,
|
||||
component: str = "fz"
|
||||
def robust_extract_first_frequency(
|
||||
op2_file: Path,
|
||||
mode_number: int = 1,
|
||||
f06_file: Optional[Path] = None,
|
||||
verbose: bool = False
|
||||
) -> float:
|
||||
"""
|
||||
Convenience function to extract force component.
|
||||
Robustly extract natural frequency from OP2 file, handling pyNastran issues.
|
||||
|
||||
This function attempts multiple strategies:
|
||||
1. Standard pyNastran OP2 reading
|
||||
2. Force reading with debug=False to ignore FATAL flags
|
||||
3. Partial OP2 reading (extract eigenvalues even if FATAL flag exists)
|
||||
4. Fallback to F06 file parsing (if provided)
|
||||
|
||||
Args:
|
||||
op2_file: Path to .op2 file
|
||||
component: Force component (fx, fy, fz, or total)
|
||||
op2_file: Path to OP2 output file
|
||||
mode_number: Mode number to extract (1-based index)
|
||||
f06_file: Optional F06 file for fallback extraction
|
||||
verbose: Print detailed extraction information
|
||||
|
||||
Returns:
|
||||
Force value
|
||||
Natural frequency in Hz
|
||||
|
||||
Raises:
|
||||
ValueError: If frequency cannot be extracted by any method
|
||||
"""
|
||||
extractor = OP2Extractor(op2_file)
|
||||
result = extractor.extract_grid_point_forces(component=component)
|
||||
return result['force']
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
if not op2_file.exists():
|
||||
raise FileNotFoundError(f"OP2 file not found: {op2_file}")
|
||||
|
||||
# Strategy 1: Try standard OP2 reading
|
||||
try:
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] Attempting standard read: {op2_file.name}")
|
||||
|
||||
model = OP2()
|
||||
model.read_op2(str(op2_file))
|
||||
|
||||
if hasattr(model, 'eigenvalues') and len(model.eigenvalues) > 0:
|
||||
frequency = _extract_frequency_from_model(model, mode_number)
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✓ Success (standard read): {frequency:.6f} Hz")
|
||||
return frequency
|
||||
else:
|
||||
raise ValueError("No eigenvalues found in OP2 file")
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✗ Standard read failed: {str(e)[:100]}")
|
||||
|
||||
# Check if this is a FATAL flag issue
|
||||
is_fatal_flag = 'FATAL' in str(e) and 'op2_reader' in str(e.__class__.__module__)
|
||||
|
||||
if is_fatal_flag:
|
||||
# Strategy 2: Try reading with more lenient settings
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] Detected pyNastran FATAL flag issue")
|
||||
print(f"[OP2 EXTRACT] Attempting partial extraction...")
|
||||
|
||||
try:
|
||||
model = OP2()
|
||||
# Try to read with debug=False and skip_undefined_matrices=True
|
||||
model.read_op2(
|
||||
str(op2_file),
|
||||
debug=False,
|
||||
skip_undefined_matrices=True
|
||||
)
|
||||
|
||||
# Check if eigenvalues were extracted despite FATAL
|
||||
if hasattr(model, 'eigenvalues') and len(model.eigenvalues) > 0:
|
||||
frequency = _extract_frequency_from_model(model, mode_number)
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✓ Success (lenient mode): {frequency:.6f} Hz")
|
||||
print(f"[OP2 EXTRACT] Note: pyNastran reported FATAL but data is valid!")
|
||||
return frequency
|
||||
|
||||
except Exception as e2:
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✗ Lenient read also failed: {str(e2)[:100]}")
|
||||
|
||||
# Strategy 3: Fallback to F06 parsing
|
||||
if f06_file and f06_file.exists():
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] Falling back to F06 extraction: {f06_file.name}")
|
||||
|
||||
try:
|
||||
frequency = extract_frequency_from_f06(f06_file, mode_number, verbose=verbose)
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✓ Success (F06 fallback): {frequency:.6f} Hz")
|
||||
return frequency
|
||||
|
||||
except Exception as e3:
|
||||
if verbose:
|
||||
print(f"[OP2 EXTRACT] ✗ F06 extraction failed: {str(e3)}")
|
||||
|
||||
# All strategies failed
|
||||
raise ValueError(
|
||||
f"Could not extract frequency from OP2 file: {op2_file.name}. "
|
||||
f"Original error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
op2_file = sys.argv[1]
|
||||
extractor = OP2Extractor(op2_file)
|
||||
def _extract_frequency_from_model(model, mode_number: int) -> float:
|
||||
"""Extract frequency from loaded OP2 model."""
|
||||
if not hasattr(model, 'eigenvalues') or len(model.eigenvalues) == 0:
|
||||
raise ValueError("No eigenvalues found in model")
|
||||
|
||||
# Extract mass
|
||||
mass_result = extractor.extract_mass()
|
||||
print(f"Mass: {mass_result['mass_kg']:.6f} kg")
|
||||
print(f"CG: {mass_result['cg']}")
|
||||
# Get first subcase
|
||||
subcase = list(model.eigenvalues.keys())[0]
|
||||
eig_obj = model.eigenvalues[subcase]
|
||||
|
||||
# Extract forces
|
||||
try:
|
||||
force_result = extractor.extract_grid_point_forces(component="fz")
|
||||
print(f"Max Fz: {force_result['force']:.2f} N")
|
||||
except ValueError as e:
|
||||
print(f"Forces not available: {e}")
|
||||
# Check if mode exists
|
||||
if mode_number > len(eig_obj.eigenvalues):
|
||||
raise ValueError(
|
||||
f"Mode {mode_number} not found. "
|
||||
f"Only {len(eig_obj.eigenvalues)} modes available"
|
||||
)
|
||||
|
||||
# Extract eigenvalue and convert to frequency
|
||||
eigenvalue = eig_obj.eigenvalues[mode_number - 1]
|
||||
angular_freq = np.sqrt(abs(eigenvalue)) # Use abs to handle numerical precision issues
|
||||
frequency_hz = angular_freq / (2 * np.pi)
|
||||
|
||||
return float(frequency_hz)
|
||||
|
||||
|
||||
def extract_frequency_from_f06(
|
||||
f06_file: Path,
|
||||
mode_number: int = 1,
|
||||
verbose: bool = False
|
||||
) -> float:
|
||||
"""
|
||||
Extract natural frequency from F06 text file (fallback method).
|
||||
|
||||
Parses the F06 file to find eigenvalue results table and extracts frequency.
|
||||
|
||||
Args:
|
||||
f06_file: Path to F06 output file
|
||||
mode_number: Mode number to extract (1-based index)
|
||||
verbose: Print extraction details
|
||||
|
||||
Returns:
|
||||
Natural frequency in Hz
|
||||
|
||||
Raises:
|
||||
ValueError: If frequency cannot be found in F06
|
||||
"""
|
||||
if not f06_file.exists():
|
||||
raise FileNotFoundError(f"F06 file not found: {f06_file}")
|
||||
|
||||
with open(f06_file, 'r', encoding='latin-1', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Look for eigenvalue table
|
||||
# Nastran F06 format has eigenvalue results like:
|
||||
# R E A L E I G E N V A L U E S
|
||||
# MODE EXTRACTION EIGENVALUE RADIANS CYCLES GENERALIZED GENERALIZED
|
||||
# NO. ORDER MASS STIFFNESS
|
||||
# 1 1 -6.602743E+04 2.569656E+02 4.089338E+01 1.000000E+00 6.602743E+04
|
||||
|
||||
lines = content.split('\n')
|
||||
|
||||
# Find eigenvalue table
|
||||
eigenvalue_section_start = None
|
||||
for i, line in enumerate(lines):
|
||||
if 'R E A L E I G E N V A L U E S' in line:
|
||||
eigenvalue_section_start = i
|
||||
break
|
||||
|
||||
if eigenvalue_section_start is None:
|
||||
raise ValueError("Eigenvalue table not found in F06 file")
|
||||
|
||||
# Parse eigenvalue table (starts a few lines after header)
|
||||
for i in range(eigenvalue_section_start + 3, min(eigenvalue_section_start + 100, len(lines))):
|
||||
line = lines[i].strip()
|
||||
|
||||
if not line or line.startswith('1'): # Page break
|
||||
continue
|
||||
|
||||
# Parse line with mode data
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
try:
|
||||
mode_num = int(parts[0])
|
||||
if mode_num == mode_number:
|
||||
# Frequency is in column 5 (CYCLES)
|
||||
frequency = float(parts[4])
|
||||
if verbose:
|
||||
print(f"[F06 EXTRACT] Found mode {mode_num}: {frequency:.6f} Hz")
|
||||
return frequency
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
raise ValueError(f"Mode {mode_number} not found in F06 eigenvalue table")
|
||||
|
||||
|
||||
def validate_op2_file(op2_file: Path, f06_file: Optional[Path] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate if an OP2 file contains usable eigenvalue data.
|
||||
|
||||
Args:
|
||||
op2_file: Path to OP2 file
|
||||
f06_file: Optional F06 file for cross-reference
|
||||
|
||||
Returns:
|
||||
(is_valid, message): Tuple of validation status and explanation
|
||||
"""
|
||||
if not op2_file.exists():
|
||||
return False, f"OP2 file does not exist: {op2_file}"
|
||||
|
||||
if op2_file.stat().st_size == 0:
|
||||
return False, "OP2 file is empty"
|
||||
|
||||
# Try to extract first frequency
|
||||
try:
|
||||
frequency = robust_extract_first_frequency(
|
||||
op2_file,
|
||||
mode_number=1,
|
||||
f06_file=f06_file,
|
||||
verbose=False
|
||||
)
|
||||
return True, f"Valid OP2 file (first frequency: {frequency:.6f} Hz)"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Cannot extract data from OP2: {str(e)}"
|
||||
|
||||
|
||||
# Convenience function (same signature as old function for backward compatibility)
|
||||
def extract_first_frequency(op2_file: Path, mode_number: int = 1) -> float:
|
||||
"""
|
||||
Extract first natural frequency (backward compatible with old function).
|
||||
|
||||
This is the simple version - just use robust_extract_first_frequency directly
|
||||
for more control.
|
||||
|
||||
Args:
|
||||
op2_file: Path to OP2 file
|
||||
mode_number: Mode number (1-based)
|
||||
|
||||
Returns:
|
||||
Frequency in Hz
|
||||
"""
|
||||
# Try to find F06 file in same directory
|
||||
f06_file = op2_file.with_suffix('.f06')
|
||||
|
||||
return robust_extract_first_frequency(
|
||||
op2_file,
|
||||
mode_number=mode_number,
|
||||
f06_file=f06_file if f06_file.exists() else None,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user