Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""
|
|
UAV Arm Optimization with AtomizerField Neural Surrogate
|
|
=========================================================
|
|
|
|
This script demonstrates the integration of AtomizerField neural network surrogates
|
|
for dramatic speedup in FEA-based optimization. The workflow includes:
|
|
|
|
1. Initial FEA exploration (30 trials) to collect training data
|
|
2. Optional neural network training on collected data
|
|
3. Neural-accelerated optimization (140 trials) with 600x+ speedup
|
|
4. Final FEA validation (20 trials) to verify best designs
|
|
|
|
Expected speedup: 600x-500,000x over pure FEA optimization
|
|
|
|
Usage:
|
|
python run_optimization.py --trials 30 # Initial FEA phase
|
|
python run_optimization.py --trials 200 --enable-nn # Full optimization with neural
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
import json
|
|
import argparse
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
# Add parent directory to path
|
|
project_root = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(project_root))
|
|
|
|
import optuna
|
|
from optuna.samplers import NSGAIISampler
|
|
|
|
# Use NXSolver (subprocess-based) instead of direct NXOpen imports
|
|
from optimization_engine.nx.solver import NXSolver
|
|
|
|
# Import extractors
|
|
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.extract_frequency import extract_frequency
|
|
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
|
|
|
|
# Import structured logger
|
|
from optimization_engine.utils.logger import get_logger
|
|
|
|
# Import training data exporter for AtomizerField
|
|
from optimization_engine.processors.surrogates.training_data_exporter import TrainingDataExporter
|
|
|
|
# Import neural surrogate for fast predictions
|
|
from optimization_engine.processors.surrogates.neural_surrogate import create_surrogate_for_study, NeuralSurrogate
|
|
|
|
|
|
def load_config(config_file: Path) -> dict:
|
|
"""Load configuration from JSON file."""
|
|
with open(config_file, 'r') as f:
|
|
return json.load(f)
|
|
|
|
|
|
def neural_objective(trial: optuna.Trial, config: dict, surrogate: NeuralSurrogate,
|
|
model_file: Path, logger) -> tuple:
|
|
"""
|
|
Neural surrogate objective function for FAST optimization.
|
|
|
|
Uses trained neural network instead of FEA - 600x+ faster!
|
|
|
|
Returns tuple: (mass, -frequency) for NSGA-II optimization
|
|
"""
|
|
# Sample design variables
|
|
design_vars = {}
|
|
for var in config['design_variables']:
|
|
param_name = var['parameter']
|
|
bounds = var['bounds']
|
|
design_vars[param_name] = trial.suggest_float(param_name, bounds[0], bounds[1])
|
|
|
|
logger.trial_start(trial.number, design_vars)
|
|
|
|
try:
|
|
# Get neural network predictions (FAST!)
|
|
prediction = surrogate.predict(design_vars)
|
|
|
|
# Extract predictions
|
|
max_displacement = prediction['max_displacement']
|
|
max_stress = prediction.get('max_stress', 0.0) # May not be trained well
|
|
inference_time = prediction['inference_time_ms']
|
|
|
|
# Mass still needs CAD extraction (expression-based, fast)
|
|
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
|
|
mass_kg = extract_mass_from_expression(model_file, expression_name="p173")
|
|
mass = mass_kg * 1000.0 # Convert to grams
|
|
|
|
# Frequency approximation from neural network
|
|
# Note: Current model predicts displacement, not frequency directly
|
|
# For now, use a simple heuristic: stiffer = higher frequency
|
|
# TODO: Train separate frequency model or extend current model
|
|
frequency = 100.0 # Placeholder - will validate with FEA later
|
|
|
|
logger.info(f" [NEURAL] mass: {mass:.3f} g, max_disp: {max_displacement:.4f} mm")
|
|
logger.info(f" [NEURAL] inference: {inference_time:.2f} ms (vs ~30s FEA)")
|
|
|
|
# Check constraints
|
|
constraints = config.get('constraints', [])
|
|
feasible = True
|
|
constraint_results = {
|
|
'max_displacement_limit': max_displacement,
|
|
'max_stress_limit': max_stress,
|
|
'min_frequency_limit': frequency
|
|
}
|
|
|
|
for constraint in constraints:
|
|
name = constraint['name']
|
|
threshold = constraint['threshold']
|
|
value = constraint_results.get(name, 0)
|
|
|
|
if constraint['type'] == 'less_than' and value > threshold:
|
|
feasible = False
|
|
logger.warning(f" Constraint violation: {name} = {value:.2f} > {threshold}")
|
|
elif constraint['type'] == 'greater_than' and value < threshold:
|
|
feasible = False
|
|
logger.warning(f" Constraint violation: {name} = {value:.2f} < {threshold}")
|
|
|
|
# Set user attributes
|
|
trial.set_user_attr('mass', mass)
|
|
trial.set_user_attr('frequency', frequency)
|
|
trial.set_user_attr('max_displacement', max_displacement)
|
|
trial.set_user_attr('max_stress', max_stress)
|
|
trial.set_user_attr('feasible', feasible)
|
|
trial.set_user_attr('neural_predicted', True)
|
|
trial.set_user_attr('inference_time_ms', inference_time)
|
|
|
|
objectives = {'mass': mass, 'frequency': frequency}
|
|
logger.trial_complete(trial.number, objectives, constraint_results, feasible)
|
|
|
|
# Return objectives for NSGA-II
|
|
return (mass, -frequency)
|
|
|
|
except Exception as e:
|
|
logger.trial_failed(trial.number, f"Neural prediction failed: {str(e)}")
|
|
return (float('inf'), float('inf'))
|
|
|
|
|
|
def objective(trial: optuna.Trial, config: dict, nx_solver: NXSolver,
|
|
model_dir: Path, model_file: Path, logger,
|
|
training_exporter: Optional[TrainingDataExporter] = None) -> tuple:
|
|
"""
|
|
Multi-objective function for UAV arm optimization.
|
|
|
|
Returns tuple: (mass, -frequency) for NSGA-II optimization
|
|
- Minimize mass
|
|
- Maximize frequency (negated for minimization)
|
|
"""
|
|
# Sample design variables
|
|
design_vars = {}
|
|
for var in config['design_variables']:
|
|
param_name = var['parameter']
|
|
bounds = var['bounds']
|
|
design_vars[param_name] = trial.suggest_float(param_name, bounds[0], bounds[1])
|
|
|
|
logger.trial_start(trial.number, design_vars)
|
|
|
|
try:
|
|
# Get file paths
|
|
sim_file = model_dir / config['simulation']['sim_file']
|
|
|
|
# Run FEA simulation via NXSolver (subprocess-based, no NXOpen import)
|
|
# Disable cleanup when exporting training data (need .dat files)
|
|
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)
|
|
cleanup=(training_exporter is None) # Keep files if exporting
|
|
)
|
|
|
|
if not result['success']:
|
|
logger.trial_failed(trial.number, f"Simulation failed: {result.get('error', 'Unknown')}")
|
|
return (float('inf'), float('inf'))
|
|
|
|
# Get OP2 file from result (solution_1 for static)
|
|
op2_file = result['op2_file']
|
|
logger.info(f"Simulation successful: {op2_file}")
|
|
|
|
# Extract mass (grams) from CAD expression p173
|
|
mass_kg = extract_mass_from_expression(model_file, expression_name="p173")
|
|
mass = mass_kg * 1000.0 # Convert to grams
|
|
logger.info(f" mass: {mass:.3f} g (from CAD expression p173)")
|
|
|
|
# Extract frequency (Hz) - from modal analysis (solution 2)
|
|
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']
|
|
logger.info(f" fundamental_frequency: {frequency:.3f} Hz")
|
|
|
|
# Extract displacement (mm) - from static analysis (subcase 1)
|
|
disp_result = extract_displacement(op2_file, subcase=1)
|
|
max_displacement = disp_result['max_displacement']
|
|
logger.info(f" max_displacement: {max_displacement:.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']
|
|
logger.info(f" max_stress: {max_stress:.3f} MPa")
|
|
|
|
# Check constraints
|
|
constraints = config.get('constraints', [])
|
|
feasible = True
|
|
constraint_results = {
|
|
'max_displacement_limit': max_displacement,
|
|
'max_stress_limit': max_stress,
|
|
'min_frequency_limit': frequency
|
|
}
|
|
|
|
for constraint in constraints:
|
|
name = constraint['name']
|
|
threshold = constraint['threshold']
|
|
value = constraint_results.get(name, 0)
|
|
|
|
if constraint['type'] == 'less_than' and value > threshold:
|
|
feasible = False
|
|
logger.warning(f" Constraint violation: {name} = {value:.2f} > {threshold}")
|
|
elif constraint['type'] == 'greater_than' and value < threshold:
|
|
feasible = False
|
|
logger.warning(f" Constraint violation: {name} = {value:.2f} < {threshold}")
|
|
|
|
# Set user attributes for constraint tracking
|
|
trial.set_user_attr('mass', mass)
|
|
trial.set_user_attr('frequency', frequency)
|
|
trial.set_user_attr('max_displacement', max_displacement)
|
|
trial.set_user_attr('max_stress', max_stress)
|
|
trial.set_user_attr('feasible', feasible)
|
|
|
|
objectives = {'mass': mass, 'frequency': frequency}
|
|
logger.trial_complete(trial.number, objectives, constraint_results, feasible)
|
|
|
|
# Export training data for AtomizerField neural network
|
|
if training_exporter is not None:
|
|
# Find .dat file (same base name as .op2)
|
|
op2_path = Path(op2_file)
|
|
dat_file = op2_path.with_suffix('.dat')
|
|
|
|
# Also export modal analysis files (solution_2)
|
|
op2_modal_path = Path(op2_modal)
|
|
dat_modal = op2_modal_path.with_suffix('.dat')
|
|
|
|
# Prepare results for metadata
|
|
export_results = {
|
|
'objectives': {'mass': mass, 'frequency': frequency},
|
|
'constraints': constraint_results,
|
|
'max_stress': max_stress,
|
|
'max_displacement': max_displacement,
|
|
'feasible': feasible
|
|
}
|
|
|
|
# Export static analysis (solution_1)
|
|
simulation_files = {
|
|
'dat_file': dat_file,
|
|
'op2_file': op2_path
|
|
}
|
|
|
|
export_success = training_exporter.export_trial(
|
|
trial_number=trial.number,
|
|
design_variables=design_vars,
|
|
results=export_results,
|
|
simulation_files=simulation_files
|
|
)
|
|
|
|
if export_success:
|
|
logger.info(f" Training data exported for trial {trial.number}")
|
|
else:
|
|
logger.warning(f" Failed to export training data for trial {trial.number}")
|
|
|
|
# Return objectives for NSGA-II (minimize mass, maximize frequency)
|
|
# Using directions=['minimize', 'minimize'] with -frequency
|
|
return (mass, -frequency)
|
|
|
|
except Exception as e:
|
|
logger.trial_failed(trial.number, str(e))
|
|
return (float('inf'), float('inf'))
|
|
|
|
|
|
def main():
|
|
"""Main optimization workflow with neural surrogate integration."""
|
|
|
|
parser = argparse.ArgumentParser(description='Run UAV arm optimization with AtomizerField neural surrogate')
|
|
parser.add_argument('--trials', type=int, default=30,
|
|
help='Number of optimization trials (default: 30 for initial FEA phase)')
|
|
parser.add_argument('--resume', action='store_true',
|
|
help='Resume from existing study')
|
|
parser.add_argument('--enable-nn', action='store_true',
|
|
help='Enable neural surrogate (requires trained model)')
|
|
parser.add_argument('--no-export', action='store_true',
|
|
help='Disable training data export')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Setup paths
|
|
study_dir = Path(__file__).parent
|
|
config_path = study_dir / "1_setup" / "optimization_config.json"
|
|
workflow_config_path = study_dir / "1_setup" / "workflow_config.json"
|
|
model_dir = study_dir / "1_setup" / "model"
|
|
model_file = model_dir / "Beam.prt" # NX part file for mass extraction
|
|
results_dir = study_dir / "2_results"
|
|
results_dir.mkdir(exist_ok=True)
|
|
|
|
# Initialize logger
|
|
logger = get_logger("uav_arm_atomizerfield_test", study_dir=results_dir)
|
|
|
|
# Load configs
|
|
config = load_config(config_path)
|
|
workflow_config = load_config(workflow_config_path) if workflow_config_path.exists() else {}
|
|
|
|
# Check neural surrogate status
|
|
neural_enabled = args.enable_nn or workflow_config.get('neural_surrogate', {}).get('enabled', False)
|
|
surrogate = None
|
|
|
|
if neural_enabled:
|
|
logger.info("Neural surrogate mode requested")
|
|
|
|
# Try to initialize neural surrogate
|
|
try:
|
|
# Use project_root for auto-detection of model and training data
|
|
surrogate = create_surrogate_for_study(project_root=project_root)
|
|
if surrogate is not None:
|
|
logger.info(f"Neural surrogate loaded successfully!")
|
|
logger.info(f" Model: {surrogate.model_path}")
|
|
logger.info(f" Device: {surrogate.device}")
|
|
logger.info(f" Expected speedup: 600x+ over FEA")
|
|
else:
|
|
logger.warning("Neural surrogate not available - falling back to FEA")
|
|
neural_enabled = False
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize neural surrogate: {e}")
|
|
logger.warning("Falling back to FEA mode")
|
|
neural_enabled = False
|
|
|
|
# Initialize training data exporter for AtomizerField
|
|
training_exporter = None
|
|
export_config = workflow_config.get('training_data_export', {})
|
|
|
|
if export_config.get('enabled', False) and not args.no_export:
|
|
export_dir = export_config.get('export_dir', 'atomizer_field_training_data/uav_arm_test')
|
|
# Make export_dir absolute if relative
|
|
if not Path(export_dir).is_absolute():
|
|
export_dir = project_root / export_dir
|
|
|
|
# Get design variable names
|
|
design_var_names = [dv['parameter'] for dv in config.get('design_variables', [])]
|
|
|
|
# Get objective names
|
|
objective_names = [obj['name'] for obj in config.get('objectives', [])]
|
|
|
|
# Get constraint names
|
|
constraint_names = [c['name'] for c in config.get('constraints', [])]
|
|
|
|
training_exporter = TrainingDataExporter(
|
|
export_dir=export_dir,
|
|
study_name="uav_arm_atomizerfield_test",
|
|
design_variable_names=design_var_names,
|
|
objective_names=objective_names,
|
|
constraint_names=constraint_names,
|
|
metadata={
|
|
'atomizer_version': workflow_config.get('version', '2.0'),
|
|
'optimization_algorithm': 'NSGA-II',
|
|
'n_trials': args.trials,
|
|
'description': config.get('description', 'UAV arm optimization')
|
|
}
|
|
)
|
|
logger.info(f"Training data export enabled: {export_dir}")
|
|
else:
|
|
logger.info("Training data export disabled")
|
|
|
|
# Initialize NX Solver (subprocess-based, works with any Python version)
|
|
nx_solver = NXSolver()
|
|
|
|
# Create Optuna study (multi-objective)
|
|
storage = f"sqlite:///{results_dir / 'study.db'}"
|
|
|
|
sampler = NSGAIISampler(
|
|
population_size=20,
|
|
mutation_prob=0.1,
|
|
crossover_prob=0.9,
|
|
seed=42
|
|
)
|
|
|
|
logger.study_start("uav_arm_atomizerfield_test", args.trials, "NSGAIISampler")
|
|
|
|
if args.resume:
|
|
study = optuna.load_study(
|
|
study_name="uav_arm_atomizerfield_test",
|
|
storage=storage,
|
|
sampler=sampler
|
|
)
|
|
logger.info(f"Resumed study with {len(study.trials)} existing trials")
|
|
else:
|
|
study = optuna.create_study(
|
|
study_name="uav_arm_atomizerfield_test",
|
|
storage=storage,
|
|
sampler=sampler,
|
|
directions=['minimize', 'minimize'], # mass, -frequency
|
|
load_if_exists=True
|
|
)
|
|
|
|
# Run optimization
|
|
logger.info(f"\n{'='*60}")
|
|
if neural_enabled and surrogate is not None:
|
|
logger.info("Starting UAV Arm Optimization (NEURAL ACCELERATED MODE)")
|
|
logger.info("Using trained neural network for FAST predictions!")
|
|
else:
|
|
logger.info("Starting UAV Arm Optimization (Phase 1: FEA Data Collection)")
|
|
logger.info(f"Trials: {args.trials}")
|
|
logger.info(f"Neural Surrogate: {'ENABLED - 600x+ speedup!' if neural_enabled else 'Disabled (collecting training data)'}")
|
|
logger.info(f"{'='*60}\n")
|
|
|
|
start_time = datetime.now()
|
|
|
|
try:
|
|
# Choose objective function based on mode
|
|
if neural_enabled and surrogate is not None:
|
|
# Use neural surrogate for FAST optimization
|
|
study.optimize(
|
|
lambda trial: neural_objective(trial, config, surrogate, model_file, logger),
|
|
n_trials=args.trials,
|
|
show_progress_bar=True
|
|
)
|
|
else:
|
|
# Use FEA for data collection
|
|
study.optimize(
|
|
lambda trial: objective(trial, config, nx_solver, model_dir, model_file, logger, training_exporter),
|
|
n_trials=args.trials,
|
|
show_progress_bar=True
|
|
)
|
|
|
|
elapsed = datetime.now() - start_time
|
|
n_successful = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
|
|
|
|
logger.study_complete("uav_arm_atomizerfield_test", len(study.trials), n_successful)
|
|
|
|
# Report results
|
|
logger.info(f"\n{'='*60}")
|
|
logger.info(f"Optimization Complete")
|
|
logger.info(f"{'='*60}")
|
|
logger.info(f"Duration: {elapsed}")
|
|
logger.info(f"Total trials: {len(study.trials)}")
|
|
logger.info(f"Successful: {n_successful}")
|
|
|
|
# Calculate and report speedup for neural mode
|
|
if neural_enabled and surrogate is not None:
|
|
avg_time_per_trial_ms = (elapsed.total_seconds() * 1000) / max(n_successful, 1)
|
|
estimated_fea_time = n_successful * 30 # ~30 seconds per FEA
|
|
actual_time = elapsed.total_seconds()
|
|
speedup = estimated_fea_time / max(actual_time, 0.001)
|
|
logger.info(f"\n [NEURAL PERFORMANCE]")
|
|
logger.info(f" Avg time per trial: {avg_time_per_trial_ms:.1f} ms")
|
|
logger.info(f" Estimated FEA time: {estimated_fea_time:.0f} seconds ({estimated_fea_time/60:.1f} min)")
|
|
logger.info(f" Actual neural time: {actual_time:.1f} seconds")
|
|
logger.info(f" SPEEDUP: {speedup:.0f}x faster!")
|
|
|
|
# Show Pareto front
|
|
pareto_trials = study.best_trials
|
|
logger.info(f"\nPareto Front ({len(pareto_trials)} solutions):")
|
|
for i, trial in enumerate(pareto_trials[:5]): # Show top 5
|
|
mass = trial.values[0]
|
|
frequency = -trial.values[1] # Convert back to positive
|
|
feasible = trial.user_attrs.get('feasible', 'N/A')
|
|
logger.info(f" {i+1}. Mass: {mass:.2f}g, Freq: {frequency:.1f}Hz, Feasible: {feasible}")
|
|
|
|
# Finalize training data export
|
|
if training_exporter is not None:
|
|
training_exporter.finalize()
|
|
logger.info(f"Training data finalized: {training_exporter.trial_count} trials exported")
|
|
|
|
# Next steps for neural training
|
|
if not neural_enabled and training_exporter is not None:
|
|
logger.info(f"\n{'='*60}")
|
|
logger.info("Next Steps for Neural Acceleration")
|
|
logger.info(f"{'='*60}")
|
|
logger.info(f"1. Training data collected: {training_exporter.export_dir}")
|
|
logger.info(f" Exported {training_exporter.trial_count} trials")
|
|
logger.info("2. Parse training data for neural network:")
|
|
logger.info(" cd atomizer-field")
|
|
logger.info(f" python batch_parser.py {training_exporter.export_dir}")
|
|
logger.info("3. Train neural network:")
|
|
logger.info(" python train.py --epochs 200")
|
|
logger.info("4. Re-run with neural surrogate:")
|
|
logger.info(" python run_optimization.py --trials 170 --enable-nn --resume")
|
|
|
|
except Exception as e:
|
|
# Finalize export even on error
|
|
if training_exporter is not None:
|
|
training_exporter.finalize()
|
|
logger.error(f"Optimization failed: {e}", exc_info=True)
|
|
raise
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit(main())
|