399 lines
14 KiB
Python
399 lines
14 KiB
Python
|
|
"""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'.")
|