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:
233
optimization_engine/extractors/extractor_library.py
Normal file
233
optimization_engine/extractors/extractor_library.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Extractor Library Manager - Phase 3.2 Architecture Refactor
|
||||
|
||||
Manages a centralized library of reusable extractors to prevent code duplication
|
||||
and keep study folders clean.
|
||||
|
||||
Architecture Principles:
|
||||
1. Reusable extractors stored in optimization_engine/extractors/
|
||||
2. Study folders only contain metadata (which extractors were used)
|
||||
3. First-time generation adds to library with documentation
|
||||
4. Subsequent requests reuse existing library code
|
||||
|
||||
Author: Antoine Letarte
|
||||
Date: 2025-11-17
|
||||
Phase: 3.2 Architecture Refactor
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtractorLibrary:
|
||||
"""
|
||||
Centralized library of reusable FEA result extractors.
|
||||
|
||||
Prevents code duplication by maintaining a core library of extractors
|
||||
that can be reused across all optimization studies.
|
||||
"""
|
||||
|
||||
def __init__(self, library_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialize extractor library.
|
||||
|
||||
Args:
|
||||
library_dir: Directory for core extractor library
|
||||
(default: optimization_engine/extractors/)
|
||||
"""
|
||||
if library_dir is None:
|
||||
library_dir = Path(__file__).parent / "extractors"
|
||||
|
||||
self.library_dir = Path(library_dir)
|
||||
self.library_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create __init__.py for Python package
|
||||
init_file = self.library_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
init_file.write_text('"""Core extractor library for Atomizer."""\n')
|
||||
|
||||
# Library catalog - tracks all available extractors
|
||||
self.catalog_file = self.library_dir / "catalog.json"
|
||||
self.catalog = self._load_catalog()
|
||||
|
||||
logger.info(f"Extractor library initialized: {self.library_dir}")
|
||||
logger.info(f"Library contains {len(self.catalog)} extractors")
|
||||
|
||||
def _load_catalog(self) -> Dict[str, Any]:
|
||||
"""Load extractor catalog from disk."""
|
||||
if self.catalog_file.exists():
|
||||
with open(self.catalog_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _save_catalog(self):
|
||||
"""Save extractor catalog to disk."""
|
||||
with open(self.catalog_file, 'w') as f:
|
||||
json.dump(self.catalog, f, indent=2)
|
||||
|
||||
def _compute_signature(self, llm_feature: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Compute unique signature for an extractor based on its functionality.
|
||||
|
||||
Two extractors are considered identical if they have the same:
|
||||
- Action (e.g., extract_displacement)
|
||||
- Domain (e.g., result_extraction)
|
||||
- Key parameters (e.g., result_type, metric)
|
||||
"""
|
||||
# Normalize the feature specification
|
||||
signature_data = {
|
||||
'action': llm_feature.get('action', ''),
|
||||
'domain': llm_feature.get('domain', ''),
|
||||
'params': llm_feature.get('params', {})
|
||||
}
|
||||
|
||||
# Create deterministic hash
|
||||
signature_str = json.dumps(signature_data, sort_keys=True)
|
||||
return hashlib.sha256(signature_str.encode()).hexdigest()[:16]
|
||||
|
||||
def get_or_create(self, llm_feature: Dict[str, Any], extractor_code: str) -> Path:
|
||||
"""
|
||||
Get existing extractor from library or add new one.
|
||||
|
||||
Args:
|
||||
llm_feature: LLM feature specification (action, domain, params)
|
||||
extractor_code: Generated Python code for the extractor
|
||||
|
||||
Returns:
|
||||
Path to extractor module in core library
|
||||
"""
|
||||
# Compute signature to check if extractor already exists
|
||||
signature = self._compute_signature(llm_feature)
|
||||
|
||||
# Check if extractor already exists in library
|
||||
if signature in self.catalog:
|
||||
extractor_info = self.catalog[signature]
|
||||
extractor_file = self.library_dir / extractor_info['filename']
|
||||
|
||||
if extractor_file.exists():
|
||||
logger.info(f"Reusing existing extractor: {extractor_info['name']}")
|
||||
return extractor_file
|
||||
|
||||
# Create new extractor in library
|
||||
action = llm_feature.get('action', 'unknown_action')
|
||||
filename = f"{action}.py"
|
||||
extractor_file = self.library_dir / filename
|
||||
|
||||
# Write extractor code to library
|
||||
extractor_file.write_text(extractor_code)
|
||||
|
||||
# Add to catalog
|
||||
self.catalog[signature] = {
|
||||
'name': action,
|
||||
'filename': filename,
|
||||
'action': llm_feature.get('action'),
|
||||
'domain': llm_feature.get('domain'),
|
||||
'description': llm_feature.get('description', ''),
|
||||
'params': llm_feature.get('params', {}),
|
||||
'signature': signature
|
||||
}
|
||||
self._save_catalog()
|
||||
|
||||
logger.info(f"Added new extractor to library: {action}")
|
||||
return extractor_file
|
||||
|
||||
def get_extractor_metadata(self, signature: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get metadata for an extractor by its signature."""
|
||||
return self.catalog.get(signature)
|
||||
|
||||
def list_extractors(self) -> List[Dict[str, Any]]:
|
||||
"""List all extractors in the library."""
|
||||
return list(self.catalog.values())
|
||||
|
||||
def get_library_summary(self) -> str:
|
||||
"""Generate human-readable summary of library contents."""
|
||||
lines = []
|
||||
lines.append("=" * 80)
|
||||
lines.append("ATOMIZER EXTRACTOR LIBRARY")
|
||||
lines.append("=" * 80)
|
||||
lines.append("")
|
||||
lines.append(f"Location: {self.library_dir}")
|
||||
lines.append(f"Total extractors: {len(self.catalog)}")
|
||||
lines.append("")
|
||||
|
||||
if self.catalog:
|
||||
lines.append("Available Extractors:")
|
||||
lines.append("-" * 80)
|
||||
|
||||
for signature, info in self.catalog.items():
|
||||
lines.append(f"\n{info['name']}")
|
||||
lines.append(f" Domain: {info['domain']}")
|
||||
lines.append(f" Description: {info['description']}")
|
||||
lines.append(f" File: {info['filename']}")
|
||||
lines.append(f" Signature: {signature}")
|
||||
else:
|
||||
lines.append("Library is empty. Extractors will be added on first use.")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 80)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_study_manifest(extractors_used: List[str], output_dir: Path):
|
||||
"""
|
||||
Create a manifest file documenting which extractors were used in a study.
|
||||
|
||||
This replaces the old approach of copying extractor code into study folders.
|
||||
Now we just record which library extractors were used.
|
||||
|
||||
Args:
|
||||
extractors_used: List of extractor signatures used in this study
|
||||
output_dir: Study output directory
|
||||
"""
|
||||
manifest = {
|
||||
'extractors_used': extractors_used,
|
||||
'extractor_library': 'optimization_engine/extractors/',
|
||||
'note': 'Extractors are stored in the core library, not in this study folder'
|
||||
}
|
||||
|
||||
manifest_file = output_dir / "extractors_manifest.json"
|
||||
with open(manifest_file, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
logger.info(f"Study manifest created: {manifest_file}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
"""Test the extractor library system."""
|
||||
|
||||
# Initialize library
|
||||
library = ExtractorLibrary()
|
||||
|
||||
# Print summary
|
||||
print(library.get_library_summary())
|
||||
|
||||
# Test adding an extractor
|
||||
test_feature = {
|
||||
'action': 'extract_displacement',
|
||||
'domain': 'result_extraction',
|
||||
'description': 'Extract displacement from OP2 file',
|
||||
'params': {'result_type': 'displacement', 'metric': 'max'}
|
||||
}
|
||||
|
||||
test_code = '''"""Extract displacement from OP2 file."""
|
||||
def extract_displacement(op2_file):
|
||||
# Implementation here
|
||||
pass
|
||||
'''
|
||||
|
||||
extractor_path = library.get_or_create(test_feature, test_code)
|
||||
print(f"\nExtractor created/retrieved: {extractor_path}")
|
||||
|
||||
# Try to add it again - should reuse existing
|
||||
extractor_path2 = library.get_or_create(test_feature, test_code)
|
||||
print(f"Second call (should reuse): {extractor_path2}")
|
||||
|
||||
# Verify they're the same
|
||||
assert extractor_path == extractor_path2, "Should reuse existing extractor!"
|
||||
print("\n[SUCCESS] Extractor deduplication working correctly!")
|
||||
@@ -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