feat: Add robust NX expression import system for all expression types
Major Enhancement: - Implemented .exp file-based expression updates via NX journal scripts - Fixes critical issue with feature-linked expressions (e.g., hole_count) - Supports ALL NX expression types including binary-stored ones - Full 4D design space validation completed successfully New Components: 1. import_expressions.py - NX journal for .exp file import - Uses NXOpen.ExpressionCollection.ImportFromFile() - Replace mode overwrites existing values - Automatic model update and save - Comprehensive error handling 2. export_expressions.py - NX journal for .exp file export - Exports all expressions to text format - Used for unit detection and verification 3. Enhanced nx_updater.py - New update_expressions_via_import() method - Automatic unit detection from .exp export - Creates study-variable-only .exp files - Replaces fragile binary .prt editing Technical Details: - .exp Format: [Units]name=value (e.g., [MilliMeter]beam_length=5000) - Unitless expressions: name=value (e.g., hole_count=10) - Robustness: Native NX functionality, no regex failures - Performance: < 1 second per update operation Validation: - Simple Beam Optimization study (4D design space) * beam_half_core_thickness: 10-40 mm * beam_face_thickness: 10-40 mm * holes_diameter: 150-450 mm * hole_count: 5-15 (integer) Results: ✅ 3-trial validation completed successfully ✅ All 4 variables update correctly in all trials ✅ Mesh adaptation verified (hole_count: 6, 15, 11 → different mesh sizes) ✅ Trial 0: 5373 CQUAD4 elements (6 holes) ✅ Trial 1: 5158 CQUAD4 + 1 CTRIA3 (15 holes) ✅ Trial 2: 5318 CQUAD4 (11 holes) Problem Solved: - hole_count expression was not updating with binary .prt editing - Expression stored in feature parameter, not accessible via text regex - Binary format prevented reliable text-based updates Solution: - Use NX native expression import/export - Works for ALL expressions (text and binary-stored) - Automatic unit handling - Model update integrated in journal Documentation: - New: docs/NX_EXPRESSION_IMPORT_SYSTEM.md (comprehensive guide) - Updated: CHANGELOG.md with Phase 3.2 progress - Study: studies/simple_beam_optimization/ (complete example) Files Added: - optimization_engine/import_expressions.py - optimization_engine/export_expressions.py - docs/NX_EXPRESSION_IMPORT_SYSTEM.md - studies/simple_beam_optimization/ (full study) Files Modified: - optimization_engine/nx_updater.py - CHANGELOG.md Compatibility: - NX 2412 tested and verified - Python 3.10+ - Works with all NX expression types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
350
studies/simple_beam_optimization/run_optimization.py
Normal file
350
studies/simple_beam_optimization/run_optimization.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Simple Beam Optimization Study
|
||||
===============================
|
||||
|
||||
Multi-objective optimization:
|
||||
- Minimize displacement (constraint: < 10mm)
|
||||
- Minimize stress
|
||||
- Minimize mass
|
||||
|
||||
Design Variables:
|
||||
- beam_half_core_thickness: 10-40 mm
|
||||
- beam_face_thickness: 10-40 mm
|
||||
- holes_diameter: 150-450 mm
|
||||
- hole_count: 5-15
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import optuna
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
# Add parent directories to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from optimization_engine.nx_updater import NXParameterUpdater
|
||||
from optimization_engine.nx_solver import NXSolver
|
||||
from optimization_engine.result_extractors.generated.extract_displacement import extract_displacement
|
||||
from optimization_engine.result_extractors.generated.extract_solid_stress import extract_solid_stress
|
||||
from optimization_engine.result_extractors.generated.extract_expression import extract_expression
|
||||
|
||||
|
||||
def print_section(title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
|
||||
def load_config(config_file: Path) -> dict:
|
||||
"""Load JSON configuration."""
|
||||
with open(config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def main():
|
||||
print_section("SIMPLE BEAM OPTIMIZATION STUDY")
|
||||
|
||||
# File paths
|
||||
study_dir = Path(__file__).parent
|
||||
config_file = study_dir / "beam_optimization_config.json"
|
||||
prt_file = study_dir / "model" / "Beam.prt"
|
||||
sim_file = study_dir / "model" / "Beam_sim1.sim"
|
||||
|
||||
if not config_file.exists():
|
||||
print(f"ERROR: Config file not found: {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
if not prt_file.exists():
|
||||
print(f"ERROR: Part file not found: {prt_file}")
|
||||
sys.exit(1)
|
||||
|
||||
if not sim_file.exists():
|
||||
print(f"ERROR: Simulation file not found: {sim_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# Load configuration
|
||||
config = load_config(config_file)
|
||||
|
||||
print("Study Configuration:")
|
||||
print(f" - Study: {config['study_name']}")
|
||||
print(f" - Substudy: {config['substudy_name']}")
|
||||
print(f" - Description: {config['description']}")
|
||||
print()
|
||||
print("Objectives:")
|
||||
for obj in config['objectives']:
|
||||
print(f" - {obj['name']}: weight={obj['weight']}")
|
||||
print()
|
||||
print("Constraints:")
|
||||
for con in config['constraints']:
|
||||
print(f" - {con['name']}: {con['type']} {con['value']} {con['units']}")
|
||||
print()
|
||||
print("Design Variables:")
|
||||
for var_name, var_info in config['design_variables'].items():
|
||||
print(f" - {var_name}: {var_info['min']}-{var_info['max']} {var_info['units']}")
|
||||
print()
|
||||
print(f"Optimization Settings:")
|
||||
print(f" - Algorithm: {config['optimization_settings']['algorithm']}")
|
||||
print(f" - Trials: {config['optimization_settings']['n_trials']}")
|
||||
print(f" - Sampler: {config['optimization_settings']['sampler']}")
|
||||
print()
|
||||
|
||||
# Setup output directory
|
||||
output_dir = study_dir / "substudies" / config['substudy_name']
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Part file: {prt_file}")
|
||||
print(f"Simulation file: {sim_file}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# DEFINE OBJECTIVE FUNCTION
|
||||
# =========================================================================
|
||||
|
||||
def objective(trial: optuna.Trial) -> float:
|
||||
"""
|
||||
Optuna objective function.
|
||||
|
||||
Evaluates one design point:
|
||||
1. Updates geometry parameters
|
||||
2. Runs FEM simulation
|
||||
3. Extracts results
|
||||
4. Computes weighted multi-objective with penalties
|
||||
"""
|
||||
trial_num = trial.number
|
||||
|
||||
print(f"\n[Trial {trial_num}] Starting...")
|
||||
|
||||
# Sample design variables
|
||||
design_vars = {}
|
||||
for var_name, var_info in config['design_variables'].items():
|
||||
if var_info['type'] == 'continuous':
|
||||
design_vars[var_name] = trial.suggest_float(
|
||||
var_name,
|
||||
var_info['min'],
|
||||
var_info['max']
|
||||
)
|
||||
elif var_info['type'] == 'integer':
|
||||
design_vars[var_name] = trial.suggest_int(
|
||||
var_name,
|
||||
int(var_info['min']),
|
||||
int(var_info['max'])
|
||||
)
|
||||
|
||||
print(f"[Trial {trial_num}] Design variables:")
|
||||
for var_name, var_value in design_vars.items():
|
||||
print(f" - {var_name}: {var_value:.3f}")
|
||||
|
||||
# Create trial directory
|
||||
trial_dir = output_dir / f"trial_{trial_num:03d}"
|
||||
trial_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Copy all 4 files to trial directory (.prt, _i.prt, .fem, .sim)
|
||||
import shutil
|
||||
trial_prt = trial_dir / prt_file.name
|
||||
trial_sim = trial_dir / sim_file.name
|
||||
|
||||
shutil.copy2(prt_file, trial_prt)
|
||||
shutil.copy2(sim_file, trial_sim)
|
||||
|
||||
# Copy FEM file
|
||||
fem_file = prt_file.parent / f"{prt_file.stem}_fem1.fem"
|
||||
if fem_file.exists():
|
||||
trial_fem = trial_dir / fem_file.name
|
||||
shutil.copy2(fem_file, trial_fem)
|
||||
|
||||
# Copy idealized geometry (_i.prt) - contains midsurface thickness data
|
||||
# Pattern: Beam_fem1_i.prt (derived from FEM file name)
|
||||
if fem_file.exists():
|
||||
prt_i_file = prt_file.parent / f"{fem_file.stem}_i.prt"
|
||||
if prt_i_file.exists():
|
||||
trial_prt_i = trial_dir / prt_i_file.name
|
||||
shutil.copy2(prt_i_file, trial_prt_i)
|
||||
|
||||
try:
|
||||
# Update geometry
|
||||
print(f"[Trial {trial_num}] Updating geometry...")
|
||||
updater = NXParameterUpdater(trial_prt)
|
||||
updater.update_expressions(design_vars)
|
||||
|
||||
# Run simulation
|
||||
print(f"[Trial {trial_num}] Running FEM simulation...")
|
||||
solver = NXSolver()
|
||||
result = solver.run_simulation(trial_sim)
|
||||
|
||||
if not result['success']:
|
||||
raise RuntimeError(f"Simulation failed: {result}")
|
||||
|
||||
op2_file = result['op2_file']
|
||||
|
||||
print(f"[Trial {trial_num}] Extracting results...")
|
||||
|
||||
# Extract displacement
|
||||
disp_result = extract_displacement(op2_file)
|
||||
max_disp = disp_result['max_displacement']
|
||||
|
||||
# Extract stress
|
||||
stress_result = extract_solid_stress(op2_file)
|
||||
max_stress = stress_result['max_von_mises']
|
||||
|
||||
# Extract mass
|
||||
mass_result = extract_expression(trial_prt, 'p173')
|
||||
mass = mass_result['p173']
|
||||
|
||||
print(f"[Trial {trial_num}] Results:")
|
||||
print(f" - Displacement: {max_disp:.3f} mm")
|
||||
print(f" - Stress: {max_stress:.3f} MPa")
|
||||
print(f" - Mass: {mass:.3f} kg")
|
||||
|
||||
# Compute weighted multi-objective
|
||||
objective_value = 0.0
|
||||
|
||||
for obj in config['objectives']:
|
||||
if obj['extractor'] == 'max_displacement':
|
||||
value = max_disp
|
||||
elif obj['extractor'] == 'max_stress':
|
||||
value = max_stress
|
||||
elif obj['extractor'] == 'mass':
|
||||
value = mass
|
||||
else:
|
||||
continue
|
||||
|
||||
weight = obj['weight']
|
||||
objective_value += weight * value
|
||||
|
||||
# Apply constraint penalties
|
||||
penalty = 0.0
|
||||
for constraint in config['constraints']:
|
||||
if constraint['extractor'] == 'max_displacement':
|
||||
current_value = max_disp
|
||||
elif constraint['extractor'] == 'max_stress':
|
||||
current_value = max_stress
|
||||
else:
|
||||
continue
|
||||
|
||||
if constraint['type'] == 'less_than':
|
||||
if current_value > constraint['value']:
|
||||
violation = (current_value - constraint['value']) / constraint['value']
|
||||
penalty += 1000.0 * violation
|
||||
print(f"[Trial {trial_num}] CONSTRAINT VIOLATED: {constraint['name']}")
|
||||
print(f" Current: {current_value:.3f}, Limit: {constraint['value']}")
|
||||
|
||||
total_objective = objective_value + penalty
|
||||
|
||||
print(f"[Trial {trial_num}] Objective: {objective_value:.3f}, Penalty: {penalty:.3f}, Total: {total_objective:.3f}")
|
||||
|
||||
# Save trial results
|
||||
trial_results = {
|
||||
'trial_number': trial_num,
|
||||
'design_variables': design_vars,
|
||||
'results': {
|
||||
'max_displacement': max_disp,
|
||||
'max_stress': max_stress,
|
||||
'mass': mass
|
||||
},
|
||||
'objective': objective_value,
|
||||
'penalty': penalty,
|
||||
'total_objective': total_objective,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(trial_dir / "results.json", 'w') as f:
|
||||
json.dump(trial_results, f, indent=2)
|
||||
|
||||
return total_objective
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Trial {trial_num}] FAILED: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1e10 # Return large penalty for failed trials
|
||||
|
||||
# =========================================================================
|
||||
# RUN OPTIMIZATION
|
||||
# =========================================================================
|
||||
|
||||
print_section("RUNNING OPTIMIZATION")
|
||||
|
||||
# Create Optuna study
|
||||
study = optuna.create_study(
|
||||
direction='minimize',
|
||||
sampler=optuna.samplers.TPESampler() if config['optimization_settings']['sampler'] == 'TPE' else None
|
||||
)
|
||||
|
||||
# Run optimization
|
||||
print(f"Starting {config['optimization_settings']['n_trials']} optimization trials...")
|
||||
print()
|
||||
|
||||
study.optimize(
|
||||
objective,
|
||||
n_trials=config['optimization_settings']['n_trials'],
|
||||
show_progress_bar=True
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# SAVE RESULTS
|
||||
# =========================================================================
|
||||
|
||||
print_section("SAVING RESULTS")
|
||||
|
||||
# Save full study
|
||||
study_file = output_dir / "optuna_study.pkl"
|
||||
import pickle
|
||||
with open(study_file, 'wb') as f:
|
||||
pickle.dump(study, f)
|
||||
|
||||
print(f"Study saved to: {study_file}")
|
||||
|
||||
# Save best trial
|
||||
best_trial = study.best_trial
|
||||
best_results = {
|
||||
'best_trial_number': best_trial.number,
|
||||
'best_params': best_trial.params,
|
||||
'best_value': best_trial.value,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
best_file = output_dir / "best_trial.json"
|
||||
with open(best_file, 'w') as f:
|
||||
json.dump(best_results, f, indent=2)
|
||||
|
||||
print(f"Best trial saved to: {best_file}")
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# PRINT SUMMARY
|
||||
# =========================================================================
|
||||
|
||||
print_section("OPTIMIZATION COMPLETE")
|
||||
|
||||
print(f"Total trials: {len(study.trials)}")
|
||||
print(f"Best trial: {best_trial.number}")
|
||||
print(f"Best objective value: {best_trial.value:.6f}")
|
||||
print()
|
||||
print("Best design variables:")
|
||||
for var_name, var_value in best_trial.params.items():
|
||||
print(f" - {var_name}: {var_value:.3f}")
|
||||
print()
|
||||
|
||||
# Load best trial results to show performance
|
||||
best_trial_dir = output_dir / f"trial_{best_trial.number:03d}"
|
||||
best_results_file = best_trial_dir / "results.json"
|
||||
|
||||
if best_results_file.exists():
|
||||
with open(best_results_file, 'r') as f:
|
||||
best_results = json.load(f)
|
||||
|
||||
print("Best performance:")
|
||||
print(f" - Displacement: {best_results['results']['max_displacement']:.3f} mm")
|
||||
print(f" - Stress: {best_results['results']['max_stress']:.3f} MPa")
|
||||
print(f" - Mass: {best_results['results']['mass']:.3f} kg")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user