- Fixed drone gimbal optimization to use proper semantic directions - Changed from ['minimize', 'minimize'] to ['minimize', 'maximize'] - Updated Claude skill (v2.0) with Phase 3.3 integration - Added centralized extractor library documentation - Added multi-objective optimization (Protocol 11) section - Added NX multi-solution protocol documentation - Added dashboard integration documentation - Fixed Pareto front degenerate issue with proper NSGA-II configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""
|
|
Drone Gimbal Arm Optimization - Protocol 11 (Multi-Objective NSGA-II)
|
|
======================================================================
|
|
|
|
Multi-objective optimization using NSGA-II to find Pareto front:
|
|
1. Minimize mass (target < 120g)
|
|
2. Maximize fundamental frequency (target > 150 Hz)
|
|
|
|
Constraints:
|
|
- Max displacement < 1.5mm (850g camera payload)
|
|
- Max stress < 120 MPa (Al 6061-T6, SF=2.3)
|
|
- Natural frequency > 150 Hz (avoid rotor resonance)
|
|
|
|
Usage:
|
|
python run_optimization.py --trials 30
|
|
python run_optimization.py --trials 5 # Quick test
|
|
python run_optimization.py --resume # Continue existing study
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Add project root to path
|
|
project_root = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
import optuna
|
|
from optuna.samplers import NSGAIISampler
|
|
from optimization_engine.nx_solver import NXSolver
|
|
from optimization_engine.extractors.extract_displacement import extract_displacement
|
|
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
|
|
from optimization_engine.extractors.op2_extractor import OP2Extractor
|
|
from optimization_engine.extractors.extract_frequency import extract_frequency
|
|
from optimization_engine.extractors.extract_mass_from_bdf import extract_mass_from_bdf
|
|
|
|
# Import central configuration
|
|
try:
|
|
import config as atomizer_config
|
|
except ImportError:
|
|
atomizer_config = None
|
|
|
|
|
|
def load_config(config_file: Path) -> dict:
|
|
"""Load configuration from JSON file."""
|
|
with open(config_file, 'r') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Run drone gimbal arm multi-objective optimization')
|
|
parser.add_argument('--trials', type=int, default=30, help='Number of optimization trials')
|
|
parser.add_argument('--resume', action='store_true', help='Resume existing study')
|
|
args = parser.parse_args()
|
|
|
|
# Get study directory
|
|
study_dir = Path(__file__).parent
|
|
|
|
print("=" * 80)
|
|
print("DRONE GIMBAL ARM OPTIMIZATION - PROTOCOL 11 (NSGA-II)")
|
|
print("=" * 80)
|
|
print()
|
|
print("Engineering Scenario:")
|
|
print(" Professional aerial cinematography drone camera gimbal support arm")
|
|
print()
|
|
print("Objectives:")
|
|
print(" 1. MINIMIZE mass (target < 4000g, baseline = 4500g)")
|
|
print(" 2. MAXIMIZE fundamental frequency (target > 150 Hz)")
|
|
print()
|
|
print("Constraints:")
|
|
print(" - Max displacement < 1.5mm (850g camera payload)")
|
|
print(" - Max von Mises stress < 120 MPa (Al 6061-T6, SF=2.3)")
|
|
print(" - Natural frequency > 150 Hz (avoid rotor resonance 80-120 Hz)")
|
|
print()
|
|
print("Design Variables:")
|
|
print(" - beam_half_core_thickness: 5-10 mm")
|
|
print(" - beam_face_thickness: 1-3 mm")
|
|
print(" - holes_diameter: 10-50 mm")
|
|
print(" - hole_count: 8-14")
|
|
print()
|
|
print(f"Running {args.trials} trials with NSGA-II sampler...")
|
|
print("=" * 80)
|
|
print()
|
|
|
|
# Load configuration
|
|
opt_config_file = study_dir / "1_setup" / "optimization_config.json"
|
|
|
|
if not opt_config_file.exists():
|
|
print(f"[ERROR] Optimization config not found: {opt_config_file}")
|
|
sys.exit(1)
|
|
|
|
opt_config = load_config(opt_config_file)
|
|
|
|
print(f"Loaded optimization config: {opt_config['study_name']}")
|
|
print(f"Protocol: {opt_config['optimization_settings']['protocol']}")
|
|
print()
|
|
|
|
# Setup paths
|
|
model_dir = study_dir / "1_setup" / "model"
|
|
model_file = model_dir / "Beam.prt"
|
|
sim_file = model_dir / "Beam_sim1.sim"
|
|
results_dir = study_dir / "2_results"
|
|
results_dir.mkdir(exist_ok=True)
|
|
|
|
# Initialize NX solver
|
|
nx_solver = NXSolver(
|
|
nastran_version=atomizer_config.NX_VERSION if atomizer_config else "2412",
|
|
timeout=atomizer_config.NASTRAN_TIMEOUT if atomizer_config else 600,
|
|
use_journal=True,
|
|
enable_session_management=True,
|
|
study_name="drone_gimbal_arm_optimization"
|
|
)
|
|
|
|
def objective(trial: optuna.Trial) -> tuple:
|
|
"""
|
|
Multi-objective function for NSGA-II.
|
|
|
|
Returns:
|
|
(mass, frequency): Tuple for NSGA-II (minimize mass, maximize frequency)
|
|
"""
|
|
# Sample design variables
|
|
design_vars = {}
|
|
for dv in opt_config['design_variables']:
|
|
design_vars[dv['parameter']] = trial.suggest_float(
|
|
dv['parameter'],
|
|
dv['bounds'][0],
|
|
dv['bounds'][1]
|
|
)
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f"Trial #{trial.number}")
|
|
print(f"{'='*60}")
|
|
print(f"Design Variables:")
|
|
for name, value in design_vars.items():
|
|
print(f" {name}: {value:.3f}")
|
|
|
|
# Run simulation
|
|
print(f"\nRunning simulation...")
|
|
try:
|
|
result = nx_solver.run_simulation(
|
|
sim_file=sim_file,
|
|
working_dir=model_dir,
|
|
expression_updates=design_vars,
|
|
solution_name=None # Solve all solutions (static + modal)
|
|
)
|
|
|
|
if not result['success']:
|
|
print(f"[ERROR] Simulation failed: {result.get('error', 'Unknown error')}")
|
|
trial.set_user_attr("feasible", False)
|
|
trial.set_user_attr("error", result.get('error', 'Unknown'))
|
|
# Prune failed simulations instead of returning penalty values
|
|
raise optuna.TrialPruned(f"Simulation failed: {result.get('error', 'Unknown error')}")
|
|
|
|
op2_file = result['op2_file']
|
|
print(f"Simulation successful: {op2_file}")
|
|
|
|
# Extract all objectives and constraints
|
|
print(f"\nExtracting results...")
|
|
|
|
# Extract mass (grams) from CAD expression p173
|
|
# This expression measures the CAD mass directly
|
|
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
|
|
|
|
prt_file = model_file # Beam.prt
|
|
mass_kg = extract_mass_from_expression(prt_file, expression_name="p173")
|
|
mass = mass_kg * 1000.0 # Convert to grams
|
|
print(f" mass: {mass:.3f} g (from CAD expression p173)")
|
|
|
|
# Extract frequency (Hz) - from modal analysis (solution 2)
|
|
# The drone gimbal has TWO solutions: solution_1 (static) and solution_2 (modal)
|
|
op2_modal = str(op2_file).replace("solution_1", "solution_2")
|
|
freq_result = extract_frequency(op2_modal, subcase=1, mode_number=1)
|
|
frequency = freq_result['frequency']
|
|
print(f" fundamental_frequency: {frequency:.3f} Hz")
|
|
|
|
# Extract displacement (mm) - from static analysis (subcase 1)
|
|
disp_result = extract_displacement(op2_file, subcase=1)
|
|
max_disp = disp_result['max_displacement']
|
|
print(f" max_displacement_limit: {max_disp:.3f} mm")
|
|
|
|
# Extract stress (MPa) - from static analysis (subcase 1)
|
|
stress_result = extract_solid_stress(op2_file, subcase=1, element_type='cquad4')
|
|
max_stress = stress_result['max_von_mises']
|
|
print(f" max_stress_limit: {max_stress:.3f} MPa")
|
|
|
|
# Frequency constraint uses same value as objective
|
|
min_freq = frequency
|
|
print(f" min_frequency_limit: {min_freq:.3f} Hz")
|
|
|
|
# Check constraints
|
|
constraint_values = {
|
|
'max_displacement_limit': max_disp,
|
|
'max_stress_limit': max_stress,
|
|
'min_frequency_limit': min_freq
|
|
}
|
|
|
|
constraint_violations = []
|
|
for constraint in opt_config['constraints']:
|
|
name = constraint['name']
|
|
value = constraint_values[name]
|
|
threshold = constraint['threshold']
|
|
c_type = constraint['type']
|
|
|
|
if c_type == 'less_than' and value > threshold:
|
|
violation = (value - threshold) / threshold
|
|
constraint_violations.append(f"{name}: {value:.2f} > {threshold} (violation: {violation:.1%})")
|
|
elif c_type == 'greater_than' and value < threshold:
|
|
violation = (threshold - value) / threshold
|
|
constraint_violations.append(f"{name}: {value:.2f} < {threshold} (violation: {violation:.1%})")
|
|
|
|
if constraint_violations:
|
|
print(f"\n[WARNING] Constraint violations:")
|
|
for v in constraint_violations:
|
|
print(f" - {v}")
|
|
trial.set_user_attr("constraint_violations", constraint_violations)
|
|
trial.set_user_attr("feasible", False)
|
|
# NSGA-II handles constraints through constraint_satisfied flag - no penalty needed
|
|
else:
|
|
print(f"\n[OK] All constraints satisfied")
|
|
trial.set_user_attr("feasible", True)
|
|
|
|
# Store all results as trial attributes for dashboard
|
|
trial.set_user_attr("mass", mass)
|
|
trial.set_user_attr("frequency", frequency)
|
|
trial.set_user_attr("max_displacement", max_disp)
|
|
trial.set_user_attr("max_stress", max_stress)
|
|
trial.set_user_attr("design_vars", design_vars)
|
|
|
|
print(f"\nObjectives:")
|
|
print(f" mass: {mass:.2f} g (minimize)")
|
|
print(f" frequency: {frequency:.2f} Hz (maximize)")
|
|
|
|
# Return tuple for NSGA-II: (minimize mass, maximize frequency)
|
|
# Using proper semantic directions in study creation
|
|
return (mass, frequency)
|
|
|
|
except optuna.TrialPruned:
|
|
# Re-raise pruned exceptions (don't catch them)
|
|
raise
|
|
except Exception as e:
|
|
print(f"\n[ERROR] Trial failed with exception: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
trial.set_user_attr("error", str(e))
|
|
trial.set_user_attr("feasible", False)
|
|
# Prune corrupted trials instead of returning penalty values
|
|
raise optuna.TrialPruned(f"Trial failed with exception: {str(e)}")
|
|
|
|
# Create Optuna study with NSGA-II sampler
|
|
study_name = opt_config['study_name']
|
|
storage = f"sqlite:///{results_dir / 'study.db'}"
|
|
|
|
if args.resume:
|
|
print(f"[INFO] Resuming existing study: {study_name}")
|
|
study = optuna.load_study(
|
|
study_name=study_name,
|
|
storage=storage,
|
|
sampler=NSGAIISampler()
|
|
)
|
|
print(f"[INFO] Loaded study with {len(study.trials)} existing trials")
|
|
else:
|
|
print(f"[INFO] Creating new study: {study_name}")
|
|
study = optuna.create_study(
|
|
study_name=study_name,
|
|
storage=storage,
|
|
directions=['minimize', 'maximize'], # Minimize mass, maximize frequency
|
|
sampler=NSGAIISampler(),
|
|
load_if_exists=True # Always allow resuming existing study
|
|
)
|
|
|
|
# Run optimization
|
|
print(f"\nStarting optimization with {args.trials} trials...")
|
|
print()
|
|
|
|
study.optimize(
|
|
objective,
|
|
n_trials=args.trials,
|
|
show_progress_bar=True
|
|
)
|
|
|
|
# Save final results
|
|
print()
|
|
print("=" * 80)
|
|
print("Optimization Complete!")
|
|
print("=" * 80)
|
|
print()
|
|
print(f"Total trials: {len(study.trials)}")
|
|
print(f"Pareto front solutions: {len(study.best_trials)}")
|
|
print()
|
|
|
|
# Show Pareto front
|
|
print("Pareto Front (non-dominated solutions):")
|
|
print()
|
|
for i, trial in enumerate(study.best_trials):
|
|
mass = trial.values[0]
|
|
freq = trial.values[1] # Frequency is stored as positive now
|
|
feasible = trial.user_attrs.get('feasible', False)
|
|
print(f" Solution #{i+1} (Trial {trial.number}):")
|
|
print(f" Mass: {mass:.2f} g")
|
|
print(f" Frequency: {freq:.2f} Hz")
|
|
print(f" Feasible: {feasible}")
|
|
print()
|
|
|
|
print("Results available in: studies/drone_gimbal_arm_optimization/2_results/")
|
|
print()
|
|
print("View in Dashboard:")
|
|
print(" 1. Ensure backend is running: cd atomizer-dashboard/backend && python -m uvicorn api.main:app --reload")
|
|
print(" 2. Open dashboard: http://localhost:3003")
|
|
print(" 3. Select study: drone_gimbal_arm_optimization")
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|