Files
Atomizer/optimization_engine/op2_extractor.py

279 lines
9.4 KiB
Python
Raw Normal View History

fix: Remove arbitrary aspect ratio validation and add comprehensive pruning diagnostics **Validation Changes (simulation_validator.py)**: - Removed arbitrary aspect ratio limits (5.0-50.0) for circular_plate model - User requirement: validation rules must be proposed, not automatic - Validator now returns empty rules for circular_plate - Relies solely on Optuna parameter bounds (user-defined feasibility) - Fixed Unicode encoding issues in pruning_logger.py **Root Cause Analysis**: - 18-20% pruning in Protocol 10 tests was NOT validation failures - All pruned trials had valid aspect ratios within bounds - Root cause: pyNastran FATAL flag false positives - Simulations succeeded but pyNastran rejected OP2 files **New Modules**: - pruning_logger.py: Comprehensive trial failure tracking - Logs validation, simulation, and OP2 extraction failures - Analyzes F06 files to detect false positives - Generates pruning_history.json and pruning_summary.json - op2_extractor.py: Robust multi-strategy OP2 extraction - Standard OP2 read - Lenient read (debug=False) - F06 fallback parsing - Handles pyNastran FATAL flag issues **Documentation**: - SESSION_SUMMARY_NOV20.md: Complete session documentation - FIX_VALIDATOR_PRUNING.md: Deprecated, retained for historical reference - PRUNING_DIAGNOSTICS.md: Usage guide for pruning diagnostics - STUDY_CONTINUATION_STANDARD.md: API documentation **Impact**: - Clean separation: parameter bounds = feasibility, validator = genuine failures - Expected pruning reduction from 18% to <2% with robust extraction - ~4-5 minutes saved per 50-trial study - All optimization trials contribute valid data **User Requirements Established**: 1. No arbitrary checks without user approval 2. Validation rules must be visible in optimization_config.json 3. Parameter bounds already define feasibility constraints 4. Physics-based constraints need clear justification
2025-11-20 20:25:33 -05:00
"""
Robust OP2 Extraction - Handles pyNastran FATAL flag issues gracefully.
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:
from optimization_engine.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 Optional, Tuple
import numpy as np
def robust_extract_first_frequency(
op2_file: Path,
mode_number: int = 1,
f06_file: Optional[Path] = None,
verbose: bool = False
) -> float:
"""
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 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:
Natural frequency in Hz
Raises:
ValueError: If frequency cannot be extracted by any method
"""
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)}"
)
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")
# Get first subcase
subcase = list(model.eigenvalues.keys())[0]
eig_obj = model.eigenvalues[subcase]
# 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
)