From 16cddd5243732d467eb2d2cc55e64a77a4262f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 13:49:16 +0000 Subject: [PATCH] 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 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 --- mcp_server/tools/model_discovery.py | 55 +++- .../op2_extractor_example.py | 236 ++++++++++++++++++ 2 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 optimization_engine/result_extractors/op2_extractor_example.py diff --git a/mcp_server/tools/model_discovery.py b/mcp_server/tools/model_discovery.py index b05af260..9425c779 100644 --- a/mcp_server/tools/model_discovery.py +++ b/mcp_server/tools/model_discovery.py @@ -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+)?)' diff --git a/optimization_engine/result_extractors/op2_extractor_example.py b/optimization_engine/result_extractors/op2_extractor_example.py new file mode 100644 index 00000000..f76df09e --- /dev/null +++ b/optimization_engine/result_extractors/op2_extractor_example.py @@ -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 ") + 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']}")