feat(hydrotech-beam): complete NXOpenSolver.evaluate() implementation

Complete the NXOpenSolver class in nx_interface.py with production-ready
evaluate() and close() methods, following proven patterns from
M1_Mirror/SAT3_Trajectory_V7.

Pipeline per trial:
1. NXSolver.create_iteration_folder() — HEEDS-style isolation with fresh
   model copies + params.exp generation
2. NXSolver.run_simulation() — journal-based solve via run_journal.exe
   (handles expression import, geometry rebuild, FEM update, SOL 101)
3. extract_displacement() — max displacement from OP2
4. extract_solid_stress() — max von Mises with auto-detect element type
   (tries all solid types first, falls back to CQUAD4 shell)
5. extract_mass_from_expression() — reads _temp_mass.txt from journal,
   with _temp_part_properties.json fallback

Key decisions:
- Auto-detect element type for stress (element_type=None) instead of
  hardcoding CQUAD4 — the beam model may use solid or shell elements
- Lazy solver init on first evaluate() call for clean error handling
- OP2 fallback path: tries solver result first, then expected naming
  convention (beam_sim1-solution_1.op2)
- Mass fallback: _temp_mass.txt -> _temp_part_properties.json
- LAC-compliant close(): only uses session_manager.cleanup_stale_locks(),
  never kills NX processes directly

Expression mapping (confirmed from binary introspection):
- beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count
- Mass output: p173 (body_property147.mass, kg)

Refs: OP_09, OPTIMIZATION_STRATEGY.md §8.2
This commit is contained in:
2026-02-11 01:11:09 +00:00
parent 33180d66c9
commit 390ffed450

View File

