Refactor: nx_interface uses optimization_engine (NXSolver + pyNastran extractors)

- AtomizerNXSolver wraps existing NXSolver + extractors from SAT3 pipeline
- HEEDS-style iteration folders with fresh model copies per trial
- Expression .exp file generation with correct unit mapping
- pyNastran OP2 extraction: displacement, von Mises (kPa→MPa), mass
- StubSolver improved with beam-theory approximations
- Reuses proven journal pipeline (solve_simulation.py)
This commit is contained in:
2026-02-11 13:33:09 +00:00
parent 135698d96a
commit 126f0bb2e0
2 changed files with 321 additions and 476 deletions

View File

@@ -1,9 +1,11 @@
"""NX automation interface for Hydrotech Beam optimization. """NX automation interface for Hydrotech Beam optimization.
This module uses the EXISTING Atomizer optimization engine for NX integration: Integrates with the existing Atomizer optimization_engine:
- optimization_engine.nx.updater.NXParameterUpdater (expression updates via .exp import) - NXSolver for journal-based solve (run_journal.exe → solve_simulation.py)
- optimization_engine.nx.solver.NXSolver (journal-based solving with run_journal.exe) - pyNastran OP2 extractors for displacement + stress
- optimization_engine.extractors.* (pyNastran OP2-based result extraction) - Expression-based mass extraction via journal temp file
The proven SAT3 pipeline: write .exp → NX journal updates + solves → pyNastran reads OP2.
NX Expression Names (confirmed via binary introspection — CONTEXT.md): NX Expression Names (confirmed via binary introspection — CONTEXT.md):
Design Variables: Design Variables:
@@ -20,24 +22,23 @@ NX Expression Names (confirmed via binary introspection — CONTEXT.md):
References: References:
CONTEXT.md — Full expression map CONTEXT.md — Full expression map
OPTIMIZATION_STRATEGY.md §8.2 — Extractor requirements optimization_engine/nx/solver.py — NXSolver class
optimization_engine/extractors/ — pyNastran-based result extractors
studies/M1_Mirror/SAT3_Trajectory_V7/run_optimization.py — proven pattern
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import sys import sys
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Any, Dict, Optional, Protocol
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Add Atomizer repo root to sys.path for imports
ATOMIZER_REPO_ROOT = Path("/home/papa/repos/Atomizer")
if str(ATOMIZER_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(ATOMIZER_REPO_ROOT))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Data types # Data types
@@ -64,7 +65,9 @@ class TrialResult:
mass: float = float("nan") # kg — from expression `p173` mass: float = float("nan") # kg — from expression `p173`
tip_displacement: float = float("nan") # mm — from SOL 101 results tip_displacement: float = float("nan") # mm — from SOL 101 results
max_von_mises: float = float("nan") # MPa — from SOL 101 results max_von_mises: float = float("nan") # MPa — from SOL 101 results
solve_time: float = 0.0 # seconds
error_message: str = "" error_message: str = ""
iteration_dir: Optional[str] = None # path to iteration folder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -79,238 +82,131 @@ EXPR_HOLE_COUNT = "hole_count"
EXPR_MASS = "p173" # body_property147.mass, kg EXPR_MASS = "p173" # body_property147.mass, kg
EXPR_BEAM_LENGTH = "beam_lenght" # ⚠️ TYPO IN NX — intentional EXPR_BEAM_LENGTH = "beam_lenght" # ⚠️ TYPO IN NX — intentional
# Unit mapping for .exp file generation (NXSolver._write_expression_file)
UNIT_MAPPING = {
EXPR_HALF_CORE_THICKNESS: "mm",
EXPR_FACE_THICKNESS: "mm",
EXPR_HOLES_DIAMETER: "mm",
EXPR_HOLE_COUNT: "Constant", # integer — no unit
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Interface protocol # Interface protocol
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class NXSolverInterface(Protocol): class NXSolverInterface(Protocol):
"""Protocol for NX solver backends. """Protocol for NX solver backends (enables stub/real swap)."""
Implementors must provide the full pipeline: def solve(self, trial: TrialInput) -> TrialResult: ...
1. Update expressions → 2. Rebuild model → 3. Solve SOL 101 → 4. Extract results def close(self) -> None: ...
"""
def evaluate(self, trial_input: TrialInput) -> TrialResult:
"""Run a full NX evaluation cycle for one trial. # ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def create_solver(backend: str = "stub", **kwargs: Any) -> NXSolverInterface:
"""Create the appropriate solver backend.
Args: Args:
trial_input: Design variable values. backend: "stub" for testing, "nxopen" for real NX runs.
**kwargs: Passed to solver constructor.
For "nxopen":
model_dir: Path to NX model files (required)
nx_version: NX version string (default: "2412")
timeout: Max solve time in seconds (default: 600)
use_iteration_folders: HEEDS-style per-trial folders (default: True)
Returns: Returns:
TrialResult with extracted outputs or failure info. Solver instance implementing NXSolverInterface.
""" """
... if backend == "stub":
return StubSolver(**kwargs)
def close(self) -> None: elif backend == "nxopen":
"""Clean up NX session resources. return AtomizerNXSolver(**kwargs)
⚠️ LAC CRITICAL: NEVER kill NX processes directly.
Use NXSessionManager.close_nx_if_allowed() only.
"""
...
# ---------------------------------------------------------------------------
# Stub implementation (for development/testing without NX)
# ---------------------------------------------------------------------------
class NXStubSolver:
"""Stub NX solver for development and testing.
Returns synthetic results based on simple analytical approximations
of the beam behavior. NOT physically accurate — use only for
testing the optimization pipeline.
The stub uses rough scaling relationships:
- Mass ∝ (core + face) and inversely with hole area
- Displacement ∝ 1/I where I depends on core and face thickness
- Stress ∝ M*y/I (bending stress approximation)
"""
def __init__(self) -> None:
"""Initialize stub solver."""
logger.warning(
"Using NX STUB solver — results are synthetic approximations. "
"Replace with NXOpenSolver for real evaluations."
)
def evaluate(self, trial_input: TrialInput) -> TrialResult:
"""Return synthetic results based on simplified beam mechanics.
Args:
trial_input: Design variable values.
Returns:
TrialResult with approximate values.
"""
try:
return self._compute_approximate(trial_input)
except Exception as e:
logger.error("Stub evaluation failed: %s", e)
return TrialResult(
success=False,
error_message=f"Stub evaluation error: {e}",
)
def _compute_approximate(self, inp: TrialInput) -> TrialResult:
"""Simple analytical approximation of beam response.
This is a ROUGH approximation for pipeline testing only.
Real physics requires NX Nastran SOL 101.
"""
import math
# Geometry
L = 5000.0 # mm — beam length
b = 300.0 # mm — beam width (2 × beam_half_width)
h_core = inp.beam_half_core_thickness # mm — half core
t_face = inp.beam_face_thickness # mm — face thickness
d_hole = inp.holes_diameter # mm
n_holes = inp.hole_count
# Total height and section properties (simplified I-beam)
h_total = 500.0 # mm — 2 × beam_half_height (fixed)
# Approximate second moment of area (sandwich beam)
# I ≈ b*h_total^3/12 - b*(h_total-2*t_face)^3/12 + web contribution
h_inner = h_total - 2.0 * t_face
I_section = (b * h_total**3 / 12.0) - (b * max(h_inner, 0.0) ** 3 / 12.0)
# Add core contribution
I_section += 2.0 * h_core * h_total**2 / 4.0 # approximate
# Hole area reduction (mass)
hole_area = n_holes * math.pi * (d_hole / 2.0) ** 2 # mm²
# Approximate mass (steel: 7.3 g/cm³ = 7.3e-6 kg/mm³)
rho = 7.3e-6 # kg/mm³
# Gross cross-section area (very simplified)
A_gross = 2.0 * b * t_face + 2.0 * h_core * h_total
# Remove holes from web
web_thickness = 2.0 * h_core # approximate web thickness
A_holes = n_holes * math.pi * (d_hole / 2.0) ** 2
V_solid = A_gross * L
V_holes = A_holes * web_thickness
mass = rho * (V_solid - min(V_holes, V_solid * 0.8))
# Approximate tip displacement (cantilever, point load)
# δ = PL³/(3EI)
P = 10000.0 * 9.81 # 10,000 kgf → N
E = 200000.0 # MPa (steel)
if I_section > 0:
displacement = P * L**3 / (3.0 * E * I_section)
else: else:
displacement = 9999.0 raise ValueError(f"Unknown backend: {backend!r}. Use 'stub' or 'nxopen'.")
# Approximate max bending stress
# σ = M*y/I where M = P*L, y = h_total/2
M_max = P * L # N·mm
y_max = h_total / 2.0
if I_section > 0:
stress = M_max * y_max / I_section # MPa
else:
stress = 9999.0
return TrialResult(
success=True,
mass=mass,
tip_displacement=displacement,
max_von_mises=stress,
)
def close(self) -> None:
"""No-op for stub solver."""
logger.info("Stub solver closed.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# NXOpen implementation using existing Atomizer engine # Real solver — wraps optimization_engine
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class NXOpenSolver: class AtomizerNXSolver:
"""Real NX solver using existing Atomizer optimization engine. """Production solver using Atomizer's optimization_engine.
Uses these Atomizer components: Pipeline (proven in SAT3):
- optimization_engine.nx.solver.NXSolver (journal-based solving with run_journal.exe) 1. Create iteration folder with fresh model copies
→ handles iteration folders, expression import via .exp, and NX solve 2. Write .exp file with updated expression values
- optimization_engine.extractors.extract_displacement.extract_displacement() 3. NX journal: open .sim → import .exp → update geometry → solve SOL 101
- optimization_engine.extractors.extract_von_mises_stress.extract_solid_stress() 4. Journal writes mass to _temp_mass.txt
- optimization_engine.extractors.extract_mass_from_expression.extract_mass_from_expression() 5. pyNastran reads .op2 → extract displacement + stress
6. Return results
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 (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__( def __init__(
self, self,
model_dir: str | Path, model_dir: str | Path = ".",
nx_install_dir: str | Path | None = None, nx_version: str = "2412",
timeout: int = 600, timeout: int = 600,
nastran_version: str = "2412", use_iteration_folders: bool = True,
) -> None: ):
"""Initialize NXOpen solver using Atomizer engine. model_dir = Path(model_dir)
if not model_dir.exists():
raise FileNotFoundError(f"Model directory not found: {model_dir}")
Args: self.model_dir = model_dir
model_dir: Path to directory containing Beam.prt, Beam_sim1.sim, etc. self.nx_version = nx_version
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.timeout = timeout
self.nastran_version = nastran_version self.use_iteration_folders = use_iteration_folders
self.nx_install_dir = str(nx_install_dir) if nx_install_dir else None self._iteration = 0
if not self.model_dir.exists(): # Set up iteration base directory
raise FileNotFoundError(f"Model directory not found: {self.model_dir}") self.iterations_dir = model_dir.parent / "2_iterations"
# Validate required files
self.prt_file = self.model_dir / self.PRT_FILENAME
self.sim_file = self.model_dir / self.SIM_FILENAME
for f in (self.prt_file, self.sim_file):
if not f.exists():
raise FileNotFoundError(f"Required file not found: {f}")
# 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) self.iterations_dir.mkdir(parents=True, exist_ok=True)
# Import Atomizer components at init time (fail-fast on missing engine) # Find the .sim file
sim_files = list(model_dir.glob("*.sim"))
if not sim_files:
raise FileNotFoundError(f"No .sim file found in {model_dir}")
self.sim_file = sim_files[0]
logger.info("SIM file: %s", self.sim_file.name)
# Find the .prt file (for mass extraction)
prt_files = [f for f in model_dir.glob("*.prt") if "_i." not in f.name]
if not prt_files:
raise FileNotFoundError(f"No .prt file found in {model_dir}")
self.prt_file = prt_files[0]
logger.info("PRT file: %s", self.prt_file.name)
# Add Atomizer root to path for imports
atomizer_root = self._find_atomizer_root()
if atomizer_root and str(atomizer_root) not in sys.path:
sys.path.insert(0, str(atomizer_root))
logger.info("Added Atomizer root to path: %s", atomizer_root)
# Import optimization_engine components
try: try:
from optimization_engine.nx.solver import NXSolver from optimization_engine.nx.solver import NXSolver
self._nx_solver = NXSolver(
nastran_version=nx_version,
timeout=timeout,
use_journal=True,
study_name="hydrotech_beam_doe",
use_iteration_folders=use_iteration_folders,
master_model_dir=model_dir,
)
logger.info(
"NXSolver initialized (NX %s, timeout=%ds, journal mode)",
nx_version, timeout,
)
except ImportError as e:
raise ImportError(
f"Could not import optimization_engine. "
f"Ensure Atomizer repo root is on PYTHONPATH.\n"
f"Error: {e}"
) from e
# Import extractors
try:
from optimization_engine.extractors.extract_displacement import ( from optimization_engine.extractors.extract_displacement import (
extract_displacement, extract_displacement,
) )
@@ -320,310 +216,259 @@ class NXOpenSolver:
from optimization_engine.extractors.extract_mass_from_expression import ( from optimization_engine.extractors.extract_mass_from_expression import (
extract_mass_from_expression, extract_mass_from_expression,
) )
self._extract_displacement = extract_displacement
self._NXSolver = NXSolver self._extract_stress = extract_solid_stress
self._extract_displacement = staticmethod(extract_displacement) self._extract_mass = extract_mass_from_expression
self._extract_stress = staticmethod(extract_solid_stress) logger.info("Extractors loaded: displacement, von_mises, mass")
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"Could not import extractors from optimization_engine.\n"
f"Ensure {ATOMIZER_REPO_ROOT} is accessible and contains optimization_engine/" f"Error: {e}"
) from e ) from e
# Lazy-init solver on first evaluate() call def _find_atomizer_root(self) -> Optional[Path]:
self._solver: object | None = None """Walk up from model_dir to find the Atomizer repo root."""
self._trial_counter: int = 0 # Look for optimization_engine directory
candidate = self.model_dir
for _ in range(10):
candidate = candidate.parent
if (candidate / "optimization_engine").is_dir():
return candidate
if candidate == candidate.parent:
break
logger.info( # Fallback: common paths
"NXOpenSolver initialized — model_dir=%s, timeout=%ds, nastran=%s", for path in [
self.model_dir, Path("C:/Users/antoi/Atomizer"),
self.timeout, Path("/home/papa/repos/Atomizer"),
self.nastran_version, ]:
) if (path / "optimization_engine").is_dir():
return path
# ------------------------------------------------------------------ logger.warning("Could not find Atomizer root with optimization_engine/")
# Public API return None
# ------------------------------------------------------------------
def evaluate(self, trial_input: TrialInput) -> TrialResult: def solve(self, trial: TrialInput) -> TrialResult:
"""Full NX evaluation pipeline for one trial. """Run a single trial through the NX pipeline.
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: Args:
trial_input: Design variable values. trial: Design variable values.
Returns: Returns:
TrialResult with extracted outputs or failure info. TrialResult with mass, displacement, stress (or failure info).
""" """
self._trial_counter += 1 self._iteration += 1
trial_num = self._trial_counter start_time = time.time()
t_start = self._time.time()
# Build expression update dict
expressions: Dict[str, float] = {
EXPR_HALF_CORE_THICKNESS: trial.beam_half_core_thickness,
EXPR_FACE_THICKNESS: trial.beam_face_thickness,
EXPR_HOLES_DIAMETER: trial.holes_diameter,
EXPR_HOLE_COUNT: float(trial.hole_count), # NX expects float in .exp
}
logger.info( logger.info(
"Trial %d — DVs: core=%.3f mm, face=%.3f mm, hole_d=%.3f mm, n_holes=%d", "Trial %d: core=%.2f face=%.2f dia=%.1f count=%d",
trial_num, self._iteration,
trial_input.beam_half_core_thickness, trial.beam_half_core_thickness,
trial_input.beam_face_thickness, trial.beam_face_thickness,
trial_input.holes_diameter, trial.holes_diameter,
trial_input.hole_count, trial.hole_count,
) )
try: try:
# 0. Lazy-init solver # Step 1: Create iteration folder with fresh model copies + .exp file
if self._solver is None: if self.use_iteration_folders:
self._init_solver() iter_dir = self._nx_solver.create_iteration_folder(
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, iterations_base_dir=self.iterations_dir,
iteration_number=trial_num, iteration_number=self._iteration,
expression_updates=expressions, expression_updates=expressions,
) )
logger.info(" Iteration folder: %s", iter_folder) sim_file = iter_dir / self.sim_file.name
prt_file = iter_dir / self.prt_file.name
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}",
)
# 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=iter_folder,
cleanup=False, # keep OP2/F06 for extraction
expression_updates=expressions,
solution_name=self.SOLUTION_NAME,
)
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)
# 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: else:
op2_file = Path(op2_file) iter_dir = self.model_dir
sim_file = self.sim_file
prt_file = self.prt_file
logger.info(" OP2: %s", op2_file.name) # Step 2: Run NX journal (update expressions + solve)
solve_result = self._nx_solver.run_simulation(
sim_file=sim_file,
working_dir=iter_dir,
expression_updates=expressions,
)
# 4a. Extract displacement if not solve_result.get("success", False):
try: errors = solve_result.get("errors", ["Unknown solver error"])
disp_result = self._extract_displacement(op2_file)
tip_displacement = disp_result["max_displacement"] # mm
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}", solve_time=time.time() - start_time,
error_message=f"NX solve failed: {'; '.join(errors)}",
iteration_dir=str(iter_dir),
) )
# 4b. Extract stress — auto-detect element type (solid or shell) op2_file = solve_result.get("op2_file")
# Pass element_type=None so it checks CTETRA, CHEXA, CPENTA, CPYRAM if not op2_file or not Path(op2_file).exists():
try: return TrialResult(
stress_result = self._extract_stress( success=False,
op2_file, solve_time=time.time() - start_time,
element_type=None, # auto-detect from OP2 contents error_message="OP2 file not generated after solve",
convert_to_mpa=True, # NX kg-mm-s → kPa, convert to MPa iteration_dir=str(iter_dir),
) )
max_von_mises = stress_result["max_von_mises"] # MPa
op2_path = Path(op2_file)
# Step 3: Extract mass from journal temp file
try:
mass_kg = self._extract_mass(prt_file, expression_name=EXPR_MASS)
except Exception as e: except Exception as e:
# Fallback: try shell elements if solid extraction failed logger.warning("Mass extraction failed: %s", e)
logger.warning(" Solid stress extraction failed, trying shell: %s", e) mass_kg = float("nan")
# Step 4: Extract displacement from OP2
try:
disp_result = self._extract_displacement(op2_path)
# For cantilever beam, max displacement IS tip displacement
tip_displacement = disp_result["max_displacement"]
except Exception as e:
logger.warning("Displacement extraction failed: %s", e)
tip_displacement = float("nan")
# Step 5: Extract max von Mises stress from OP2
# Use shell element extraction (CQUAD4 mesh)
try: try:
stress_result = self._extract_stress( stress_result = self._extract_stress(
op2_file, op2_path,
element_type="cquad4", element_type="cquad4",
convert_to_mpa=True, convert_to_mpa=True, # ⚠️ LAC lesson: NX outputs kPa, must convert
)
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}",
)
# 4c. Extract mass — reads _temp_mass.txt written by solve_simulation.py journal
try:
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",
) )
max_vm_stress = stress_result["max_von_mises"]
except Exception as e: except Exception as e:
logger.error(" Mass extraction failed: %s", e) logger.warning("Stress extraction failed: %s", e)
return TrialResult( max_vm_stress = float("nan")
success=False,
error_message=f"Mass extraction failed: {e}",
)
elapsed = self._time.time() - t_start elapsed = time.time() - start_time
logger.info( logger.info(
" Trial %d OK (%.1fs) — mass=%.4f kg, disp=%.4f mm, σ_vm=%.2f MPa", "Trial %d complete: mass=%.2f kg, disp=%.3f mm, stress=%.1f MPa (%.1fs)",
trial_num, self._iteration, mass_kg, tip_displacement, max_vm_stress, elapsed,
elapsed,
mass,
tip_displacement,
max_von_mises,
) )
return TrialResult( return TrialResult(
success=True, success=True,
mass=mass, mass=mass_kg,
tip_displacement=tip_displacement, tip_displacement=tip_displacement,
max_von_mises=max_von_mises, max_von_mises=max_vm_stress,
solve_time=elapsed,
iteration_dir=str(iter_dir),
) )
except Exception as e: except Exception as e:
elapsed = self._time.time() - t_start elapsed = time.time() - start_time
logger.error(" Trial %d FAILED (%.1fs): %s", trial_num, elapsed, e) logger.error("Trial %d failed: %s", self._iteration, e, exc_info=True)
return TrialResult( return TrialResult(
success=False, success=False,
error_message=f"Unexpected error in trial {trial_num}: {e}", solve_time=elapsed,
error_message=str(e),
iteration_dir=str(iter_dir) if 'iter_dir' in locals() else None,
) )
def close(self) -> None: def close(self) -> None:
"""Clean up NX session resources. """Clean up NX solver resources."""
logger.info("AtomizerNXSolver closed. %d iterations completed.", self._iteration)
⚠️ LAC CRITICAL: NEVER kill NX processes directly.
Uses NXSessionManager for safe lock cleanup only.
"""
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:
sm.cleanup_stale_locks()
except Exception as 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 # Stub solver — for development/testing without NX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def create_solver( class StubSolver:
backend: str = "stub", """Synthetic solver for testing without NX.
model_dir: str = "",
nx_install_dir: str | None = None,
timeout: int = 600,
nastran_version: str = "2412",
) -> NXStubSolver | NXOpenSolver:
"""Create an NX solver instance.
Args: Generates physically-plausible approximate results based on
backend: "stub" for development, "nxopen" for real NX (Windows/dalidou only). beam theory. NOT accurate — only for pipeline validation.
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).
nastran_version: NX version (e.g., "2412", "2506", "2512").
Returns:
Solver instance implementing the NXSolverInterface protocol.
Raises:
ValueError: If backend is unknown or model_dir missing for nxopen.
""" """
if backend == "stub":
return NXStubSolver() def __init__(self, **kwargs: Any):
elif backend == "nxopen": self._call_count = 0
if not model_dir: logger.warning(
raise ValueError("model_dir required for nxopen backend") "Using NX STUB solver — results are synthetic approximations. "
return NXOpenSolver( "Replace with AtomizerNXSolver (--backend nxopen) for real evaluations."
model_dir=model_dir,
nx_install_dir=nx_install_dir,
timeout=timeout,
nastran_version=nastran_version,
) )
def solve(self, trial: TrialInput) -> TrialResult:
"""Generate approximate results from beam theory.
Uses simplified cantilever beam formulas:
- Mass ∝ cross-section area × length - hole_volume
- Displacement ~ PL³/3EI (Euler-Bernoulli)
- Stress ~ Mc/I (nominal) with hole SCF
"""
self._call_count += 1
import numpy as np
# Geometry (mm)
L = 5000.0 # beam length
h_half = 250.0 # beam half-height (fixed)
w_half = 150.0 # beam half-width (fixed)
h_core = trial.beam_half_core_thickness
t_face = trial.beam_face_thickness
d_hole = trial.holes_diameter
n_hole = trial.hole_count
# Material: AISI 1005
E = 205000.0 # MPa (Young's modulus)
rho = 7.3e-6 # kg/mm³ (7.3 g/cm³)
# I-beam cross-section second moment of area (approximate)
# Full section: 2*w_half × 2*h_half rectangle
# Minus core cutouts (simplified)
H = 2 * h_half # 500 mm total height
W = 2 * w_half # 300 mm total width
I_full = W * H**3 / 12
# Subtract inner rectangle (core region without faces)
h_web = H - 2 * t_face
w_web = W - 2 * h_core # approximate
I_inner = max(0, w_web) * max(0, h_web)**3 / 12
I_eff = max(I_full - I_inner, I_full * 0.01) # don't go to zero
# Cross-section area (approximate)
A_section = W * H - max(0, w_web) * max(0, h_web)
# Hole volume removal
web_height = H - 2 * t_face
hole_area = n_hole * np.pi * (d_hole / 2)**2
# Only remove from web if holes fit
if d_hole < web_height:
effective_hole_area = min(hole_area, 0.8 * web_height * 4000)
else: else:
raise ValueError(f"Unknown backend: {backend!r}. Use 'stub' or 'nxopen'.") effective_hole_area = 0
# Mass
vol = A_section * L - effective_hole_area * min(h_core * 2, 50)
mass = max(rho * vol, 1.0)
# Tip displacement: δ = PL³ / 3EI
P = 10000 * 9.80665 # 10,000 kgf → N
delta = P * L**3 / (3 * E * I_eff)
# Stress: σ = M*c/I with SCF from holes
M = P * L # max moment at fixed end
c = h_half # distance to extreme fiber
sigma_nominal = M * c / I_eff / 1000 # kPa → MPa
# Stress concentration from holes (simplified)
scf = 1.0 + 0.5 * (d_hole / (web_height + 1))
sigma_max = sigma_nominal * scf
# Add noise (±5%) to simulate model variability
rng = np.random.default_rng(self._call_count)
noise = rng.uniform(0.95, 1.05, 3)
return TrialResult(
success=True,
mass=float(mass * noise[0]),
tip_displacement=float(delta * noise[1]),
max_von_mises=float(sigma_max * noise[2]),
solve_time=0.1,
)
def close(self) -> None:
"""Clean up stub solver."""
logger.info("Stub solver closed.")

View File

@@ -173,7 +173,7 @@ def evaluate_trial(
) )
t_start = time.monotonic() t_start = time.monotonic()
nx_result: TrialResult = solver.evaluate(trial_input) nx_result: TrialResult = solver.solve(trial_input)
t_elapsed = time.monotonic() - t_start t_elapsed = time.monotonic() - t_start
trial.set_user_attr("solve_time_s", round(t_elapsed, 2)) trial.set_user_attr("solve_time_s", round(t_elapsed, 2))