"""NX automation interface for Hydrotech Beam optimization. Stub/template module for NXOpen Python API integration. The actual NX automation runs on Windows (dalidou node) via Syncthing-synced model files. This module defines the interface contract. The NXOpen-specific implementation will be filled in when running on the Windows side. NX Expression Names (confirmed via binary introspection — CONTEXT.md): Design Variables: - beam_half_core_thickness (mm, continuous) - beam_face_thickness (mm, continuous) - holes_diameter (mm, continuous) - hole_count (integer, links to Pattern_p7) Outputs: - p173 (mass in kg, body_property147.mass) Fixed: - beam_lenght (⚠️ TYPO in NX — no 'h', 5000 mm) - beam_half_height (250 mm) - beam_half_width (150 mm) References: CONTEXT.md — Full expression map OPTIMIZATION_STRATEGY.md §8.2 — Extractor requirements """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Protocol logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Data types # --------------------------------------------------------------------------- @dataclass(frozen=True) class TrialInput: """Design variable values for a single trial.""" beam_half_core_thickness: float # mm — DV1 beam_face_thickness: float # mm — DV2 holes_diameter: float # mm — DV3 hole_count: int # — DV4 @dataclass class TrialResult: """Results extracted from NX after a trial solve. All values populated after a successful SOL 101 solve. On failure, success=False and error_message explains the failure. """ success: bool mass: float = float("nan") # kg — from expression `p173` tip_displacement: float = float("nan") # mm — from SOL 101 results max_von_mises: float = float("nan") # MPa — from SOL 101 results error_message: str = "" # --------------------------------------------------------------------------- # NX expression name constants # --------------------------------------------------------------------------- # ⚠️ These are EXACT NX expression names from binary introspection. # Do NOT change spelling — `beam_lenght` has a typo (no 'h') in NX. EXPR_HALF_CORE_THICKNESS = "beam_half_core_thickness" EXPR_FACE_THICKNESS = "beam_face_thickness" EXPR_HOLES_DIAMETER = "holes_diameter" EXPR_HOLE_COUNT = "hole_count" EXPR_MASS = "p173" # body_property147.mass, kg EXPR_BEAM_LENGTH = "beam_lenght" # ⚠️ TYPO IN NX — intentional # --------------------------------------------------------------------------- # Interface protocol # --------------------------------------------------------------------------- class NXSolverInterface(Protocol): """Protocol for NX solver backends. Implementors must provide the full pipeline: 1. Update expressions → 2. Rebuild model → 3. Solve SOL 101 → 4. Extract results """ def evaluate(self, trial_input: TrialInput) -> TrialResult: """Run a full NX evaluation cycle for one trial. Args: trial_input: Design variable values. Returns: TrialResult with extracted outputs or failure info. """ ... def close(self) -> None: """Clean up NX session resources. ⚠️ 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: displacement = 9999.0 # 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 template (to be completed on Windows/dalidou) # --------------------------------------------------------------------------- class NXOpenSolver: """Real NXOpen-based solver — TEMPLATE, not yet functional. This class provides the correct structure for NXOpen integration. Expression update code uses the exact names from binary introspection. To complete: 1. Set NX_MODEL_DIR to the Syncthing-synced model directory 2. Implement _open_session() with NXOpen.Session 3. Implement _solve() to trigger SOL 101 4. Implement _extract_displacement() and _extract_stress() from .op2 results or NX result sensors """ def __init__(self, model_dir: str) -> None: """Initialize NXOpen solver. Args: model_dir: Path to directory containing Beam.prt, etc. """ self.model_dir = model_dir self._session = None # NXOpen.Session self._part = None # NXOpen.Part logger.info("NXOpenSolver initialized with model_dir=%s", model_dir) def evaluate(self, trial_input: TrialInput) -> TrialResult: """Full NX evaluation pipeline. Pipeline: 1. Update expressions (beam_half_core_thickness, etc.) 2. Rebuild model (triggers re-mesh of idealized part) 3. Solve SOL 101 4. Extract mass (p173), displacement, stress """ raise NotImplementedError( "NXOpenSolver.evaluate() is a template — implement on Windows " "with NXOpen Python API. See docstring for pipeline." ) def _update_expressions(self, trial_input: TrialInput) -> None: """Update NX expressions for a trial. ⚠️ Expression names are EXACT from binary introspection. ⚠️ `beam_lenght` has a typo (no 'h') — do NOT correct it. This is the correct NXOpen code pattern (to be run on Windows): ```python import NXOpen session = NXOpen.Session.GetSession() part = session.Parts.Work # Update design variables expressions = { "beam_half_core_thickness": trial_input.beam_half_core_thickness, "beam_face_thickness": trial_input.beam_face_thickness, "holes_diameter": trial_input.holes_diameter, "hole_count": trial_input.hole_count, } for expr_name, value in expressions.items(): expr = part.Expressions.FindObject(expr_name) unit = expr.Units part.Expressions.EditWithUnits(expr, unit, str(value)) # Rebuild (update model to reflect new expressions) session.UpdateManager.DoUpdate( session.SetUndoMark(NXOpen.Session.MarkVisibility.Invisible, "Update") ) ``` """ raise NotImplementedError("Template — implement with NXOpen") def _solve(self) -> bool: """Trigger NX Nastran SOL 101 solve. ```python # Open the sim file sim_part = session.Parts.OpenDisplay( os.path.join(self.model_dir, "Beam_sim1.sim"), None ) # Get the solution and solve sim_simulation = sim_part.Simulation solution = sim_simulation.Solutions[0] # First solution solution.Solve() # Check solve status return solution.SolveStatus == NXOpen.CAE.Solution.Status.Solved ``` """ raise NotImplementedError("Template — implement with NXOpen") def _extract_mass(self) -> float: """Extract mass from NX expression p173. ```python mass_expr = part.Expressions.FindObject("p173") return mass_expr.Value # kg ``` """ raise NotImplementedError("Template — implement with NXOpen") def _extract_displacement(self) -> float: """Extract tip displacement from SOL 101 results. Options (TBD — need to determine best approach): 1. NX result sensor at beam tip → read value directly 2. Parse .op2 file with pyNastran → find max displacement at tip nodes 3. NX post-processing API → query displacement field Returns: Tip displacement in mm. """ raise NotImplementedError( "Template — extraction method TBD. " "Options: result sensor, .op2 parsing, or NX post-processing API." ) def _extract_stress(self) -> float: """Extract max von Mises stress from SOL 101 results. ⚠️ LAC LESSON: pyNastran returns stress in kPa for NX kg-mm-s unit system. Divide by 1000 for MPa. Options (TBD): 1. NX result sensor for max VM stress → read value directly 2. Parse .op2 with pyNastran → max elemental nodal VM stress 3. NX post-processing API → query stress field Returns: Max von Mises stress in MPa. """ raise NotImplementedError( "Template — extraction method TBD. " "Remember: pyNastran stress is in kPa → divide by 1000 for MPa." ) def close(self) -> None: """Close NX session gracefully. ⚠️ LAC CRITICAL: NEVER kill NX processes directly. Use NXSessionManager.close_nx_if_allowed() only. If NX hangs, implement a timeout (10 min per trial) and let NX time out gracefully. """ raise NotImplementedError("Template — implement graceful shutdown") # --------------------------------------------------------------------------- # Factory # --------------------------------------------------------------------------- def create_solver( backend: str = "stub", model_dir: str = "", ) -> NXStubSolver | NXOpenSolver: """Create an NX solver instance. Args: backend: "stub" for development, "nxopen" for real NX (Windows only). model_dir: Path to NX model directory (required for nxopen backend). Returns: Solver instance implementing the NXSolverInterface protocol. Raises: ValueError: If backend is unknown. """ if backend == "stub": return NXStubSolver() elif backend == "nxopen": if not model_dir: raise ValueError("model_dir required for nxopen backend") return NXOpenSolver(model_dir=model_dir) else: raise ValueError(f"Unknown backend: {backend!r}. Use 'stub' or 'nxopen'.")