Complete implementation of Protocol 13 featuring real-time web dashboard for monitoring multi-objective optimization studies. ## New Features ### Backend (Python) - Real-time tracking system with per-trial JSON writes - New API endpoints for metadata, optimizer state, and Pareto fronts - Unit inference from objective descriptions - Multi-objective support using Optuna's best_trials API ### Frontend (React + TypeScript) - OptimizerPanel: Real-time optimizer state (phase, strategy, progress) - ParetoPlot: Pareto front visualization with normalization toggle - 3 modes: Raw, Min-Max [0-1], Z-Score standardization - Pareto front line connecting optimal points - ParallelCoordinatesPlot: High-dimensional interactive visualization - Objectives + design variables on parallel axes - Click-to-select, hover-to-highlight - Color-coded feasibility - Dynamic units throughout all visualizations ### Documentation - Comprehensive Protocol 13 guide with architecture, data flow, usage ## Files Added - `docs/PROTOCOL_13_DASHBOARD.md` - `atomizer-dashboard/frontend/src/components/OptimizerPanel.tsx` - `atomizer-dashboard/frontend/src/components/ParetoPlot.tsx` - `atomizer-dashboard/frontend/src/components/ParallelCoordinatesPlot.tsx` - `optimization_engine/realtime_tracking.py` ## Files Modified - `atomizer-dashboard/frontend/src/pages/Dashboard.tsx` - `atomizer-dashboard/backend/api/routes/optimization.py` - `optimization_engine/intelligent_optimizer.py` ## Testing - Tested with bracket_stiffness_optimization_V2 (30 trials, 20 Pareto solutions) - Dashboard running on localhost:3001 - All P1 and P2 features verified working 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
491 lines
18 KiB
Python
491 lines
18 KiB
Python
"""
|
|
Optimization API endpoints
|
|
Handles study status, history retrieval, and control operations
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional
|
|
import json
|
|
import sys
|
|
import sqlite3
|
|
|
|
# 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
|
|
|
|
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": params,
|
|
"results": {"first_frequency": values[0]} if len(values) > 0 else {},
|
|
"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)}")
|