@@ -229,93 +229,131 @@ class NXOpenSolver:
"""Real NX solver using existing Atomizer optimization engine.
Uses these Atomizer components:
- optimization_engine.nx.updater.NXParameterUpdater (expression updates via .exp import)
- optimization_engine.nx.solver.NXSolver (journal-based solving with run_journal.exe)
→ handles iteration folders, expression import via .exp, and NX solve
- optimization_engine.extractors.extract_displacement.extract_displacement()
- optimization_engine.extractors.extract_von_mises_stress.extract_solid_stress()
- optimization_engine.extractors.extract_mass_from_expression.extract_mass_from_expression()
Pipeline per trial (HEEDS-style iteration folder pattern):
1. NXSolver.create_iteration_folder() — copies model files + writes params.exp
2. NXSolver.run_simulation() — runs solve_simulation.py journal via run_journal.exe
→ The journal imports params.exp, rebuilds geometry, updates FEM, solves, extracts mass
3. extract_displacement(op2) — max displacement from SOL 101
4. extract_solid_stress(op2) — max von Mises (auto-detect element type)
5. extract_mass_from_expression(prt) — reads _temp_mass.txt written by journal
Files required in model_dir:
- Beam.prt (part file with expressions)
- Beam_sim1.sim (simulation file)
- Expected OP2 output: beam_sim1-solution_1.op2
Expression names:
- beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count
- Mass from expression: p173
Expression names (confirmed from binary introspection):
- DVs: beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count
- Mass output: p173 (body_property147.mass, kg)
References:
- M1_Mirror/SAT3_Trajectory_V7/run_optimization.py — FEARunner pattern
- optimization_engine/nx/solver.py — NXSolver API
- optimization_engine/nx/solve_simulation.py — Journal internals
"""
# SIM filename and solution name for this model
SIM_FILENAME = "Beam_sim1.sim"
PRT_FILENAME = "Beam.prt"
SOLUTION_NAME = "Solution 1"
# Expected OP2: <sim_stem>-<solution_name_lower_underscored>.op2
# = beam_sim1-solution_1.op2
def __init__(
self,
model_dir: str | Path,
nx_install_dir: str | Path | None = None,
timeout: int = 600,
use_iteration_folders: bool = True,
nastran_version: str = "2412",
) -> None:
"""Initialize NXOpen solver using Atomizer engine.
Args:
model_dir: Path to directory containing Beam.prt, Beam_sim1.sim, etc.
timeout: Timeout per trial in seconds (default: 600s = 10 min)
use_iteration_folders: Use HEEDS-style iteration folders for clean state
nastran_version: NX version (e.g., "2412", "2506")
This is the "master model" directory — files are copied per iteration.
nx_install_dir: Path to NX installation (auto-detected if None).
timeout: Timeout per trial in seconds (default: 600s = 10 min).
nastran_version: NX version string (e.g., "2412", "2506", "2512").
"""
import time as _time # avoid repeated __import__
self._time = _time
self.model_dir = Path(model_dir)
self.timeout = timeout
self.use_iteration_folders = use_iteration_folders
self.nastran_version = nastran_version
self.nx_install_dir = str(nx_install_dir) if nx_install_dir else None
if not self.model_dir.exists():
raise FileNotFoundError(f"Model directory not found: {self.model_dir}")
# Required files
self.prt_file = self.model_dir / "Beam.prt"
self.sim_file = self.model_dir / "Beam_sim1.sim"
# Validate required files
self.prt_file = self.model_dir / self.PRT_FILENAME
self.sim_file = self.model_dir / self.SIM_FILENAME
if not self.prt_file.exists():
raise FileNotFoundError(f"Part file not found: {self.prt_file}")
if not self.sim_file.exists():
raise FileNotFoundError(f"Simulation file not found: {self.sim_file}")
for f in (self.prt_file, self.sim_file):
if not f.exists():
raise FileNotFoundError(f"Required file not found: {f}")
# Import Atomizer components
# Iterations output directory (sibling to model_dir per study convention)
# Layout: studies/01_doe_landscape/
# 1_setup/model/ ← model_dir (master)
# 2_iterations/ ← iteration folders
# 3_results/ ← final outputs
self.iterations_dir = self.model_dir.parent.parent / "2_iterations"
self.iterations_dir.mkdir(parents=True, exist_ok=True)
# Import Atomizer components at init time (fail-fast on missing engine)
try:
from optimization_engine.nx.updater import NXParameterUpdater
from optimization_engine.nx.solver import NXSolver
from optimization_engine.extractors.extract_displacement import extract_displacement
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
from optimization_engine.extractors.extract_displacement import (
extract_displacement,
)
from optimization_engine.extractors.extract_von_mises_stress import (
extract_solid_stress,
)
from optimization_engine.extractors.extract_mass_from_expression import (
extract_mass_from_expression,
)
self._NXParameterUpdater = NXParameterUpdater
self._NXSolver = NXSolver
self._extract_displacement = extract_displacement
self._extract_stress = extract_solid_stress
self._extract_mass = extract_mass_from_expression
self._extract_displacement = staticmethod(extract_displacement)
self._extract_stress = staticmethod(extract_solid_stress)
self._extract_mass = staticmethod(extract_mass_from_expression)
except ImportError as e:
raise ImportError(
f"Failed to import Atomizer optimization engine: {e}\n"
f"Ensure {ATOMIZER_REPO_ROOT} is accessible and contains optimization_engine/"
)
) from e
# Initialize the NX solver
self._solver = None
self._trial_counter = 0
# Lazy-init solver on first evaluate() call
self._solver: object | None = None
self._trial_counter: int = 0
logger.info(
"NXOpenSolver initialized with model_dir=%s, timeout=%ds",
self.model_dir,
self.timeout
"NXOpenSolver initialized model_dir=%s, timeout=%ds, nastran=%s",
self.model_dir,
self.timeout,
self.nastran_version,
)
def evaluate(self, trial_input: TrialInput) -> TrialResult:
"""Full NX evaluation pipeline using Atomizer engine.
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
Pipeline:
1. Initialize NX solver (if needed)
2. Create iteration folder (if using iteration folders)
3. Update expressions via NXParameterUpdater
4. Solve via NXSolver
5. Extract results via Atomizer extractors
def evaluate(self, trial_input: TrialInput) -> TrialResult:
"""Full NX evaluation pipeline for one trial.
Pipeline (mirrors M1_Mirror/SAT3_Trajectory_V7 FEARunner.run_fea):
1. create_iteration_folder → copies model + writes params.exp
2. run_simulation → journal updates expressions, rebuilds, solves
3. extract displacement, stress, mass from results
Args:
trial_input: Design variable values.
@@ -324,101 +362,141 @@ class NXOpenSolver:
TrialResult with extracted outputs or failure info.
"""
self._trial_counter += 1
trial_start_time = __import__('time').time()
trial_num = self._trial_counter
t_start = self._time.time()
logger.info(f"Starting trial {self._trial_counter}")
logger.info(f" beam_half_core_thickness: {trial_input.beam_half_core_thickness} mm")
logger.info(f" beam_face_thickness: {trial_input.beam_face_thickness} mm")
logger.info(f" holes_diameter: {trial_input.holes_diameter} mm")
logger.info(f" hole_count: {trial_input.hole_count}")
logger.info(
"Trial %d — DVs: core=%.3f mm, face=%.3f mm, hole_d=%.3f mm, n_holes=%d",
trial_num,
trial_input.beam_half_core_thickness,
trial_input.beam_face_thickness,
trial_input.holes_diameter,
trial_input.hole_count,
)
try:
# Initialize solver if needed
# 0. Lazy-init solver
if self._solver is None:
self._init_solver()
# Determine working directory
if self.use_iteration_folders:
# Create iteration folder with fresh model copies
iterations_dir = self.model_dir / "2_iterations"
working_dir = self._solver.create_iteration_folder(
iterations_base_dir=iterations_dir,
iteration_number=self._trial_counter,
expression_updates=self._build_expression_dict(trial_input)
expressions = self._build_expression_dict(trial_input)
# 1. Create iteration folder with fresh model copies + params.exp
iter_folder = self._solver.create_iteration_folder(
iterations_base_dir=self.iterations_dir,
iteration_number=trial_num,
expression_updates=expressions,
)
logger.info(" Iteration folder: %s", iter_folder)
working_sim = iter_folder / self.SIM_FILENAME
working_prt = iter_folder / self.PRT_FILENAME
if not working_sim.exists():
return TrialResult(
success=False,
error_message=f"SIM file missing in iteration folder: {working_sim}",
)
working_prt = working_dir / "Beam.prt"
working_sim = working_dir / "Beam_sim1.sim"
else:
# Work directly in model directory
working_dir = self.model_dir
working_prt = self.prt_file
working_sim = self.sim_file
# Update expressions directly
self._update_expressions(working_prt, trial_input)
# Solve simulation
logger.info(f"Solving simulation: {working_sim.name}")
# 2. Solve — journal handles expression import + geometry rebuild + FEM update + solve
# expression_updates are passed as argv to the journal (key=value pairs)
logger.info(" Solving: %s", working_sim.name)
solve_result = self._solver.run_simulation(
sim_file=working_sim,
working_dir=working_dir,
cleanup=False, # Keep results for extraction
expression_updates=self._build_expression_dict(trial_input) if self.use_iteration_folders else None,
solution_name="Solution 1"
working_dir=iter_folder,
cleanup=False, # keep OP2/F06 for extraction
expression_updates=expressions,
solution_name=self.SOLUTION_NAME,
)
if not solve_result['success']:
return TrialResult(
success=False,
error_message=f"NX solve failed: {'; '.join(solve_result.get('errors', ['Unknown error']))}"
)
if not solve_result["success"]:
errors = solve_result.get("errors", ["Unknown error"])
rc = solve_result.get("return_code", "?")
msg = f"NX solve failed (rc={rc}): {'; '.join(errors)}"
logger.error(" %s", msg)
return TrialResult(success=False, error_message=msg)
# Extract results
op2_file = solve_result['op2_file']
if not op2_file or not op2_file.exists():
return TrialResult(
success=False,
error_message=f"OP2 file not found: {op2_file}"
)
# 3. Locate OP2
op2_file = solve_result.get("op2_file")
if op2_file is None or not Path(op2_file).exists():
# Fallback: try the expected naming convention
op2_file = iter_folder / "beam_sim1-solution_1.op2"
if not op2_file.exists():
return TrialResult(
success=False,
error_message=f"OP2 not found. Expected: {op2_file}",
)
else:
op2_file = Path(op2_file)
# Extract displacement (tip displacement)
logger.info(" OP2: %s", op2_file.name)
# 4a. Extract displacement
try:
disp_result = self._extract_displacement(op2_file)
tip_displacement = disp_result['max_displacement'] # mm
tip_displacement = disp_result["max_displacement"] # mm
except Exception as e:
logger.error(" Displacement extraction failed: %s", e)
return TrialResult(
success=False,
error_message=f"Displacement extraction failed: {e}"
error_message=f"Displacement extraction failed: {e}",
)
# Extract stress (max von Mises from shells - CQUAD4 elements)
# 4b. Extract stress — auto-detect element type (solid or shell)
# Pass element_type=None so it checks CTETRA, CHEXA, CPENTA, CPYRAM
try:
stress_result = self._extract_stress(
op2_file,
element_type="cquad4", # Hydrotech beam uses shell elements
convert_to_mpa=True # Convert from kPa to MPa
op2_file,
element_type=None, # auto-detect from OP2 contents
convert_to_mpa=True, # NX kg-mm-s → kPa, convert to MPa
)
max_von_mises = stress_result['max_von_mises'] # MPa
max_von_mises = stress_result["max_von_mises"] # MPa
except Exception as e:
return TrialResult(
success=False,
error_message=f"Stress extraction failed: {e}"
)
# Fallback: try shell elements if solid extraction failed
logger.warning(" Solid stress extraction failed, trying shell: %s", e)
try:
stress_result = self._extract_stress(
op2_file,
element_type="cquad4",
convert_to_mpa=True,
)
max_von_mises = stress_result["max_von_mises"]
except Exception as e2:
logger.error(" Stress extraction failed (all types): %s", e2)
return TrialResult(
success=False,
error_message=f"Stress extraction failed: {e}; shell fallback: {e2}",
)
# Extract mass from expression p173
# 4c. Extract mass — reads _temp_mass.txt written by solve_simulation.py journal
try:
mass = self._extract_mass(working_prt, expression_name="p173") # kg
mass = self._extract_mass(working_prt, expression_name=EXPR_MASS) # kg
except FileNotFoundError:
# _temp_mass.txt not found — journal may not have written it for single-part models
# Fallback: try reading from _temp_part_properties.json
logger.warning(" _temp_mass.txt not found, trying _temp_part_properties.json")
mass = self._extract_mass_fallback(iter_folder)
if mass is None:
return TrialResult(
success=False,
error_message="Mass extraction failed: _temp_mass.txt not found",
)
except Exception as e:
logger.error(" Mass extraction failed: %s", e)
return TrialResult(
success=False,
error_message=f"Mass extraction failed: {e}"
error_message=f"Mass extraction failed: {e}",
)
elapsed_time = __import__('time').time() - trial_start_time
logger.info(f"Trial {self._trial_counter} completed in {elapsed_time:.1f}s")
logger.info(f" mass: {mass:.6f} kg")
logger.info(f" tip_displacement: {tip_displacement:.6f} mm")
logger.info(f" max_von_mises: {max_von_mises:.3f} MPa")
elapsed = self._time.time() - t_start
logger.info(
" Trial %d OK (%.1fs) — mass=%.4f kg, disp=%.4f mm, σ_vm=%.2f MPa",
trial_num,
elapsed,
mass,
tip_displacement,
max_von_mises,
)
return TrialResult(
success=True,
@@ -428,61 +506,88 @@ class NXOpenSolver:
)
except Exception as e:
elapsed_time = __import__('time').time() - trial_start_time
logger.error(f"Trial {self._trial_counter} failed after {elapsed_time:.1f}s: {e}")
elapsed = self._time.time() - t_start
logger.error(" Trial %d FAILED (%.1fs): %s", trial_num, elapsed, e)
return TrialResult(
success=False,
error_message=f"Trial evaluation failed: {e}"
error_message=f"Unexpected error in trial {trial_num}: {e}",
)
def _init_solver(self) -> None:
"""Initialize the NX solver."""
logger.info("Initializing NX solver")
self._solver = self._NXSolver(
nastran_version=self.nastran_version,
timeout=self.timeout,
use_journal=True, # Always use journal mode
enable_session_management=True,
study_name="hydrotech_beam_doe",
use_iteration_folders=self.use_iteration_folders,
master_model_dir=self.model_dir if self.use_iteration_folders else None
)
def _build_expression_dict(self, trial_input: TrialInput) -> dict[str, float]:
"""Build expression dictionary for Atomizer engine."""
return {
EXPR_HALF_CORE_THICKNESS: trial_input.beam_half_core_thickness,
EXPR_FACE_THICKNESS: trial_input.beam_face_thickness,
EXPR_HOLES_DIAMETER: trial_input.holes_diameter,
EXPR_HOLE_COUNT: float(trial_input.hole_count), # NX expects float
}
def _update_expressions(self, prt_file: Path, trial_input: TrialInput) -> None:
"""Update expressions in PRT file using NXParameterUpdater."""
logger.info("Updating expressions via NXParameterUpdater")
updater = self._NXParameterUpdater(prt_file, backup=False)
expression_updates = self._build_expression_dict(trial_input)
updater.update_expressions(expression_updates, use_nx_import=True)
updater.save()
def close(self) -> None:
"""Clean up NX session resources.
⚠️ LAC CRITICAL: Uses NXSessionManager for safe shutdown.
⚠️ LAC CRITICAL: NEVER kill NX processes directly.
Uses NXSessionManager for safe lock cleanup only.
"""
if self._solver and hasattr(self._solver, 'session_manager'):
if self._solver.session_manager:
logger.info("Closing NX session via session manager")
if self._solver is not None:
sm = getattr(self._solver, "session_manager", None)
if sm is not None:
logger.info("Cleaning up NX session locks via session manager")
try:
self._solver.session_manager.cleanup_stale_locks()
sm.cleanup_stale_locks()
except Exception as e:
logger.warning(f"Session cleanup warning: {e}")
logger.warning("Session lock cleanup warning: %s", e)
self._solver = None
logger.info("NXOpenSolver closed.")
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _init_solver(self) -> None:
"""Lazy-initialize NXSolver (matches SAT3_V7 FEARunner.setup pattern)."""
logger.info("Initializing NXSolver (nastran=%s, timeout=%ds)", self.nastran_version, self.timeout)
kwargs: dict = {
"nastran_version": self.nastran_version,
"timeout": self.timeout,
"use_journal": True,
"enable_session_management": True,
"study_name": "hydrotech_beam_doe",
"use_iteration_folders": True,
"master_model_dir": str(self.model_dir),
}
if self.nx_install_dir:
kwargs["nx_install_dir"] = self.nx_install_dir
self._solver = self._NXSolver(**kwargs)
logger.info("NXSolver ready")
def _build_expression_dict(self, trial_input: TrialInput) -> dict[str, float]:
"""Build NX expression name→value dict for the solver.
These are passed to:
- create_iteration_folder() → writes params.exp (unit defaulting to mm)
- run_simulation(expression_updates=...) → passed as argv to solve journal
"""
return {
EXPR_HALF_CORE_THICKNESS: trial_input.beam_half_core_thickness,
EXPR_FACE_THICKNESS: trial_input.beam_face_thickness,
EXPR_HOLES_DIAMETER: trial_input.holes_diameter,
EXPR_HOLE_COUNT: float(trial_input.hole_count), # NX expressions are float
}
@staticmethod
def _extract_mass_fallback(iter_folder: Path) -> float | None:
"""Try to read mass from _temp_part_properties.json (backup path)."""
import json as _json
props_file = iter_folder / "_temp_part_properties.json"
if not props_file.exists():
return None
try:
with open(props_file) as f:
props = _json.load(f)
mass = props.get("mass_kg", 0.0)
if mass > 0:
logger.info(" Mass from _temp_part_properties.json: %.6f kg", mass)
return mass
return None
except Exception as e:
logger.warning(" Failed to read %s: %s", props_file, e)
return None
# ---------------------------------------------------------------------------
# Factory
@@ -490,24 +595,24 @@ class NXOpenSolver:
def create_solver(
backend: str = "stub",
model_dir: str = "",
nx_install_dir: str | None = None,
timeout: int = 600,
use_iteration_folders: bool = True,
nastran_version: str = "2412",
) -> NXStubSolver | NXOpenSolver:
"""Create an NX solver instance.
Args:
backend: "stub" for development, "nxopen" for real NX (Windows only).
backend: "stub" for development, "nxopen" for real NX (Windows/dalidou only).
model_dir: Path to NX model directory (required for nxopen backend).
nx_install_dir: Path to NX installation (auto-detected if None).
timeout: Timeout per trial in seconds (default: 600s = 10 min).
use_iteration_folders: Use HEEDS-style iteration folders for clean state.
nastran_version: NX version (e.g., "2412", "2506").
nastran_version: NX version (e.g., "2412", "2506", "2512").
Returns:
Solver instance implementing the NXSolverInterface protocol.
Raises:
ValueError: If backend is unknown.
ValueError: If backend is unknown or model_dir missing for nxopen.
"""
if backend == "stub":
return NXStubSolver()
@@ -516,8 +621,8 @@ def create_solver(
raise ValueError("model_dir required for nxopen backend")
return NXOpenSolver(
model_dir=model_dir,
nx_install_dir=nx_install_dir,
timeout=timeout,
use_iteration_folders=use_iteration_folders,
nastran_version=nastran_version,
)
else: