""" Optimization API endpoints Handles study status, history retrieval, and control operations """ from fastapi import APIRouter, HTTPException, UploadFile, File, Form from fastapi.responses import JSONResponse, FileResponse from pathlib import Path from typing import List, Dict, Optional import json import sys import sqlite3 import shutil from datetime import datetime # Add project root to path sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) router = APIRouter() # Base studies directory STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies" @router.get("/studies") async def list_studies(): """List all available optimization studies""" try: studies = [] if not STUDIES_DIR.exists(): return {"studies": []} for study_dir in STUDIES_DIR.iterdir(): if not study_dir.is_dir(): continue # Look for optimization config (check multiple locations) config_file = study_dir / "optimization_config.json" if not config_file.exists(): config_file = study_dir / "1_setup" / "optimization_config.json" if not config_file.exists(): continue # Load config with open(config_file) as f: config = json.load(f) # Check if results directory exists results_dir = study_dir / "2_results" # Check for Optuna database (Protocol 10) or JSON history (other protocols) study_db = results_dir / "study.db" history_file = results_dir / "optimization_history_incremental.json" status = "not_started" trial_count = 0 best_value = None # Protocol 10: Read from Optuna SQLite database if study_db.exists(): try: conn = sqlite3.connect(str(study_db)) cursor = conn.cursor() # Get trial count and status cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'") trial_count = cursor.fetchone()[0] # Get best trial (for single-objective, or first objective for multi-objective) if trial_count > 0: cursor.execute(""" SELECT value FROM trial_values WHERE trial_id IN ( SELECT trial_id FROM trials WHERE state = 'COMPLETE' ) ORDER BY value ASC LIMIT 1 """) result = cursor.fetchone() if result: best_value = result[0] conn.close() # Determine status total_trials = config.get('optimization_settings', {}).get('n_trials', 50) if trial_count >= total_trials: status = "completed" else: status = "running" # Simplified - would need process check except Exception as e: print(f"Warning: Failed to read Optuna database for {study_dir.name}: {e}") status = "error" # Legacy: Read from JSON history elif history_file.exists(): with open(history_file) as f: history = json.load(f) trial_count = len(history) if history: # Find best trial best_trial = min(history, key=lambda x: x['objective']) best_value = best_trial['objective'] # Determine status total_trials = config.get('trials', {}).get('n_trials', 50) if trial_count >= total_trials: status = "completed" else: status = "running" # Simplified - would need process check # Get total trials from config (supports both formats) total_trials = ( config.get('optimization_settings', {}).get('n_trials') or config.get('trials', {}).get('n_trials', 50) ) studies.append({ "id": study_dir.name, "name": study_dir.name.replace("_", " ").title(), "status": status, "progress": { "current": trial_count, "total": total_trials }, "best_value": best_value, "target": config.get('target', {}).get('value'), "path": str(study_dir) }) return {"studies": studies} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to list studies: {str(e)}") @router.get("/studies/{study_id}/status") async def get_study_status(study_id: str): """Get detailed status of a specific study""" try: study_dir = STUDIES_DIR / study_id if not study_dir.exists(): raise HTTPException(status_code=404, detail=f"Study {study_id} not found") # Load config (check multiple locations) config_file = study_dir / "optimization_config.json" if not config_file.exists(): config_file = study_dir / "1_setup" / "optimization_config.json" with open(config_file) as f: config = json.load(f) # Check for results results_dir = study_dir / "2_results" study_db = results_dir / "study.db" history_file = results_dir / "optimization_history_incremental.json" # Protocol 10: Read from Optuna database if study_db.exists(): conn = sqlite3.connect(str(study_db)) cursor = conn.cursor() # Get trial counts by state cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'") trial_count = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'PRUNED'") pruned_count = cursor.fetchone()[0] # Get best trial (first objective for multi-objective) best_trial = None if trial_count > 0: cursor.execute(""" SELECT t.trial_id, t.number, tv.value FROM trials t JOIN trial_values tv ON t.trial_id = tv.trial_id WHERE t.state = 'COMPLETE' ORDER BY tv.value ASC LIMIT 1 """) result = cursor.fetchone() if result: trial_id, trial_number, best_value = result # Get parameters for this trial cursor.execute(""" SELECT param_name, param_value FROM trial_params WHERE trial_id = ? """, (trial_id,)) params = {row[0]: row[1] for row in cursor.fetchall()} best_trial = { "trial_number": trial_number, "objective": best_value, "design_variables": params, "results": {"first_frequency": best_value} } conn.close() total_trials = config.get('optimization_settings', {}).get('n_trials', 50) status = "completed" if trial_count >= total_trials else "running" return { "study_id": study_id, "status": status, "progress": { "current": trial_count, "total": total_trials, "percentage": (trial_count / total_trials * 100) if total_trials > 0 else 0 }, "best_trial": best_trial, "pruned_trials": pruned_count, "config": config } # Legacy: Read from JSON history if not history_file.exists(): return { "study_id": study_id, "status": "not_started", "progress": {"current": 0, "total": config.get('trials', {}).get('n_trials', 50)}, "config": config } with open(history_file) as f: history = json.load(f) trial_count = len(history) total_trials = config.get('trials', {}).get('n_trials', 50) # Find best trial best_trial = None if history: best_trial = min(history, key=lambda x: x['objective']) # Check for pruning data pruning_file = results_dir / "pruning_history.json" pruned_count = 0 if pruning_file.exists(): with open(pruning_file) as f: pruning_history = json.load(f) pruned_count = len(pruning_history) status = "completed" if trial_count >= total_trials else "running" return { "study_id": study_id, "status": status, "progress": { "current": trial_count, "total": total_trials, "percentage": (trial_count / total_trials * 100) if total_trials > 0 else 0 }, "best_trial": best_trial, "pruned_trials": pruned_count, "config": config } except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get study status: {str(e)}") @router.get("/studies/{study_id}/history") async def get_optimization_history(study_id: str, limit: Optional[int] = None): """Get optimization history (all trials)""" try: study_dir = STUDIES_DIR / study_id results_dir = study_dir / "2_results" study_db = results_dir / "study.db" history_file = results_dir / "optimization_history_incremental.json" # Protocol 10: Read from Optuna database if study_db.exists(): conn = sqlite3.connect(str(study_db)) cursor = conn.cursor() # Get all completed trials cursor.execute(""" SELECT trial_id, number, datetime_start, datetime_complete FROM trials WHERE state = 'COMPLETE' ORDER BY number DESC """ + (f" LIMIT {limit}" if limit else "")) trial_rows = cursor.fetchall() trials = [] for trial_id, trial_num, start_time, end_time in trial_rows: # Get objectives for this trial cursor.execute(""" SELECT value FROM trial_values WHERE trial_id = ? ORDER BY objective """, (trial_id,)) values = [row[0] for row in cursor.fetchall()] # Get parameters for this trial cursor.execute(""" SELECT param_name, param_value FROM trial_params WHERE trial_id = ? """, (trial_id,)) params = {} for param_name, param_value in cursor.fetchall(): try: params[param_name] = float(param_value) if param_value is not None else None except (ValueError, TypeError): params[param_name] = param_value # Get user attributes (extracted results: mass, frequency, stress, displacement, etc.) cursor.execute(""" SELECT key, value_json FROM trial_user_attributes WHERE trial_id = ? """, (trial_id,)) user_attrs = {} for key, value_json in cursor.fetchall(): try: user_attrs[key] = json.loads(value_json) except (ValueError, TypeError): user_attrs[key] = value_json # Extract relevant metrics for results (mass, frequency, stress, displacement, etc.) results = {} if "mass" in user_attrs: results["mass"] = user_attrs["mass"] if "frequency" in user_attrs: results["frequency"] = user_attrs["frequency"] if "max_stress" in user_attrs: results["max_stress"] = user_attrs["max_stress"] if "max_displacement" in user_attrs: results["max_displacement"] = user_attrs["max_displacement"] # Fallback to first frequency from objectives if available if not results and len(values) > 0: results["first_frequency"] = values[0] # CRITICAL: Extract design_vars from user_attrs if stored there # The optimization code does: trial.set_user_attr("design_vars", design_vars) design_vars_from_attrs = user_attrs.get("design_vars", {}) # Merge with params (prefer user_attrs design_vars if available) final_design_vars = {**params, **design_vars_from_attrs} if design_vars_from_attrs else params trials.append({ "trial_number": trial_num, "objective": values[0] if len(values) > 0 else None, # Primary objective "objectives": values if len(values) > 1 else None, # All objectives for multi-objective "design_variables": final_design_vars, # Use merged design vars "results": results, "user_attrs": user_attrs, # Include all user attributes "start_time": start_time, "end_time": end_time }) conn.close() return {"trials": trials} # Legacy: Read from JSON history if not history_file.exists(): return {"trials": []} with open(history_file) as f: history = json.load(f) # Apply limit if specified if limit: history = history[-limit:] return {"trials": history} except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get history: {str(e)}") @router.get("/studies/{study_id}/pruning") async def get_pruning_history(study_id: str): """Get pruning diagnostics""" try: study_dir = STUDIES_DIR / study_id pruning_file = study_dir / "2_results" / "pruning_history.json" if not pruning_file.exists(): return {"pruned_trials": []} with open(pruning_file) as f: pruning_history = json.load(f) return {"pruned_trials": pruning_history} except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get pruning history: {str(e)}") def _infer_objective_unit(objective: Dict) -> str: """Infer unit from objective name and description""" name = objective.get("name", "").lower() desc = objective.get("description", "").lower() # Common unit patterns if "frequency" in name or "hz" in desc: return "Hz" elif "stiffness" in name or "n/mm" in desc: return "N/mm" elif "mass" in name or "kg" in desc: return "kg" elif "stress" in name or "mpa" in desc or "pa" in desc: return "MPa" elif "displacement" in name or "mm" in desc: return "mm" elif "force" in name or "newton" in desc: return "N" elif "%" in desc or "percent" in desc: return "%" # Check if unit is explicitly mentioned in description (e.g., "(N/mm)") import re unit_match = re.search(r'\(([^)]+)\)', desc) if unit_match: return unit_match.group(1) return "" # No unit found @router.get("/studies/{study_id}/metadata") async def get_study_metadata(study_id: str): """Read optimization_config.json for objectives, design vars, units (Protocol 13)""" try: study_dir = STUDIES_DIR / study_id if not study_dir.exists(): raise HTTPException(status_code=404, detail=f"Study {study_id} not found") # Load config (check multiple locations) config_file = study_dir / "optimization_config.json" if not config_file.exists(): config_file = study_dir / "1_setup" / "optimization_config.json" if not config_file.exists(): raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}") with open(config_file) as f: config = json.load(f) # Enhance objectives with inferred units if not present objectives = config.get("objectives", []) for obj in objectives: if "unit" not in obj or not obj["unit"]: obj["unit"] = _infer_objective_unit(obj) return { "objectives": objectives, "design_variables": config.get("design_variables", []), "constraints": config.get("constraints", []), "study_name": config.get("study_name", study_id), "description": config.get("description", "") } except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get study metadata: {str(e)}") @router.get("/studies/{study_id}/optimizer-state") async def get_optimizer_state(study_id: str): """Read realtime optimizer state from intelligent_optimizer/ (Protocol 13)""" try: study_dir = STUDIES_DIR / study_id results_dir = study_dir / "2_results" state_file = results_dir / "intelligent_optimizer" / "optimizer_state.json" if not state_file.exists(): return {"available": False} with open(state_file) as f: state = json.load(f) return {"available": True, **state} except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get optimizer state: {str(e)}") @router.get("/studies/{study_id}/pareto-front") async def get_pareto_front(study_id: str): """Get Pareto-optimal solutions for multi-objective studies (Protocol 13)""" try: study_dir = STUDIES_DIR / study_id results_dir = study_dir / "2_results" study_db = results_dir / "study.db" if not study_db.exists(): return {"is_multi_objective": False, "pareto_front": []} # Import optuna here to avoid loading it for all endpoints import optuna storage = optuna.storages.RDBStorage(f"sqlite:///{study_db}") study = optuna.load_study(study_name=study_id, storage=storage) # Check if multi-objective if len(study.directions) == 1: return {"is_multi_objective": False, "pareto_front": []} # Get Pareto front pareto_trials = study.best_trials return { "is_multi_objective": True, "pareto_front": [ { "trial_number": t.number, "values": t.values, "params": t.params, "user_attrs": dict(t.user_attrs), "constraint_satisfied": t.user_attrs.get("constraint_satisfied", True) } for t in pareto_trials ] } except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get Pareto front: {str(e)}") @router.post("/studies") async def create_study( config: str = Form(...), prt_file: Optional[UploadFile] = File(None), sim_file: Optional[UploadFile] = File(None), fem_file: Optional[UploadFile] = File(None) ): """ Create a new optimization study Accepts: - config: JSON string with study configuration - prt_file: NX part file (optional if using existing study) - sim_file: NX simulation file (optional) - fem_file: NX FEM file (optional) """ try: # Parse config config_data = json.loads(config) study_name = config_data.get("name") # Changed from study_name to name to match frontend if not study_name: raise HTTPException(status_code=400, detail="name is required in config") # Create study directory structure study_dir = STUDIES_DIR / study_name if study_dir.exists(): raise HTTPException(status_code=400, detail=f"Study {study_name} already exists") setup_dir = study_dir / "1_setup" model_dir = setup_dir / "model" results_dir = study_dir / "2_results" setup_dir.mkdir(parents=True, exist_ok=True) model_dir.mkdir(parents=True, exist_ok=True) results_dir.mkdir(parents=True, exist_ok=True) # Save config file config_file = setup_dir / "optimization_config.json" with open(config_file, 'w') as f: json.dump(config_data, f, indent=2) # Save uploaded files files_saved = {} if prt_file: prt_path = model_dir / prt_file.filename with open(prt_path, 'wb') as f: content = await prt_file.read() f.write(content) files_saved['prt_file'] = str(prt_path) if sim_file: sim_path = model_dir / sim_file.filename with open(sim_path, 'wb') as f: content = await sim_file.read() f.write(content) files_saved['sim_file'] = str(sim_path) if fem_file: fem_path = model_dir / fem_file.filename with open(fem_path, 'wb') as f: content = await fem_file.read() f.write(content) files_saved['fem_file'] = str(fem_path) return JSONResponse( status_code=201, content={ "status": "created", "study_id": study_name, "study_path": str(study_dir), "config_path": str(config_file), "files_saved": files_saved, "message": f"Study {study_name} created successfully. Ready to run optimization." } ) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid JSON in config: {str(e)}") except Exception as e: # Clean up on error if 'study_dir' in locals() and study_dir.exists(): shutil.rmtree(study_dir) raise HTTPException(status_code=500, detail=f"Failed to create study: {str(e)}") @router.post("/studies/{study_id}/convert-mesh") async def convert_study_mesh(study_id: str): """ Convert study mesh to GLTF for 3D visualization Creates a web-viewable 3D model with FEA results as vertex colors """ try: study_dir = STUDIES_DIR / study_id if not study_dir.exists(): raise HTTPException(status_code=404, detail=f"Study {study_id} not found") # Import mesh converter sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) from optimization_engine.mesh_converter import convert_study_mesh # Convert mesh output_path = convert_study_mesh(study_dir) if output_path and output_path.exists(): return { "status": "success", "gltf_path": str(output_path), "gltf_url": f"/api/optimization/studies/{study_id}/mesh/model.gltf", "metadata_url": f"/api/optimization/studies/{study_id}/mesh/model.json", "message": "Mesh converted successfully" } else: raise HTTPException(status_code=500, detail="Mesh conversion failed") except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to convert mesh: {str(e)}") @router.get("/studies/{study_id}/mesh/{filename}") async def get_mesh_file(study_id: str, filename: str): """ Serve GLTF mesh files and metadata Supports .gltf, .bin, and .json files """ try: # Validate filename to prevent directory traversal if '..' in filename or '/' in filename or '\\' in filename: raise HTTPException(status_code=400, detail="Invalid filename") study_dir = STUDIES_DIR / study_id visualization_dir = study_dir / "3_visualization" file_path = visualization_dir / filename if not file_path.exists(): raise HTTPException(status_code=404, detail=f"File {filename} not found") # Determine content type suffix = file_path.suffix.lower() content_types = { '.gltf': 'model/gltf+json', '.bin': 'application/octet-stream', '.json': 'application/json', '.glb': 'model/gltf-binary' } content_type = content_types.get(suffix, 'application/octet-stream') return FileResponse( path=str(file_path), media_type=content_type, filename=filename ) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"File not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to serve mesh file: {str(e)}") @router.get("/studies/{study_id}/optuna-url") async def get_optuna_dashboard_url(study_id: str): """ Get the Optuna dashboard URL for a specific study. Returns the URL to access the study in Optuna dashboard. The Optuna dashboard should be started with a relative path from the Atomizer root: sqlite:///studies/{study_id}/2_results/study.db """ try: study_dir = STUDIES_DIR / study_id if not study_dir.exists(): raise HTTPException(status_code=404, detail=f"Study {study_id} not found") results_dir = study_dir / "2_results" study_db = results_dir / "study.db" if not study_db.exists(): raise HTTPException(status_code=404, detail=f"No Optuna database found for study {study_id}") # Get the study name from the database (may differ from folder name) import optuna storage = optuna.storages.RDBStorage(f"sqlite:///{study_db}") studies = storage.get_all_studies() if not studies: raise HTTPException(status_code=404, detail=f"No Optuna study found in database for {study_id}") # Use the actual study name from the database optuna_study_name = studies[0].study_name # Return URL info for the frontend # The dashboard should be running on port 8081 with the correct database return { "study_id": study_id, "optuna_study_name": optuna_study_name, "database_path": f"studies/{study_id}/2_results/study.db", "dashboard_url": f"http://localhost:8081/dashboard/studies/{studies[0]._study_id}", "dashboard_base": "http://localhost:8081", "note": "Optuna dashboard must be started with: sqlite:///studies/{study_id}/2_results/study.db" } except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get Optuna URL: {str(e)}") @router.post("/studies/{study_id}/generate-report") async def generate_report( study_id: str, format: str = "markdown", include_llm_summary: bool = False ): """ Generate an optimization report in the specified format Args: study_id: Study identifier format: Report format ('markdown', 'html', or 'pdf') include_llm_summary: Whether to include LLM-generated executive summary Returns: Information about the generated report including download URL """ try: study_dir = STUDIES_DIR / study_id if not study_dir.exists(): raise HTTPException(status_code=404, detail=f"Study {study_id} not found") # Validate format valid_formats = ['markdown', 'md', 'html', 'pdf'] if format.lower() not in valid_formats: raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(valid_formats)}") # Import report generator sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) from optimization_engine.report_generator import generate_study_report # Generate report output_path = generate_study_report( study_dir=study_dir, output_format=format.lower(), include_llm_summary=include_llm_summary ) if output_path and output_path.exists(): # Get relative path for URL rel_path = output_path.relative_to(study_dir) return { "status": "success", "format": format, "file_path": str(output_path), "download_url": f"/api/optimization/studies/{study_id}/reports/{output_path.name}", "file_size": output_path.stat().st_size, "message": f"Report generated successfully in {format} format" } else: raise HTTPException(status_code=500, detail="Report generation failed") except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Study {study_id} not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to generate report: {str(e)}") @router.get("/studies/{study_id}/reports/{filename}") async def download_report(study_id: str, filename: str): """ Download a generated report file Args: study_id: Study identifier filename: Report filename Returns: Report file for download """ try: # Validate filename to prevent directory traversal if '..' in filename or '/' in filename or '\\' in filename: raise HTTPException(status_code=400, detail="Invalid filename") study_dir = STUDIES_DIR / study_id results_dir = study_dir / "2_results" file_path = results_dir / filename if not file_path.exists(): raise HTTPException(status_code=404, detail=f"Report file {filename} not found") # Determine content type suffix = file_path.suffix.lower() content_types = { '.md': 'text/markdown', '.html': 'text/html', '.pdf': 'application/pdf', '.json': 'application/json' } content_type = content_types.get(suffix, 'application/octet-stream') return FileResponse( path=str(file_path), media_type=content_type, filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"} ) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Report file not found") except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to download report: {str(e)}")