feat: Comprehensive expression extraction and OP2 result extractor example

Enhanced expression extraction to find ALL named expressions in .prt files,
not just specific format. Added pyNastran-based result extraction example.

Expression Extraction Improvements:
- Updated regex to handle all NX expression format variations:
  * #(Type [units]) name: value;
  * (Type [units]) name: value;
  * *(Type [units]) name: value;
  * ((Type [units]) name: value;
- Added Root:expression_name: pattern detection
- Finds expressions even when value is not immediately available
- Deduplication to avoid duplicates
- Filters out NX internal names

Test Results with Bracket.prt:
- Previously: 1 expression (tip_thickness only)
- Now: 5 expressions found:
  * support_angle = 30.0 degrees
  * tip_thickness = 20.0 mm
  * p3 = 10.0 mm
  * support_blend_radius = 10.0 mm
  * p11 (reference found, value unknown)

OP2 Result Extraction (pyNastran):
- Created example extractor: op2_extractor_example.py
- Functions for common optimization metrics:
  * extract_max_displacement() - max displacement magnitude on any node
  * extract_max_stress() - von Mises or max principal stress
  * extract_mass() - total mass and center of gravity
- Handles multiple element types (CQUAD4, CTRIA3, CTETRA, etc.)
- Returns structured JSON for optimization engine integration
- Command-line tool for testing with real OP2 files

Usage:
  python optimization_engine/result_extractors/op2_extractor_example.py <file.op2>

Integration Ready:
- pyNastran already in requirements.txt
- Result extractor pattern established
- Can be used as template for custom metrics

Next Steps:
- Integrate result extractors into MCP tool framework
- Add safety factor calculations
- Support for thermal, modal results
This commit is contained in:
Claude
2025-11-15 13:49:16 +00:00
parent 063439af43
commit 16cddd5243
2 changed files with 280 additions and 11 deletions

View File

@@ -222,21 +222,54 @@ class SimFileParser:
# Try to decode as latin-1 (preserves all byte values)
text_content = content.decode('latin-1', errors='ignore')
# Pattern 1: NX native format: #(Number [mm]) tip_thickness: 20;
# Captures: type, units, name, value
nx_pattern = r'#\((\w+)\s*\[([^\]]*)\]\)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
# Pattern 1: NX native format with variations:
# #(Number [mm]) tip_thickness: 20;
# (Number [mm]) p3: 10;
# *(Number [mm]) support_blend_radius: 10;
# ((Number [degrees]) support_angle: 30;
# Prefix can be: #(, *(, (, ((
nx_pattern = r'[#*\(]*\((\w+)\s*\[([^\]]*)\]\)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'
# Use set to avoid duplicates
expr_names_seen = set()
for match in re.finditer(nx_pattern, text_content):
expr_type, units, name, value = match.groups()
expressions.append({
'name': name,
'value': float(value),
'units': units,
'type': expr_type,
'source': 'prt_file_nx_format'
})
if name not in expr_names_seen:
expr_names_seen.add(name)
expressions.append({
'name': name,
'value': float(value),
'units': units,
'type': expr_type,
'source': 'prt_file_nx_format'
})
# Pattern 2: Fallback - simple name=value pattern
# Pattern 2: Find expression names from Root: references
# Format: Root:expression_name:
root_pattern = r'Root:([a-zA-Z_][a-zA-Z0-9_]{2,}):'
potential_expr_names = set()
for match in re.finditer(root_pattern, text_content):
name = match.group(1)
# Filter out common NX internal names
if name not in ['index', '%%Name', '%%ug_objects_for_', 'WorldModifier']:
if not name.startswith('%%'):
potential_expr_names.add(name)
# For names found in Root: but not in value patterns,
# mark as "found but value unknown"
for name in potential_expr_names:
if name not in expr_names_seen:
expressions.append({
'name': name,
'value': None,
'units': '',
'type': 'Unknown',
'source': 'prt_file_reference_only'
})
# Pattern 3: Fallback - simple name=value pattern
# Only use if no NX-format expressions found
if not expressions:
simple_pattern = r'([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)'

View File

@@ -0,0 +1,236 @@
"""
Example: Result Extraction from OP2 files using pyNastran
This shows how to extract optimization metrics from Nastran OP2 files.
Common metrics:
- Max displacement (for stiffness constraints)
- Max von Mises stress (for strength constraints)
- Mass (for minimization objectives)
"""
from pathlib import Path
from typing import Dict, Any
import numpy as np
def extract_max_displacement(op2_path: Path) -> Dict[str, Any]:
"""
Extract maximum displacement magnitude from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with max displacement, node ID, and components
"""
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_path))
# Get first subcase (usually the only one in static analysis)
subcase_id = list(op2.displacements.keys())[0]
displacements = op2.displacements[subcase_id]
# Extract node IDs and displacement data
node_ids = displacements.node_gridtype[:, 0].astype(int)
disp_data = displacements.data[0] # First (and usually only) timestep
# Calculate magnitude: sqrt(dx^2 + dy^2 + dz^2)
dx = disp_data[:, 0]
dy = disp_data[:, 1]
dz = disp_data[:, 2]
magnitudes = np.sqrt(dx**2 + dy**2 + dz**2)
# Find max
max_idx = np.argmax(magnitudes)
max_displacement = magnitudes[max_idx]
max_node_id = node_ids[max_idx]
return {
'max_displacement': float(max_displacement),
'max_node_id': int(max_node_id),
'dx': float(dx[max_idx]),
'dy': float(dy[max_idx]),
'dz': float(dz[max_idx]),
'units': 'mm', # NX typically uses mm
'subcase': subcase_id
}
def extract_max_stress(op2_path: Path, stress_type: str = 'von_mises') -> Dict[str, Any]:
"""
Extract maximum stress from OP2 file.
Args:
op2_path: Path to .op2 file
stress_type: 'von_mises' or 'max_principal'
Returns:
Dictionary with max stress, element ID, and location
"""
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_path))
# Stress can be in different tables depending on element type
# Common: cquad4_stress, ctria3_stress, ctetra_stress, etc.
stress_tables = [
'cquad4_stress',
'ctria3_stress',
'ctetra_stress',
'chexa_stress',
'cbar_stress',
'cbeam_stress'
]
max_stress_overall = 0.0
max_element_id = None
max_element_type = None
for table_name in stress_tables:
if hasattr(op2, table_name):
stress_table = getattr(op2, table_name)
if stress_table:
subcase_id = list(stress_table.keys())[0]
stress_data = stress_table[subcase_id]
# Extract von Mises stress
# Note: Structure varies by element type
element_ids = stress_data.element_node[:, 0].astype(int)
if stress_type == 'von_mises':
# von Mises is usually last column
stresses = stress_data.data[0, :, -1] # timestep 0, all elements, last column
else:
# Max principal stress (second-to-last column typically)
stresses = stress_data.data[0, :, -2]
max_stress_in_table = np.max(stresses)
if max_stress_in_table > max_stress_overall:
max_stress_overall = max_stress_in_table
max_idx = np.argmax(stresses)
max_element_id = element_ids[max_idx]
max_element_type = table_name.replace('_stress', '')
return {
'max_stress': float(max_stress_overall),
'stress_type': stress_type,
'element_id': int(max_element_id) if max_element_id else None,
'element_type': max_element_type,
'units': 'MPa', # NX typically uses MPa
}
def extract_mass(op2_path: Path) -> Dict[str, Any]:
"""
Extract total mass from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with mass and center of gravity
"""
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_path))
# Mass is in grid_point_weight table
if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight:
mass_data = op2.grid_point_weight
# Total mass
total_mass = mass_data.mass.sum()
# Center of gravity
cg = mass_data.cg
return {
'total_mass': float(total_mass),
'cg_x': float(cg[0]),
'cg_y': float(cg[1]),
'cg_z': float(cg[2]),
'units': 'kg'
}
else:
# Fallback: Mass not directly available
return {
'total_mass': None,
'note': 'Mass data not found in OP2 file. Ensure PARAM,GRDPNT,0 is in Nastran deck'
}
# Combined extraction function for optimization
def extract_all_results(op2_path: Path) -> Dict[str, Any]:
"""
Extract all common optimization metrics from OP2 file.
Args:
op2_path: Path to .op2 file
Returns:
Dictionary with all results
"""
results = {
'op2_file': str(op2_path),
'status': 'success'
}
try:
results['displacement'] = extract_max_displacement(op2_path)
except Exception as e:
results['displacement'] = {'error': str(e)}
try:
results['stress'] = extract_max_stress(op2_path)
except Exception as e:
results['stress'] = {'error': str(e)}
try:
results['mass'] = extract_mass(op2_path)
except Exception as e:
results['mass'] = {'error': str(e)}
return results
# Example usage
if __name__ == "__main__":
import sys
import json
if len(sys.argv) < 2:
print("Usage: python op2_extractor_example.py <path_to_op2_file>")
sys.exit(1)
op2_path = Path(sys.argv[1])
if not op2_path.exists():
print(f"Error: File not found: {op2_path}")
sys.exit(1)
print(f"Extracting results from: {op2_path}")
print("=" * 60)
results = extract_all_results(op2_path)
print("\nResults:")
print(json.dumps(results, indent=2))
# Summary
print("\n" + "=" * 60)
print("SUMMARY:")
if 'displacement' in results and 'max_displacement' in results['displacement']:
disp = results['displacement']
print(f" Max Displacement: {disp['max_displacement']:.6f} {disp['units']} at node {disp['max_node_id']}")
if 'stress' in results and 'max_stress' in results['stress']:
stress = results['stress']
print(f" Max {stress['stress_type']}: {stress['max_stress']:.2f} {stress['units']} in element {stress['element_id']}")
if 'mass' in results and 'total_mass' in results['mass'] and results['mass']['total_mass']:
mass = results['mass']
print(f" Total Mass: {mass['total_mass']:.6f} {mass['units']}")