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. """Real NX solver using existing Atomizer optimization engine.
Uses these Atomizer components: 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) - 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_displacement.extract_displacement()
- optimization_engine.extractors.extract_von_mises_stress.extract_solid_stress() - optimization_engine.extractors.extract_von_mises_stress.extract_solid_stress()
- optimization_engine.extractors.extract_mass_from_expression.extract_mass_from_expression() - 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: Files required in model_dir:
- Beam.prt (part file with expressions) - Beam.prt (part file with expressions)
- Beam_sim1.sim (simulation file) - Beam_sim1.sim (simulation file)
- Expected OP2 output: beam_sim1-solution_1.op2 - Expected OP2 output: beam_sim1-solution_1.op2
Expression names: Expression names (confirmed from binary introspection):
- beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count - DVs: beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count
- Mass from expression: p173 - 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__( def __init__(
self, self,
model_dir: str | Path, model_dir: str | Path,
nx_install_dir: str | Path | None = None,
timeout: int = 600, timeout: int = 600,
use_iteration_folders: bool = True,
nastran_version: str = "2412", nastran_version: str = "2412",
) -> None: ) -> None:
"""Initialize NXOpen solver using Atomizer engine. """Initialize NXOpen solver using Atomizer engine.
Args: Args:
model_dir: Path to directory containing Beam.prt, Beam_sim1.sim, etc. model_dir: Path to directory containing Beam.prt, Beam_sim1.sim, etc.
timeout: Timeout per trial in seconds (default: 600s = 10 min) This is the "master model" directory — files are copied per iteration.
use_iteration_folders: Use HEEDS-style iteration folders for clean state nx_install_dir: Path to NX installation (auto-detected if None).
nastran_version: NX version (e.g., "2412", "2506") 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.model_dir = Path(model_dir)
self.timeout = timeout self.timeout = timeout
self.use_iteration_folders = use_iteration_folders
self.nastran_version = nastran_version 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(): if not self.model_dir.exists():
raise FileNotFoundError(f"Model directory not found: {self.model_dir}") raise FileNotFoundError(f"Model directory not found: {self.model_dir}")
# Required files # Validate required files
self.prt_file = self.model_dir / "Beam.prt" self.prt_file = self.model_dir / self.PRT_FILENAME
self.sim_file = self.model_dir / "Beam_sim1.sim" self.sim_file = self.model_dir / self.SIM_FILENAME
if not self.prt_file.exists(): for f in (self.prt_file, self.sim_file):
raise FileNotFoundError(f"Part file not found: {self.prt_file}") if not f.exists():
if not self.sim_file.exists(): raise FileNotFoundError(f"Required file not found: {f}")
raise FileNotFoundError(f"Simulation file not found: {self.sim_file}")
# 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: try:
from optimization_engine.nx.updater import NXParameterUpdater
from optimization_engine.nx.solver import NXSolver from optimization_engine.nx.solver import NXSolver
from optimization_engine.extractors.extract_displacement import extract_displacement from optimization_engine.extractors.extract_displacement import (
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress extract_displacement,
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression )
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._NXSolver = NXSolver
self._extract_displacement = extract_displacement self._extract_displacement = staticmethod(extract_displacement)
self._extract_stress = extract_solid_stress self._extract_stress = staticmethod(extract_solid_stress)
self._extract_mass = extract_mass_from_expression self._extract_mass = staticmethod(extract_mass_from_expression)
except ImportError as e: except ImportError as e:
raise ImportError( raise ImportError(
f"Failed to import Atomizer optimization engine: {e}\n" f"Failed to import Atomizer optimization engine: {e}\n"
f"Ensure {ATOMIZER_REPO_ROOT} is accessible and contains optimization_engine/" f"Ensure {ATOMIZER_REPO_ROOT} is accessible and contains optimization_engine/"
) ) from e
# Initialize the NX solver # Lazy-init solver on first evaluate() call
self._solver = None self._solver: object | None = None
self._trial_counter = 0 self._trial_counter: int = 0
logger.info( logger.info(
"NXOpenSolver initialized with model_dir=%s, timeout=%ds", "NXOpenSolver initialized model_dir=%s, timeout=%ds, nastran=%s",
self.model_dir, self.model_dir,
self.timeout self.timeout,
self.nastran_version,
) )
def evaluate(self, trial_input: TrialInput) -> TrialResult: # ------------------------------------------------------------------
"""Full NX evaluation pipeline using Atomizer engine. # Public API
# ------------------------------------------------------------------
Pipeline: def evaluate(self, trial_input: TrialInput) -> TrialResult:
1. Initialize NX solver (if needed) """Full NX evaluation pipeline for one trial.
2. Create iteration folder (if using iteration folders)
3. Update expressions via NXParameterUpdater Pipeline (mirrors M1_Mirror/SAT3_Trajectory_V7 FEARunner.run_fea):
4. Solve via NXSolver 1. create_iteration_folder → copies model + writes params.exp
5. Extract results via Atomizer extractors 2. run_simulation → journal updates expressions, rebuilds, solves
3. extract displacement, stress, mass from results
Args: Args:
trial_input: Design variable values. trial_input: Design variable values.
@@ -324,101 +362,141 @@ class NXOpenSolver:
TrialResult with extracted outputs or failure info. TrialResult with extracted outputs or failure info.
""" """
self._trial_counter += 1 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(
logger.info(f" beam_half_core_thickness: {trial_input.beam_half_core_thickness} mm") "Trial %d — DVs: core=%.3f mm, face=%.3f mm, hole_d=%.3f mm, n_holes=%d",
logger.info(f" beam_face_thickness: {trial_input.beam_face_thickness} mm") trial_num,
logger.info(f" holes_diameter: {trial_input.holes_diameter} mm") trial_input.beam_half_core_thickness,
logger.info(f" hole_count: {trial_input.hole_count}") trial_input.beam_face_thickness,
trial_input.holes_diameter,
trial_input.hole_count,
)
try: try:
# Initialize solver if needed # 0. Lazy-init solver
if self._solver is None: if self._solver is None:
self._init_solver() self._init_solver()
# Determine working directory expressions = self._build_expression_dict(trial_input)
if self.use_iteration_folders:
# Create iteration folder with fresh model copies # 1. Create iteration folder with fresh model copies + params.exp
iterations_dir = self.model_dir / "2_iterations" iter_folder = self._solver.create_iteration_folder(
working_dir = self._solver.create_iteration_folder( iterations_base_dir=self.iterations_dir,
iterations_base_dir=iterations_dir, iteration_number=trial_num,
iteration_number=self._trial_counter, expression_updates=expressions,
expression_updates=self._build_expression_dict(trial_input) )
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 # 2. Solve — journal handles expression import + geometry rebuild + FEM update + solve
self._update_expressions(working_prt, trial_input) # expression_updates are passed as argv to the journal (key=value pairs)
logger.info(" Solving: %s", working_sim.name)
# Solve simulation
logger.info(f"Solving simulation: {working_sim.name}")
solve_result = self._solver.run_simulation( solve_result = self._solver.run_simulation(
sim_file=working_sim, sim_file=working_sim,
working_dir=working_dir, working_dir=iter_folder,
cleanup=False, # Keep results for extraction cleanup=False, # keep OP2/F06 for extraction
expression_updates=self._build_expression_dict(trial_input) if self.use_iteration_folders else None, expression_updates=expressions,
solution_name="Solution 1" solution_name=self.SOLUTION_NAME,
) )
if not solve_result['success']: if not solve_result["success"]:
return TrialResult( errors = solve_result.get("errors", ["Unknown error"])
success=False, rc = solve_result.get("return_code", "?")
error_message=f"NX solve failed: {'; '.join(solve_result.get('errors', ['Unknown error']))}" msg = f"NX solve failed (rc={rc}): {'; '.join(errors)}"
) logger.error(" %s", msg)
return TrialResult(success=False, error_message=msg)
# Extract results # 3. Locate OP2
op2_file = solve_result['op2_file'] op2_file = solve_result.get("op2_file")
if not op2_file or not op2_file.exists(): if op2_file is None or not Path(op2_file).exists():
return TrialResult( # Fallback: try the expected naming convention
success=False, op2_file = iter_folder / "beam_sim1-solution_1.op2"
error_message=f"OP2 file not found: {op2_file}" 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: try:
disp_result = self._extract_displacement(op2_file) 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: except Exception as e:
logger.error(" Displacement extraction failed: %s", e)
return TrialResult( return TrialResult(
success=False, 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: try:
stress_result = self._extract_stress( stress_result = self._extract_stress(
op2_file, op2_file,
element_type="cquad4", # Hydrotech beam uses shell elements element_type=None, # auto-detect from OP2 contents
convert_to_mpa=True # Convert from kPa to MPa 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: except Exception as e:
return TrialResult( # Fallback: try shell elements if solid extraction failed
success=False, logger.warning(" Solid stress extraction failed, trying shell: %s", e)
error_message=f"Stress extraction failed: {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: 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: except Exception as e:
logger.error(" Mass extraction failed: %s", e)
return TrialResult( return TrialResult(
success=False, success=False,
error_message=f"Mass extraction failed: {e}" error_message=f"Mass extraction failed: {e}",
) )
elapsed_time = __import__('time').time() - trial_start_time elapsed = self._time.time() - t_start
logger.info(f"Trial {self._trial_counter} completed in {elapsed_time:.1f}s") logger.info(
logger.info(f" mass: {mass:.6f} kg") " Trial %d OK (%.1fs) — mass=%.4f kg, disp=%.4f mm, σ_vm=%.2f MPa",
logger.info(f" tip_displacement: {tip_displacement:.6f} mm") trial_num,
logger.info(f" max_von_mises: {max_von_mises:.3f} MPa") elapsed,
mass,
tip_displacement,
max_von_mises,
)
return TrialResult( return TrialResult(
success=True, success=True,
@@ -428,61 +506,88 @@ class NXOpenSolver:
) )
except Exception as e: except Exception as e:
elapsed_time = __import__('time').time() - trial_start_time elapsed = self._time.time() - t_start
logger.error(f"Trial {self._trial_counter} failed after {elapsed_time:.1f}s: {e}") logger.error(" Trial %d FAILED (%.1fs): %s", trial_num, elapsed, e)
return TrialResult( return TrialResult(
success=False, 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: def close(self) -> None:
"""Clean up NX session resources. """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 is not None:
if self._solver.session_manager: sm = getattr(self._solver, "session_manager", None)
logger.info("Closing NX session via session manager") if sm is not None:
logger.info("Cleaning up NX session locks via session manager")
try: try:
self._solver.session_manager.cleanup_stale_locks() sm.cleanup_stale_locks()
except Exception as e: 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.") 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 # Factory
@@ -490,24 +595,24 @@ class NXOpenSolver:
def create_solver( def create_solver(
backend: str = "stub", backend: str = "stub",
model_dir: str = "", model_dir: str = "",
nx_install_dir: str | None = None,
timeout: int = 600, timeout: int = 600,
use_iteration_folders: bool = True,
nastran_version: str = "2412", nastran_version: str = "2412",
) -> NXStubSolver | NXOpenSolver: ) -> NXStubSolver | NXOpenSolver:
"""Create an NX solver instance. """Create an NX solver instance.
Args: 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). 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). 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", "2512").
nastran_version: NX version (e.g., "2412", "2506").
Returns: Returns:
Solver instance implementing the NXSolverInterface protocol. Solver instance implementing the NXSolverInterface protocol.
Raises: Raises:
ValueError: If backend is unknown. ValueError: If backend is unknown or model_dir missing for nxopen.
""" """
if backend == "stub": if backend == "stub":
return NXStubSolver() return NXStubSolver()
@@ -516,8 +621,8 @@ def create_solver(
raise ValueError("model_dir required for nxopen backend") raise ValueError("model_dir required for nxopen backend")
return NXOpenSolver( return NXOpenSolver(
model_dir=model_dir, model_dir=model_dir,
nx_install_dir=nx_install_dir,
timeout=timeout, timeout=timeout,
use_iteration_folders=use_iteration_folders,
nastran_version=nastran_version, nastran_version=nastran_version,
) )
else: else: