Files
Atomizer/optimization_engine/nx_solver.py
Anto01 96e88fe714 fix: Apply expression updates directly in NX journal
Critical fix - the expressions were not being applied during optimization!
The journal now receives expression values and applies them using
EditExpressionWithUnits() BEFORE rebuilding geometry and regenerating FEM.

## Key Changes

### Expression Application in Journal (solve_simulation.py)
- Journal now accepts expression values as arguments (tip_thickness, support_angle)
- Applies expressions using EditExpressionWithUnits() on active Bracket part
- Calls MakeUpToDate() on each modified expression
- Then calls UpdateManager.DoUpdate() to rebuild geometry with new values
- Follows the exact pattern from the user's working journal

### NX Solver Updates (nx_solver.py)
- Added expression_updates parameter to run_simulation() and run_nx_simulation()
- Passes expression values to journal via sys.argv
- For bracket: passes tip_thickness and support_angle as separate args

### Test Script Updates (test_journal_optimization.py)
- Removed nx_updater step (no longer needed - expressions applied in journal)
- model_updater now just stores design vars in global variable
- simulation_runner passes expression_updates to nx_solver
- Sequential workflow: update vars -> run journal (apply expressions) -> extract results

## Results - OPTIMIZATION NOW WORKS!

Before (all trials same stress):
- Trial 0: tip=23.48, angle=37.21 → stress=197.89 MPa
- Trial 1: tip=20.08, angle=20.32 → stress=197.89 MPa (SAME!)
- Trial 2: tip=18.19, angle=35.23 → stress=197.89 MPa (SAME!)

After (varying stress values):
- Trial 0: tip=21.62, angle=30.15 → stress=192.71 MPa 
- Trial 1: tip=17.17, angle=33.52 → stress=167.96 MPa  BEST!
- Trial 2: tip=15.06, angle=21.81 → stress=242.50 MPa 

Mesh also changes: 1027 → 951 CTETRA elements with different parameters.

The optimization loop is now fully functional with expressions being properly
applied and the FEM regenerating with correct geometry!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 12:47:55 -05:00

493 lines
18 KiB
Python

"""
NX Nastran Solver Integration
Executes NX Nastran solver in batch mode for optimization loops.
"""
from pathlib import Path
from typing import Optional, Dict, Any
import subprocess
import time
import shutil
import os
class NXSolver:
"""
Wrapper for NX Nastran batch solver execution.
Supports:
- Running .sim files through NX Nastran
- Monitoring solver progress
- Detecting completion and errors
- Cleaning up temporary files
"""
def __init__(
self,
nx_install_dir: Optional[Path] = None,
nastran_version: str = "2412",
timeout: int = 600,
use_journal: bool = True
):
"""
Initialize NX Solver.
Args:
nx_install_dir: Path to NX installation (auto-detected if None)
nastran_version: NX version (e.g., "2412", "2506")
timeout: Maximum solver time in seconds (default: 10 minutes)
use_journal: Use NX journal for solving (recommended for licensing)
"""
self.nastran_version = nastran_version
self.timeout = timeout
self.use_journal = use_journal
# Auto-detect NX installation
if nx_install_dir is None:
nx_install_dir = self._find_nx_installation()
self.nx_install_dir = Path(nx_install_dir)
# Set up solver executable
if use_journal:
self.solver_exe = self._find_journal_runner()
else:
self.solver_exe = self._find_solver_executable()
if not self.solver_exe.exists():
raise FileNotFoundError(
f"NX solver/runner not found at: {self.solver_exe}\n"
f"Please check NX installation at: {self.nx_install_dir}"
)
def _find_nx_installation(self) -> Path:
"""Auto-detect NX installation directory."""
# Common installation paths
possible_paths = [
Path(f"C:/Program Files/Siemens/NX{self.nastran_version}"),
Path(f"C:/Program Files/Siemens/Simcenter3D_{self.nastran_version}"),
Path(f"C:/Program Files (x86)/Siemens/NX{self.nastran_version}"),
]
for path in possible_paths:
if path.exists():
return path
raise FileNotFoundError(
f"Could not auto-detect NX {self.nastran_version} installation.\n"
f"Checked: {[str(p) for p in possible_paths]}\n"
f"Please specify nx_install_dir manually."
)
def _find_journal_runner(self) -> Path:
"""Find the NX journal runner executable."""
# Simcenter3D has run_journal.exe for batch execution
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"),
]
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]
def _find_solver_executable(self) -> Path:
"""Find the Nastran solver executable."""
# Use NX Nastran (not Simcenter) - has different licensing
# Priority: Use NX installation, not Simcenter
possible_exes = [
self.nx_install_dir / "NXNASTRAN" / "bin" / "nastran.exe",
self.nx_install_dir / "NXNASTRAN" / "nastran.exe",
self.nx_install_dir / "bin" / "nastran.exe",
]
for exe in possible_exes:
if exe.exists():
return exe
# If not found in NX, try Simcenter as fallback
simcenter_paths = [
Path(f"C:/Program Files/Siemens/Simcenter3D_{self.nastran_version}"),
]
for simcenter_dir in simcenter_paths:
if simcenter_dir.exists():
solve_exe = simcenter_dir / "NXNASTRAN" / "bin" / "nastran.exe"
if solve_exe.exists():
return solve_exe
# Return first guess (will error in __init__ if doesn't exist)
return possible_exes[0]
def run_simulation(
self,
sim_file: Path,
working_dir: Optional[Path] = None,
cleanup: bool = True,
expression_updates: Optional[Dict[str, float]] = None
) -> Dict[str, Any]:
"""
Run NX Nastran simulation.
Args:
sim_file: Path to .sim file
working_dir: Working directory for solver (defaults to sim file dir)
cleanup: Remove intermediate files after solving
expression_updates: Dict of expression name -> value to update
(only used in journal mode)
e.g., {'tip_thickness': 22.5, 'support_angle': 35.0}
Returns:
Dictionary with:
- success: bool
- op2_file: Path to output .op2 file
- log_file: Path to .log file
- elapsed_time: Solve time in seconds
- errors: List of error messages (if any)
"""
sim_file = Path(sim_file)
if not sim_file.exists():
raise FileNotFoundError(f"Simulation file not found: {sim_file}")
if working_dir is None:
working_dir = sim_file.parent
else:
working_dir = Path(working_dir)
# Check if we need to find/use .dat file (only in direct mode, not journal mode)
# .sim files require NX GUI, but .dat files can be run directly with Nastran
dat_file = None
if not self.use_journal and sim_file.suffix == '.sim':
# Look for corresponding .dat file (created by NX when solving)
# Pattern: Bracket_sim1.sim -> bracket_sim1-solution_1.dat
base = sim_file.stem.lower()
possible_dats = list(working_dir.glob(f"{base}-solution_*.dat"))
if possible_dats:
# Use the most recent .dat file
dat_file = max(possible_dats, key=lambda p: p.stat().st_mtime)
print(f"\n[NX SOLVER] Found .dat file: {dat_file.name}")
print(f" Using .dat instead of .sim for better compatibility")
sim_file = dat_file
# Prepare output file names
# When using journal mode with .sim files, output is named: <base>-solution_1.op2
# When using direct mode with .dat files, output is named: <base>.op2
base_name = sim_file.stem
if self.use_journal and sim_file.suffix == '.sim':
# Journal mode: look for -solution_1 pattern
output_base = f"{base_name.lower()}-solution_1"
else:
# Direct mode or .dat file
output_base = base_name
op2_file = working_dir / f"{output_base}.op2"
log_file = working_dir / f"{output_base}.log"
f06_file = working_dir / f"{output_base}.f06"
print(f"\n[NX SOLVER] Starting simulation...")
print(f" Input file: {sim_file.name}")
print(f" Working dir: {working_dir}")
print(f" Mode: {'Journal' if self.use_journal else 'Direct'}")
# Delete old result files (.op2, .log, .f06) to force fresh solve
# (.dat file is needed by NX, don't delete it!)
# (Otherwise NX may reuse cached results)
files_to_delete = [op2_file, log_file, f06_file]
deleted_count = 0
for old_file in files_to_delete:
if old_file.exists():
try:
old_file.unlink()
deleted_count += 1
except Exception as e:
print(f" Warning: Could not delete {old_file.name}: {e}")
if deleted_count > 0:
print(f" Deleted {deleted_count} old result file(s) to force fresh solve")
# Build command based on mode
if self.use_journal and sim_file.suffix == '.sim':
# Use NX journal for .sim files (handles licensing properly)
# Generate a temporary journal file with the correct sim file path
journal_template = Path(__file__).parent / "solve_simulation.py"
temp_journal = working_dir / "_temp_solve_journal.py"
# Read template and replace placeholder with actual path
with open(journal_template, 'r') as f:
journal_content = f.read()
# Create a custom journal that passes the sim file path and expression values
# Build argv list with expression updates
argv_list = [f"r'{sim_file.absolute()}'"]
# Add expression values if provided
if expression_updates:
# For bracket example, we expect: tip_thickness, support_angle
if 'tip_thickness' in expression_updates:
argv_list.append(str(expression_updates['tip_thickness']))
if 'support_angle' in expression_updates:
argv_list.append(str(expression_updates['support_angle']))
argv_str = ', '.join(argv_list)
custom_journal = f'''# Auto-generated journal for solving {sim_file.name}
import sys
sys.argv = ['', {argv_str}] # Set argv for the main function
{journal_content}
'''
with open(temp_journal, 'w') as f:
f.write(custom_journal)
cmd = [
str(self.solver_exe), # run_journal.exe
str(temp_journal.absolute()) # Use absolute path to avoid path issues
]
else:
# Direct Nastran batch command for .dat files or direct mode
# IMPORTANT: prog=bundle enables bundle licensing (required for desktop licenses)
cmd = [
str(self.solver_exe),
str(sim_file),
"prog=bundle",
"old=no",
"scratch=yes"
]
# Set up environment for Simcenter/NX
env = os.environ.copy()
# Set license server (use 29000 for Simcenter)
# Override any incorrect license server settings
env['SPLM_LICENSE_SERVER'] = '29000@AntoineThinkpad'
# Force desktop licensing instead of enterprise
# User has nx_nas_bn_basic_dsk (desktop) not nx_nas_basic_ent (enterprise)
env['NXNA_LICENSE_FILE'] = '29000@AntoineThinkpad'
env['NXNASTRAN_LICENSE_FILE'] = '29000@AntoineThinkpad'
# Add NX/Simcenter paths to environment
nx_bin = self.nx_install_dir / "NXBIN"
if nx_bin.exists():
env['PATH'] = f"{nx_bin};{env.get('PATH', '')}"
nastran_bin = self.solver_exe.parent
if nastran_bin.exists():
env['PATH'] = f"{nastran_bin};{env.get('PATH', '')}"
# Run solver
start_time = time.time()
try:
result = subprocess.run(
cmd,
cwd=str(working_dir),
capture_output=True,
text=True,
timeout=self.timeout,
env=env # Use modified environment
)
elapsed_time = time.time() - start_time
# Display journal output for debugging
if self.use_journal:
if result.stdout and result.stdout.strip():
print("[JOURNAL OUTPUT]")
for line in result.stdout.strip().split('\n'):
print(f" {line}")
# Check for journal errors
if self.use_journal and result.stderr and "error" in result.stderr.lower():
print("[JOURNAL ERRORS]")
for line in result.stderr.strip().split('\n')[:5]:
print(f" {line}")
# Wait for output files to appear (journal mode runs solve in background)
if self.use_journal:
max_wait = 30 # seconds - background solves can take time
wait_start = time.time()
print("[NX SOLVER] Waiting for solve to complete...")
while not (f06_file.exists() and op2_file.exists()) and (time.time() - wait_start) < max_wait:
time.sleep(0.5)
if (time.time() - wait_start) % 2 < 0.5: # Print every 2 seconds
elapsed = time.time() - wait_start
print(f" Waiting... ({elapsed:.0f}s)")
if f06_file.exists() and op2_file.exists():
print(f"[NX SOLVER] Output files detected after {time.time() - wait_start:.1f}s")
# Check for completion
success = self._check_solution_success(f06_file, log_file)
errors = []
if not success:
errors = self._extract_errors(f06_file, log_file)
# Clean up intermediate files if requested
if cleanup and success:
self._cleanup_temp_files(working_dir, base_name)
# Clean up temporary journal file if it was created
temp_journal_path = working_dir / "_temp_solve_journal.py"
if temp_journal_path.exists():
try:
temp_journal_path.unlink()
except Exception:
pass
print(f"[NX SOLVER] Complete in {elapsed_time:.1f}s")
if success:
print(f"[NX SOLVER] Results: {op2_file.name}")
else:
print(f"[NX SOLVER] FAILED - check {f06_file.name}")
for error in errors:
print(f" ERROR: {error}")
return {
'success': success,
'op2_file': op2_file if op2_file.exists() else None,
'log_file': log_file if log_file.exists() else None,
'f06_file': f06_file if f06_file.exists() else None,
'elapsed_time': elapsed_time,
'errors': errors,
'return_code': result.returncode
}
except subprocess.TimeoutExpired:
elapsed_time = time.time() - start_time
print(f"[NX SOLVER] TIMEOUT after {elapsed_time:.1f}s")
return {
'success': False,
'op2_file': None,
'log_file': log_file if log_file.exists() else None,
'elapsed_time': elapsed_time,
'errors': [f'Solver timeout after {self.timeout}s'],
'return_code': -1
}
except Exception as e:
elapsed_time = time.time() - start_time
print(f"[NX SOLVER] ERROR: {e}")
return {
'success': False,
'op2_file': None,
'log_file': None,
'elapsed_time': elapsed_time,
'errors': [str(e)],
'return_code': -1
}
def _check_solution_success(self, f06_file: Path, log_file: Path) -> bool:
"""
Check if solution completed successfully.
Looks for completion markers in .f06 and .log files.
"""
# Check .f06 file for completion
if f06_file.exists():
try:
with open(f06_file, 'r', encoding='latin-1', errors='ignore') as f:
content = f.read()
# Look for successful completion markers
if 'NORMAL TERMINATION' in content or 'USER INFORMATION MESSAGE' in content:
return True
# Check for fatal errors
if 'FATAL MESSAGE' in content or 'EXECUTION TERMINATED' in content:
return False
except Exception:
pass
# Fallback: check if OP2 was created recently
op2_file = f06_file.with_suffix('.op2')
if op2_file.exists():
# If OP2 was modified within last minute, assume success
if (time.time() - op2_file.stat().st_mtime) < 60:
return True
return False
def _extract_errors(self, f06_file: Path, log_file: Path) -> list:
"""Extract error messages from output files."""
errors = []
if f06_file.exists():
try:
with open(f06_file, 'r', encoding='latin-1', errors='ignore') as f:
for line in f:
if 'FATAL' in line or 'ERROR' in line:
errors.append(line.strip())
except Exception:
pass
return errors[:10] # Limit to first 10 errors
def _cleanup_temp_files(self, working_dir: Path, base_name: str):
"""Remove temporary solver files."""
# Files to keep
keep_extensions = {'.op2', '.f06', '.log'}
# Files to remove
remove_patterns = [
f"{base_name}.f04",
f"{base_name}.dat",
f"{base_name}.diag",
f"{base_name}.master",
f"{base_name}.dball",
f"{base_name}.MASTER",
f"{base_name}.DBALL",
f"{base_name}_*.png",
f"{base_name}_*.html",
]
for pattern in remove_patterns:
for file in working_dir.glob(pattern):
try:
file.unlink()
except Exception:
pass
# Convenience function for optimization loops
def run_nx_simulation(
sim_file: Path,
nastran_version: str = "2412",
timeout: int = 600,
cleanup: bool = True,
use_journal: bool = True,
expression_updates: Optional[Dict[str, float]] = None
) -> Path:
"""
Convenience function to run NX simulation and return OP2 file path.
Args:
sim_file: Path to .sim file
nastran_version: NX version
timeout: Solver timeout in seconds
cleanup: Remove temp files
use_journal: Use NX journal for solving (recommended for licensing)
expression_updates: Dict of expression name -> value to update in journal
Returns:
Path to output .op2 file
Raises:
RuntimeError: If simulation fails
"""
solver = NXSolver(nastran_version=nastran_version, timeout=timeout, use_journal=use_journal)
result = solver.run_simulation(sim_file, cleanup=cleanup, expression_updates=expression_updates)
if not result['success']:
error_msg = '\n'.join(result['errors']) if result['errors'] else 'Unknown error'
raise RuntimeError(f"NX simulation failed:\n{error_msg}")
if not result['op2_file'] or not result['op2_file'].exists():
raise RuntimeError("Simulation completed but OP2 file not found")
return result['op2_file']