refactor: Engine updates and NX hooks improvements

optimization_engine:
- Updated nx_solver.py with improvements
- Enhanced solve_simulation.py
- Updated extractors/__init__.py
- Improved NX CAD hooks (expression_manager, feature_manager,
  geometry_query, model_introspection, part_manager)
- Enhanced NX CAE solver_manager hook

Documentation:
- Updated OP_01_CREATE_STUDY.md protocol
- Updated SYS_12_EXTRACTOR_LIBRARY.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 13:47:21 -05:00
parent 7c700c4606
commit 274081d977
12 changed files with 698 additions and 48 deletions

View File

@@ -10,6 +10,7 @@ Available extractors:
- Strain Energy: extract_strain_energy, extract_total_strain_energy
- SPC Forces: extract_spc_forces, extract_total_reaction_force
- Zernike: extract_zernike_from_op2, ZernikeExtractor (telescope mirrors)
- Part Introspection: introspect_part (comprehensive NX .prt analysis)
Phase 2 Extractors (2025-12-06):
- Principal stress extraction (sigma1, sigma2, sigma3)
@@ -21,6 +22,9 @@ Phase 3 Extractors (2025-12-06):
- Thermal gradient extraction
- Heat flux extraction
- Modal mass extraction (modal effective mass from F06)
Phase 4 Extractors (2025-12-19):
- Part Introspection (E12): Comprehensive .prt analysis (expressions, mass, materials, attributes, groups, features)
"""
# Zernike extractor for telescope mirror optimization
@@ -82,6 +86,14 @@ from optimization_engine.extractors.extract_modal_mass import (
get_modal_mass_ratio,
)
# Part introspection (Phase 4) - comprehensive .prt analysis
from optimization_engine.extractors.introspect_part import (
introspect_part,
get_expressions_dict,
get_expression_value,
print_introspection_summary,
)
__all__ = [
# Part mass & material (from .prt)
'extract_part_mass_material',
@@ -117,4 +129,9 @@ __all__ = [
'extract_frequencies',
'get_first_frequency',
'get_modal_mass_ratio',
# Part introspection (Phase 4)
'introspect_part',
'get_expressions_dict',
'get_expression_value',
'print_introspection_summary',
]

View File

@@ -0,0 +1,297 @@
"""
Part Introspection Extractor (E12)
===================================
Comprehensive introspection of NX .prt files. Extracts:
- All expressions (user and internal)
- Mass properties (mass, volume, surface area, CoG)
- Material properties
- Body information
- Part attributes
- Groups
- Features summary
- Datum planes and coordinate systems
- Unit system
- Linked/associated parts
Usage:
from optimization_engine.extractors.introspect_part import introspect_part
result = introspect_part("path/to/model.prt")
print(f"Mass: {result['mass_properties']['mass_kg']} kg")
print(f"Expressions: {result['expressions']['user_count']}")
Dependencies:
- NX installed with run_journal.exe
- SPLM_LICENSE_SERVER environment variable
Author: Atomizer
Created: 2025-12-19
Version: 1.0
"""
import os
import json
import subprocess
import time
from pathlib import Path
from typing import Dict, Any, Optional
# NX installation path
NX_INSTALL_PATH = r"C:\Program Files\Siemens\DesigncenterNX2512"
RUN_JOURNAL_EXE = os.path.join(NX_INSTALL_PATH, "NXBIN", "run_journal.exe")
JOURNAL_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"nx_journals", "introspect_part.py")
# License server
LICENSE_SERVER = "28000@dalidou;28000@100.80.199.40"
def introspect_part(
prt_file_path: str,
output_dir: Optional[str] = None,
timeout_seconds: int = 120,
verbose: bool = True
) -> Dict[str, Any]:
"""
Run comprehensive introspection on an NX .prt file.
Args:
prt_file_path: Path to the .prt file
output_dir: Directory for output JSON (defaults to prt directory)
timeout_seconds: Maximum time to wait for journal execution
verbose: Print progress messages
Returns:
Dictionary with introspection results:
{
'success': bool,
'part_file': str,
'expressions': {
'user': [...],
'internal': [...],
'user_count': int,
'total_count': int
},
'mass_properties': {
'mass_kg': float,
'mass_g': float,
'volume_mm3': float,
'surface_area_mm2': float,
'center_of_gravity_mm': [x, y, z]
},
'materials': {
'assigned': [...],
'available': [...]
},
'bodies': {
'solid_bodies': [...],
'sheet_bodies': [...],
'counts': {...}
},
'attributes': [...],
'groups': [...],
'features': {
'total_count': int,
'by_type': {...}
},
'datums': {...},
'units': {...},
'linked_parts': {...}
}
Raises:
FileNotFoundError: If prt file doesn't exist
RuntimeError: If journal execution fails
"""
prt_path = Path(prt_file_path)
if not prt_path.exists():
raise FileNotFoundError(f"Part file not found: {prt_file_path}")
if output_dir is None:
output_dir = str(prt_path.parent)
output_file = os.path.join(output_dir, "_temp_introspection.json")
# Remove old output file if exists
if os.path.exists(output_file):
os.remove(output_file)
if verbose:
print(f"[INTROSPECT] Part: {prt_path.name}")
print(f"[INTROSPECT] Output: {output_dir}")
# Build PowerShell command (learned workaround - see LAC)
# Using [Environment]::SetEnvironmentVariable() for reliable license server setting
ps_command = (
f"[Environment]::SetEnvironmentVariable('SPLM_LICENSE_SERVER', '{LICENSE_SERVER}', 'Process'); "
f"& '{RUN_JOURNAL_EXE}' '{JOURNAL_PATH}' -args '{prt_file_path}' '{output_dir}' 2>&1"
)
cmd = ["powershell", "-Command", ps_command]
if verbose:
print(f"[INTROSPECT] Executing NX journal...")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_seconds
)
if verbose and result.stdout:
# Print key lines from output
for line in result.stdout.split('\n'):
if '[INTROSPECT]' in line:
print(line)
if result.returncode != 0 and verbose:
print(f"[INTROSPECT] Warning: Non-zero return code: {result.returncode}")
if result.stderr:
print(f"[INTROSPECT] Stderr: {result.stderr[:500]}")
except subprocess.TimeoutExpired:
raise RuntimeError(f"Journal execution timed out after {timeout_seconds}s")
except Exception as e:
raise RuntimeError(f"Journal execution failed: {e}")
# Wait for output file (NX may still be writing)
wait_start = time.time()
while not os.path.exists(output_file) and (time.time() - wait_start) < 10:
time.sleep(0.5)
if not os.path.exists(output_file):
raise RuntimeError(f"Introspection output file not created: {output_file}")
# Read and return results
with open(output_file, 'r') as f:
results = json.load(f)
if verbose:
if results.get('success'):
print(f"[INTROSPECT] Success!")
print(f"[INTROSPECT] Expressions: {results['expressions']['user_count']} user")
print(f"[INTROSPECT] Mass: {results['mass_properties']['mass_kg']:.4f} kg")
else:
print(f"[INTROSPECT] Failed: {results.get('error', 'Unknown error')}")
return results
def get_expressions_dict(introspection_result: Dict[str, Any]) -> Dict[str, float]:
"""
Convert introspection result to simple {name: value} dictionary of user expressions.
Args:
introspection_result: Result from introspect_part()
Returns:
Dictionary mapping expression names to values
"""
expressions = {}
for expr in introspection_result.get('expressions', {}).get('user', []):
name = expr.get('name')
value = expr.get('value')
if name and value is not None:
expressions[name] = value
return expressions
def get_expression_value(introspection_result: Dict[str, Any], name: str) -> Optional[float]:
"""
Get a specific expression value from introspection result.
Args:
introspection_result: Result from introspect_part()
name: Expression name
Returns:
Expression value or None if not found
"""
for expr in introspection_result.get('expressions', {}).get('user', []):
if expr.get('name') == name:
return expr.get('value')
return None
def print_introspection_summary(result: Dict[str, Any]) -> None:
"""Print a formatted summary of introspection results."""
print("\n" + "="*60)
print(f"INTROSPECTION SUMMARY: {result.get('part_file', 'Unknown')}")
print("="*60)
# Mass
mp = result.get('mass_properties', {})
print(f"\nMASS PROPERTIES:")
print(f" Mass: {mp.get('mass_kg', 0):.4f} kg ({mp.get('mass_g', 0):.2f} g)")
print(f" Volume: {mp.get('volume_mm3', 0):.2f} mm³")
print(f" Surface Area: {mp.get('surface_area_mm2', 0):.2f} mm²")
cog = mp.get('center_of_gravity_mm', [0, 0, 0])
print(f" Center of Gravity: [{cog[0]:.2f}, {cog[1]:.2f}, {cog[2]:.2f}] mm")
# Bodies
bodies = result.get('bodies', {})
counts = bodies.get('counts', {})
print(f"\nBODIES:")
print(f" Solid: {counts.get('solid', 0)}")
print(f" Sheet: {counts.get('sheet', 0)}")
# Materials
mats = result.get('materials', {})
print(f"\nMATERIALS:")
for mat in mats.get('assigned', []):
print(f" {mat.get('name', 'Unknown')}")
props = mat.get('properties', {})
if 'Density' in props:
print(f" Density: {props['Density']}")
if 'YoungModulus' in props:
print(f" Young's Modulus: {props['YoungModulus']}")
# Expressions
exprs = result.get('expressions', {})
print(f"\nEXPRESSIONS: {exprs.get('user_count', 0)} user, {len(exprs.get('internal', []))} internal")
user_exprs = exprs.get('user', [])
if user_exprs:
print(" User expressions (first 10):")
for expr in user_exprs[:10]:
units = f" [{expr.get('units', '')}]" if expr.get('units') else ""
print(f" {expr.get('name')}: {expr.get('value')}{units}")
if len(user_exprs) > 10:
print(f" ... and {len(user_exprs) - 10} more")
# Features
feats = result.get('features', {})
print(f"\nFEATURES: {feats.get('total_count', 0)} total")
by_type = feats.get('by_type', {})
if by_type:
top_types = sorted(by_type.items(), key=lambda x: x[1], reverse=True)[:5]
for feat_type, count in top_types:
print(f" {feat_type}: {count}")
# Units
units = result.get('units', {})
print(f"\nUNITS: {units.get('system', 'Unknown')}")
print("="*60 + "\n")
# CLI interface
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python introspect_part.py <prt_file_path> [output_dir]")
sys.exit(1)
prt_path = sys.argv[1]
out_dir = sys.argv[2] if len(sys.argv) > 2 else None
try:
result = introspect_part(prt_path, out_dir, verbose=True)
print_introspection_summary(result)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)

View File

@@ -36,11 +36,15 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple, Union
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# Journal template for expression operations
EXPRESSION_OPERATIONS_JOURNAL = '''

View File

@@ -34,11 +34,15 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# Journal template for feature operations
FEATURE_OPERATIONS_JOURNAL = '''

View File

@@ -30,11 +30,15 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# Journal template for geometry query operations
GEOMETRY_QUERY_JOURNAL = '''

View File

@@ -32,11 +32,16 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, List
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
# Fallback if config not available
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# =============================================================================

View File

@@ -31,11 +31,15 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any, Tuple
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# Journal template for part operations
PART_OPERATIONS_JOURNAL = '''

View File

@@ -32,11 +32,15 @@ import tempfile
from pathlib import Path
from typing import Optional, Dict, Any
# NX installation path (configurable)
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\NX2506\NXBIN"
)
# Import NX path from centralized config
try:
from config import NX_BIN_DIR
NX_BIN_PATH = str(NX_BIN_DIR)
except ImportError:
NX_BIN_PATH = os.environ.get(
"NX_BIN_PATH",
r"C:\Program Files\Siemens\DesigncenterNX2512\NXBIN"
)
# Journal template for BDF export
BDF_EXPORT_JOURNAL = '''

View File

@@ -107,18 +107,25 @@ class NXSolver:
def _find_journal_runner(self) -> Path:
"""Find the NX journal runner executable."""
# Simcenter3D has run_journal.exe for batch execution
# First check the provided nx_install_dir
if self.nx_install_dir:
direct_path = self.nx_install_dir / "NXBIN" / "run_journal.exe"
if direct_path.exists():
return direct_path
# Fallback: check common installation paths
possible_exes = [
Path(f"C:/Program Files/Siemens/Simcenter3D_{self.nastran_version}/NXBIN/run_journal.exe"),
Path(f"C:/Program Files/Siemens/NX{self.nastran_version}/NXBIN/run_journal.exe"),
Path(f"C:/Program Files/Siemens/DesigncenterNX{self.nastran_version}/NXBIN/run_journal.exe"),
]
for exe in possible_exes:
if exe.exists():
return exe
# Return first guess (will error in __init__ if doesn't exist)
return possible_exes[0]
# Return the direct path (will error in __init__ if doesn't exist)
return self.nx_install_dir / "NXBIN" / "run_journal.exe" if self.nx_install_dir else possible_exes[0]
def _find_solver_executable(self) -> Path:
"""Find the Nastran solver executable."""
@@ -440,9 +447,35 @@ sys.argv = ['', {argv_str}] # Set argv for the main function
# Set up environment for Simcenter/NX
env = os.environ.copy()
# Use existing SPLM_LICENSE_SERVER from environment if set
# Only set if not already defined (respects user's license configuration)
if 'SPLM_LICENSE_SERVER' not in env or not env['SPLM_LICENSE_SERVER']:
# Get SPLM_LICENSE_SERVER - prefer system registry (most up-to-date) over process env
license_server = ''
# First try system-level environment (Windows registry) - this is the authoritative source
import subprocess as sp
try:
result = sp.run(
['reg', 'query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'SPLM_LICENSE_SERVER'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# Parse: " SPLM_LICENSE_SERVER REG_SZ value"
for line in result.stdout.splitlines():
if 'SPLM_LICENSE_SERVER' in line:
parts = line.split('REG_SZ')
if len(parts) > 1:
license_server = parts[1].strip()
break
except Exception:
pass
# Fall back to process environment if registry query failed
if not license_server:
license_server = env.get('SPLM_LICENSE_SERVER', '')
if license_server:
env['SPLM_LICENSE_SERVER'] = license_server
print(f"[NX SOLVER] Using license server: {license_server}")
else:
env['SPLM_LICENSE_SERVER'] = '29000@localhost'
print(f"[NX SOLVER] WARNING: SPLM_LICENSE_SERVER not set, using default: {env['SPLM_LICENSE_SERVER']}")

View File

@@ -53,6 +53,111 @@ import NXOpen.Assemblies
import NXOpen.CAE
def extract_part_mass(theSession, part, output_dir):
"""
Extract mass from a part using NX MeasureManager.
Writes mass to _temp_mass.txt and _temp_part_properties.json in output_dir.
Args:
theSession: NXOpen.Session
part: NXOpen.Part to extract mass from
output_dir: Directory to write temp files
Returns:
Mass in kg (float)
"""
import json
results = {
'part_file': part.Name,
'mass_kg': 0.0,
'mass_g': 0.0,
'volume_mm3': 0.0,
'surface_area_mm2': 0.0,
'center_of_gravity_mm': [0.0, 0.0, 0.0],
'num_bodies': 0,
'success': False,
'error': None
}
try:
# Get all solid bodies
bodies = []
for body in part.Bodies:
if body.IsSolidBody:
bodies.append(body)
results['num_bodies'] = len(bodies)
if not bodies:
results['error'] = "No solid bodies found"
raise ValueError("No solid bodies found in part")
# Get the measure manager
measureManager = part.MeasureManager
# Get unit collection and build mass_units array
# API requires: [Area, Volume, Mass, Length] base units
uc = part.UnitCollection
mass_units = [
uc.GetBase("Area"),
uc.GetBase("Volume"),
uc.GetBase("Mass"),
uc.GetBase("Length")
]
# Create mass properties measurement
measureBodies = measureManager.NewMassProperties(mass_units, 0.99, bodies)
if measureBodies:
results['mass_kg'] = measureBodies.Mass
results['mass_g'] = results['mass_kg'] * 1000.0
try:
results['volume_mm3'] = measureBodies.Volume
except:
pass
try:
results['surface_area_mm2'] = measureBodies.Area
except:
pass
try:
cog = measureBodies.Centroid
if cog:
results['center_of_gravity_mm'] = [cog.X, cog.Y, cog.Z]
except:
pass
try:
measureBodies.Dispose()
except:
pass
results['success'] = True
except Exception as e:
results['error'] = str(e)
results['success'] = False
# Write results to JSON file
output_file = os.path.join(output_dir, "_temp_part_properties.json")
with open(output_file, 'w') as f:
json.dump(results, f, indent=2)
# Write simple mass value for backward compatibility
mass_file = os.path.join(output_dir, "_temp_mass.txt")
with open(mass_file, 'w') as f:
f.write(str(results['mass_kg']))
if not results['success']:
raise ValueError(results['error'])
return results['mass_kg']
def find_or_open_part(theSession, part_path):
"""
Find a part if already loaded, otherwise open it.
@@ -296,6 +401,15 @@ def solve_assembly_fem_workflow(theSession, sim_file_path, solution_name, expres
partSaveStatus_blank.Dispose()
print(f"[JOURNAL] M1_Blank saved")
# STEP 2a: EXTRACT MASS FROM M1_BLANK
# Extract mass using MeasureManager after geometry is updated
print(f"[JOURNAL] Extracting mass from M1_Blank...")
try:
mass_kg = extract_part_mass(theSession, workPart, working_dir)
print(f"[JOURNAL] Mass extracted: {mass_kg:.6f} kg ({mass_kg * 1000:.2f} g)")
except Exception as mass_err:
print(f"[JOURNAL] WARNING: Mass extraction failed: {mass_err}")
updated_expressions = list(expression_updates.keys())
except Exception as e: