Files
Atomizer/projects/hydrotech-beam/studies/01_doe_landscape/nx_interface.py

399 lines
14 KiB
Python
Raw Normal View History

"""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'.")