feat: Add Analysis page, run comparison, notifications, and config editor

Dashboard enhancements:
- Add Analysis page with tabs: Overview, Parameters, Pareto, Correlations, Constraints, Surrogate, Runs
- Add PlotlyCorrelationHeatmap for parameter-objective correlation analysis
- Add PlotlyFeasibilityChart for constraint satisfaction visualization
- Add PlotlySurrogateQuality for FEA vs NN prediction comparison
- Add PlotlyRunComparison for comparing optimization runs within a study

Real-time improvements:
- Replace watchdog file-watching with SQLite database polling for better Windows reliability
- Add DatabasePoller class with 2-second polling interval
- Enhanced WebSocket messages: trial_completed, new_best, pareto_update, progress

Desktop notifications:
- Add useNotifications hook using Web Notifications API
- Add NotificationSettings toggle component
- Notify users when new best solutions are found

Config editor:
- Add PUT /studies/{study_id}/config endpoint with auto-backup
- Add ConfigEditor modal with tabs: General, Variables, Objectives, Settings, JSON
- Prevents editing while optimization is running

Enhanced Pareto visualization:
- Add dark mode styling with transparent backgrounds
- Add stats bar showing Pareto, FEA, NN, and infeasible counts
- Add Pareto front connecting line for 2D view
- Add table showing top 10 Pareto-optimal solutions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-05 19:57:20 -05:00
parent 5c660ff270
commit 5fb94fdf01
27 changed files with 5878 additions and 722 deletions

View File

@@ -1794,3 +1794,563 @@ run_server("{storage_url}", host="0.0.0.0", port={port})
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to launch Optuna dashboard: {str(e)}")
# ============================================================================
# Model Files Endpoint
# ============================================================================
@router.get("/studies/{study_id}/model-files")
async def get_model_files(study_id: str):
"""
Get list of NX model files (.prt, .sim, .fem, .bdf, .dat, .op2) for a study
Args:
study_id: Study identifier
Returns:
JSON with list of model files and their paths
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Look for model directory (check multiple locations)
model_dirs = [
study_dir / "1_setup" / "model",
study_dir / "model",
study_dir / "1_setup",
study_dir
]
model_files = []
model_dir_path = None
# NX and FEA file extensions to look for
nx_extensions = {'.prt', '.sim', '.fem', '.bdf', '.dat', '.op2', '.f06', '.inp'}
for model_dir in model_dirs:
if model_dir.exists() and model_dir.is_dir():
for file_path in model_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in nx_extensions:
model_files.append({
"name": file_path.name,
"path": str(file_path),
"extension": file_path.suffix.lower(),
"size_bytes": file_path.stat().st_size,
"size_display": _format_file_size(file_path.stat().st_size),
"modified": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
})
if model_dir_path is None:
model_dir_path = str(model_dir)
# Sort by extension for better display (prt first, then sim, fem, etc.)
extension_order = {'.prt': 0, '.sim': 1, '.fem': 2, '.bdf': 3, '.dat': 4, '.op2': 5, '.f06': 6, '.inp': 7}
model_files.sort(key=lambda x: (extension_order.get(x['extension'], 99), x['name']))
return {
"study_id": study_id,
"model_dir": model_dir_path or str(study_dir / "1_setup" / "model"),
"files": model_files,
"count": len(model_files)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get model files: {str(e)}")
def _format_file_size(size_bytes: int) -> str:
"""Format file size in human-readable form"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
@router.post("/studies/{study_id}/open-folder")
async def open_model_folder(study_id: str, folder_type: str = "model"):
"""
Open the model folder in system file explorer
Args:
study_id: Study identifier
folder_type: Type of folder to open (model, results, setup)
Returns:
JSON with success status
"""
import os
import platform
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Determine which folder to open
if folder_type == "model":
target_dir = study_dir / "1_setup" / "model"
if not target_dir.exists():
target_dir = study_dir / "1_setup"
elif folder_type == "results":
target_dir = get_results_dir(study_dir)
elif folder_type == "setup":
target_dir = study_dir / "1_setup"
else:
target_dir = study_dir
if not target_dir.exists():
target_dir = study_dir
# Open in file explorer based on platform
system = platform.system()
try:
if system == "Windows":
os.startfile(str(target_dir))
elif system == "Darwin": # macOS
subprocess.Popen(["open", str(target_dir)])
else: # Linux
subprocess.Popen(["xdg-open", str(target_dir)])
return {
"success": True,
"message": f"Opened {target_dir}",
"path": str(target_dir)
}
except Exception as e:
return {
"success": False,
"message": f"Failed to open folder: {str(e)}",
"path": str(target_dir)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {str(e)}")
@router.get("/studies/{study_id}/best-solution")
async def get_best_solution(study_id: str):
"""Get the best trial(s) for a study with improvement metrics"""
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 = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
return {
"study_id": study_id,
"best_trial": None,
"first_trial": None,
"improvements": {},
"total_trials": 0
}
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get best trial (single objective - minimize by default)
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective,
datetime(tv.value_id, 'unixepoch') as timestamp
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
""")
best_row = cursor.fetchone()
# Get first completed trial for comparison
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE'
ORDER BY t.number ASC
LIMIT 1
""")
first_row = cursor.fetchone()
# Get total trial count
cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
total_trials = cursor.fetchone()[0]
best_trial = None
first_trial = None
improvements = {}
if best_row:
best_trial_id = best_row['trial_id']
# Get design variables
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (best_trial_id,))
params = {row['param_name']: row['param_value'] for row in cursor.fetchall()}
# Get user attributes (including results)
cursor.execute("""
SELECT key, value_json
FROM trial_user_attributes
WHERE trial_id = ?
""", (best_trial_id,))
user_attrs = {}
for row in cursor.fetchall():
try:
user_attrs[row['key']] = json.loads(row['value_json'])
except:
user_attrs[row['key']] = row['value_json']
best_trial = {
"trial_number": best_row['number'],
"objective": best_row['objective'],
"design_variables": params,
"user_attrs": user_attrs,
"timestamp": best_row['timestamp']
}
if first_row:
first_trial_id = first_row['trial_id']
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (first_trial_id,))
first_params = {row['param_name']: row['param_value'] for row in cursor.fetchall()}
first_trial = {
"trial_number": first_row['number'],
"objective": first_row['objective'],
"design_variables": first_params
}
# Calculate improvement
if best_row and first_row['objective'] != 0:
improvement_pct = ((first_row['objective'] - best_row['objective']) / abs(first_row['objective'])) * 100
improvements["objective"] = {
"initial": first_row['objective'],
"final": best_row['objective'],
"improvement_pct": round(improvement_pct, 2),
"absolute_change": round(first_row['objective'] - best_row['objective'], 6)
}
conn.close()
return {
"study_id": study_id,
"best_trial": best_trial,
"first_trial": first_trial,
"improvements": improvements,
"total_trials": total_trials
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get best solution: {str(e)}")
@router.get("/studies/{study_id}/runs")
async def get_study_runs(study_id: str):
"""
Get all optimization runs/studies in the database for comparison.
Many studies have multiple Optuna studies (e.g., v11_fea, v11_iter1_nn, v11_iter2_nn).
This endpoint returns metrics for each sub-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")
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
return {"runs": [], "total_runs": 0}
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all Optuna studies in this database
cursor.execute("""
SELECT study_id, study_name
FROM studies
ORDER BY study_id
""")
studies = cursor.fetchall()
runs = []
for study_row in studies:
optuna_study_id = study_row['study_id']
study_name = study_row['study_name']
# Get trial count
cursor.execute("""
SELECT COUNT(*) FROM trials
WHERE study_id = ? AND state = 'COMPLETE'
""", (optuna_study_id,))
trial_count = cursor.fetchone()[0]
if trial_count == 0:
continue
# Get best value (first objective)
cursor.execute("""
SELECT MIN(tv.value) as best_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.study_id = ? AND t.state = 'COMPLETE' AND tv.objective = 0
""", (optuna_study_id,))
best_result = cursor.fetchone()
best_value = best_result['best_value'] if best_result else None
# Get average value
cursor.execute("""
SELECT AVG(tv.value) as avg_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.study_id = ? AND t.state = 'COMPLETE' AND tv.objective = 0
""", (optuna_study_id,))
avg_result = cursor.fetchone()
avg_value = avg_result['avg_value'] if avg_result else None
# Get time range
cursor.execute("""
SELECT MIN(datetime_start) as first_trial, MAX(datetime_complete) as last_trial
FROM trials
WHERE study_id = ? AND state = 'COMPLETE'
""", (optuna_study_id,))
time_result = cursor.fetchone()
# Determine source type (FEA or NN)
source = "NN" if "_nn" in study_name.lower() else "FEA"
runs.append({
"run_id": optuna_study_id,
"name": study_name,
"source": source,
"trial_count": trial_count,
"best_value": best_value,
"avg_value": avg_value,
"first_trial": time_result['first_trial'] if time_result else None,
"last_trial": time_result['last_trial'] if time_result else None
})
conn.close()
return {
"runs": runs,
"total_runs": len(runs),
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get runs: {str(e)}")
class UpdateConfigRequest(BaseModel):
config: dict
@router.put("/studies/{study_id}/config")
async def update_study_config(study_id: str, request: UpdateConfigRequest):
"""
Update the optimization_config.json for a study
Args:
study_id: Study identifier
request: New configuration data
Returns:
JSON with success status
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Check if optimization is running - don't allow config changes while running
if is_optimization_running(study_id):
raise HTTPException(
status_code=409,
detail="Cannot modify config while optimization is running. Stop the optimization first."
)
# Find config file location
config_file = study_dir / "1_setup" / "optimization_config.json"
if not config_file.exists():
config_file = study_dir / "optimization_config.json"
if not config_file.exists():
raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}")
# Backup existing config
backup_file = config_file.with_suffix('.json.backup')
shutil.copy(config_file, backup_file)
# Write new config
with open(config_file, 'w') as f:
json.dump(request.config, f, indent=2)
return {
"success": True,
"message": "Configuration updated successfully",
"path": str(config_file),
"backup_path": str(backup_file)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update config: {str(e)}")
@router.get("/studies/{study_id}/export/{format}")
async def export_study_data(study_id: str, format: str):
"""Export study data in various formats: csv, json, excel"""
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 = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
raise HTTPException(status_code=404, detail="No study data available")
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all completed trials with their params and values
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE'
ORDER BY t.number
""")
trials_data = []
for row in cursor.fetchall():
trial_id = row['trial_id']
# Get params
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {r['param_name']: r['param_value'] for r in cursor.fetchall()}
# Get user attrs
cursor.execute("""
SELECT key, value_json
FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
user_attrs = {}
for r in cursor.fetchall():
try:
user_attrs[r['key']] = json.loads(r['value_json'])
except:
user_attrs[r['key']] = r['value_json']
trials_data.append({
"trial_number": row['number'],
"objective": row['objective'],
"params": params,
"user_attrs": user_attrs
})
conn.close()
if format.lower() == "json":
return JSONResponse(content={
"study_id": study_id,
"total_trials": len(trials_data),
"trials": trials_data
})
elif format.lower() == "csv":
import io
import csv
if not trials_data:
return JSONResponse(content={"error": "No data to export"})
# Build CSV
output = io.StringIO()
# Get all param names
param_names = sorted(set(
key for trial in trials_data
for key in trial['params'].keys()
))
fieldnames = ['trial_number', 'objective'] + param_names
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for trial in trials_data:
row_data = {
'trial_number': trial['trial_number'],
'objective': trial['objective']
}
row_data.update(trial['params'])
writer.writerow(row_data)
csv_content = output.getvalue()
return JSONResponse(content={
"filename": f"{study_id}_data.csv",
"content": csv_content,
"content_type": "text/csv"
})
elif format.lower() == "config":
# Export optimization config
setup_dir = study_dir / "1_setup"
config_path = setup_dir / "optimization_config.json"
if config_path.exists():
with open(config_path, 'r') as f:
config = json.load(f)
return JSONResponse(content={
"filename": f"{study_id}_config.json",
"content": json.dumps(config, indent=2),
"content_type": "application/json"
})
else:
raise HTTPException(status_code=404, detail="Config file not found")
else:
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export data: {str(e)}")

View File

@@ -19,6 +19,82 @@ router = APIRouter()
# Store active terminal sessions
_terminal_sessions: dict = {}
# Path to Atomizer root (for loading prompts)
ATOMIZER_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)))
def get_session_prompt(study_name: str = None) -> str:
"""
Generate the initial prompt for a Claude session.
This injects the Protocol Operating System context and study-specific info.
"""
prompt_lines = [
"# Atomizer Session Context",
"",
"You are assisting with **Atomizer** - an LLM-first FEA optimization framework.",
"",
"## Bootstrap (READ FIRST)",
"",
"Read these files to understand how to help:",
"- `.claude/skills/00_BOOTSTRAP.md` - Task classification and routing",
"- `.claude/skills/01_CHEATSHEET.md` - Quick reference (I want X → Use Y)",
"- `.claude/skills/02_CONTEXT_LOADER.md` - What to load per task",
"",
"## Protocol System",
"",
"| Layer | Location | Purpose |",
"|-------|----------|---------|",
"| Operations | `docs/protocols/operations/OP_*.md` | How-to guides |",
"| System | `docs/protocols/system/SYS_*.md` | Core specs |",
"| Extensions | `docs/protocols/extensions/EXT_*.md` | Adding features |",
"",
]
if study_name:
prompt_lines.extend([
f"## Current Study: `{study_name}`",
"",
f"**Directory**: `studies/{study_name}/`",
"",
"Key files:",
f"- `studies/{study_name}/1_setup/optimization_config.json` - Configuration",
f"- `studies/{study_name}/2_results/study.db` - Optuna database",
f"- `studies/{study_name}/README.md` - Study documentation",
"",
"Quick status check:",
"```bash",
f"python -c \"import optuna; s=optuna.load_study('{study_name}', 'sqlite:///studies/{study_name}/2_results/study.db'); print(f'Trials: {{len(s.trials)}}, Best: {{s.best_value}}')\"",
"```",
"",
])
else:
prompt_lines.extend([
"## No Study Selected",
"",
"No specific study context. You can:",
"- List studies: `ls studies/`",
"- Create new study: Ask user what they want to optimize",
"- Load context: Read `.claude/skills/core/study-creation-core.md`",
"",
])
prompt_lines.extend([
"## Key Principles",
"",
"1. **Read bootstrap first** - Follow task routing from 00_BOOTSTRAP.md",
"2. **Use centralized extractors** - Check `optimization_engine/extractors/`",
"3. **Never modify master models** - Work on copies",
"4. **Python env**: Always use `conda activate atomizer`",
"",
"---",
"*Session launched from Atomizer Dashboard*",
])
return "\n".join(prompt_lines)
# Check if winpty is available (for Windows)
try:
from winpty import PtyProcess
@@ -44,9 +120,6 @@ class TerminalSession:
self.websocket = websocket
self._running = True
# Determine the claude command
claude_cmd = "claude"
try:
if self._use_winpty:
# Use winpty for proper PTY on Windows
@@ -306,14 +379,13 @@ async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_i
{"type": "output", "data": "terminal output"}
{"type": "exit", "code": 0}
{"type": "error", "message": "..."}
{"type": "context", "prompt": "..."} # Initial context prompt
"""
await websocket.accept()
# Default to Atomizer root directory
if not working_dir:
working_dir = str(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
))))
working_dir = ATOMIZER_ROOT
# Create session
session_id = f"claude-{id(websocket)}"
@@ -321,13 +393,24 @@ async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_i
_terminal_sessions[session_id] = session
try:
# Send context prompt to frontend (for display/reference)
context_prompt = get_session_prompt(study_id)
await websocket.send_json({
"type": "context",
"prompt": context_prompt,
"study_id": study_id
})
# Start Claude Code
await session.start(websocket)
# Note: Claude is started in Atomizer root directory so it has access to:
# - CLAUDE.md (system instructions)
# - .claude/skills/ (skill definitions)
# The study_id is available for the user to reference in their prompts
# If study_id provided, send initial context to Claude after startup
if study_id:
# Wait a moment for Claude to initialize
await asyncio.sleep(1.0)
# Send the context as the first message
initial_message = f"I'm working with the Atomizer study '{study_id}'. Please read .claude/skills/00_BOOTSTRAP.md first to understand the Protocol Operating System, then help me with this study.\n"
await session.write(initial_message)
# Handle incoming messages
while session._running:
@@ -370,3 +453,31 @@ async def terminal_status():
"winpty_available": HAS_WINPTY,
"message": "Claude Code CLI is available" if claude_path else "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
}
@router.get("/context")
async def get_context(study_id: str = None):
"""
Get the context prompt for a Claude session without starting a terminal.
Useful for displaying context in the UI or preparing prompts.
Query params:
study_id: Optional study ID to include study-specific context
"""
prompt = get_session_prompt(study_id)
return {
"study_id": study_id,
"prompt": prompt,
"bootstrap_files": [
".claude/skills/00_BOOTSTRAP.md",
".claude/skills/01_CHEATSHEET.md",
".claude/skills/02_CONTEXT_LOADER.md",
],
"study_files": [
f"studies/{study_id}/1_setup/optimization_config.json",
f"studies/{study_id}/2_results/study.db",
f"studies/{study_id}/README.md",
] if study_id else []
}

View File

@@ -1,10 +1,10 @@
import asyncio
import json
import sqlite3
from pathlib import Path
from typing import Dict, Set
from typing import Dict, Set, Optional, Any, List
from datetime import datetime
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import aiofiles
router = APIRouter()
@@ -12,185 +12,440 @@ router = APIRouter()
# Base studies directory
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
class OptimizationFileHandler(FileSystemEventHandler):
def get_results_dir(study_dir: Path) -> Path:
"""Get the results directory for a study, supporting both 2_results and 3_results."""
results_dir = study_dir / "2_results"
if not results_dir.exists():
results_dir = study_dir / "3_results"
return results_dir
class DatabasePoller:
"""
Polls the Optuna SQLite database for changes.
More reliable than file watching, especially on Windows.
"""
def __init__(self, study_id: str, callback):
self.study_id = study_id
self.callback = callback
self.last_trial_count = 0
self.last_pruned_count = 0
self.last_trial_id = 0
self.last_best_value: Optional[float] = None
self.last_pareto_count = 0
self.last_state_timestamp = ""
self.running = False
self._task: Optional[asyncio.Task] = None
def on_modified(self, event):
if event.src_path.endswith("optimization_history_incremental.json"):
asyncio.run(self.process_history_update(event.src_path))
elif event.src_path.endswith("pruning_history.json"):
asyncio.run(self.process_pruning_update(event.src_path))
elif event.src_path.endswith("study.db"): # Watch for Optuna DB changes (Pareto front)
asyncio.run(self.process_pareto_update(event.src_path))
elif event.src_path.endswith("optimizer_state.json"):
asyncio.run(self.process_state_update(event.src_path))
async def start(self):
"""Start the polling loop"""
self.running = True
self._task = asyncio.create_task(self._poll_loop())
async def process_history_update(self, file_path):
async def stop(self):
"""Stop the polling loop"""
self.running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _poll_loop(self):
"""Main polling loop - checks database every 2 seconds"""
study_dir = STUDIES_DIR / self.study_id
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
while self.running:
try:
if db_path.exists():
await self._check_database(db_path)
await asyncio.sleep(2) # Poll every 2 seconds
except asyncio.CancelledError:
break
except Exception as e:
print(f"[WebSocket] Polling error for {self.study_id}: {e}")
await asyncio.sleep(5) # Back off on error
async def _check_database(self, db_path: Path):
"""Check database for new trials and updates"""
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
history = json.loads(content)
current_count = len(history)
if current_count > self.last_trial_count:
# New trials added
new_trials = history[self.last_trial_count:]
for trial in new_trials:
await self.callback({
"type": "trial_completed",
"data": trial
})
self.last_trial_count = current_count
# Use timeout to avoid blocking on locked databases
conn = sqlite3.connect(str(db_path), timeout=2.0)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get total completed trial count
cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
total_count = cursor.fetchone()[0]
# Check for new trials
if total_count > self.last_trial_count:
await self._process_new_trials(cursor, total_count)
# Check for new best value
await self._check_best_value(cursor)
# Check Pareto front for multi-objective
await self._check_pareto_front(cursor, db_path)
# Send progress update
await self._send_progress(cursor, total_count)
conn.close()
except sqlite3.OperationalError as e:
# Database locked - skip this poll
pass
except Exception as e:
print(f"Error processing history update: {e}")
print(f"[WebSocket] Database check error: {e}")
async def process_pruning_update(self, file_path):
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
history = json.loads(content)
current_count = len(history)
if current_count > self.last_pruned_count:
# New pruned trials
new_pruned = history[self.last_pruned_count:]
for trial in new_pruned:
await self.callback({
"type": "trial_pruned",
"data": trial
})
self.last_pruned_count = current_count
except Exception as e:
print(f"Error processing pruning update: {e}")
async def _process_new_trials(self, cursor, total_count: int):
"""Process and broadcast new trials"""
# Get new trials since last check
cursor.execute("""
SELECT t.trial_id, t.number, t.datetime_start, t.datetime_complete, s.study_name
FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE' AND t.trial_id > ?
ORDER BY t.trial_id ASC
""", (self.last_trial_id,))
new_trials = cursor.fetchall()
for row in new_trials:
trial_id = row['trial_id']
trial_data = await self._build_trial_data(cursor, row)
await self.callback({
"type": "trial_completed",
"data": trial_data
})
self.last_trial_id = trial_id
self.last_trial_count = total_count
async def _build_trial_data(self, cursor, row) -> Dict[str, Any]:
"""Build trial data dictionary from database row"""
trial_id = row['trial_id']
# Get objectives
cursor.execute("""
SELECT value FROM trial_values
WHERE trial_id = ? ORDER BY objective
""", (trial_id,))
values = [r[0] for r in cursor.fetchall()]
# Get parameters
cursor.execute("""
SELECT param_name, param_value FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {}
for r in cursor.fetchall():
try:
params[r[0]] = float(r[1]) if r[1] is not None else None
except (ValueError, TypeError):
params[r[0]] = r[1]
# Get user attributes
cursor.execute("""
SELECT key, value_json FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
user_attrs = {}
for r in cursor.fetchall():
try:
user_attrs[r[0]] = json.loads(r[1])
except (ValueError, TypeError):
user_attrs[r[0]] = r[1]
# Extract source and design vars
source = user_attrs.get("source", "FEA")
design_vars = user_attrs.get("design_vars", params)
return {
"trial_number": trial_id, # Use trial_id for uniqueness
"trial_num": row['number'],
"objective": values[0] if values else None,
"values": values,
"params": design_vars,
"user_attrs": user_attrs,
"source": source,
"start_time": row['datetime_start'],
"end_time": row['datetime_complete'],
"study_name": row['study_name'],
"constraint_satisfied": user_attrs.get("constraint_satisfied", True)
}
async def _check_best_value(self, cursor):
"""Check for new best value and broadcast if changed"""
cursor.execute("""
SELECT MIN(tv.value) as best_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE' AND tv.objective = 0
""")
result = cursor.fetchone()
if result and result['best_value'] is not None:
best_value = result['best_value']
if self.last_best_value is None or best_value < self.last_best_value:
# Get the best trial details
cursor.execute("""
SELECT t.trial_id, t.number
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE' AND tv.objective = 0 AND tv.value = ?
LIMIT 1
""", (best_value,))
best_row = cursor.fetchone()
if best_row:
# Get params for best trial
cursor.execute("""
SELECT param_name, param_value FROM trial_params
WHERE trial_id = ?
""", (best_row['trial_id'],))
params = {r[0]: r[1] for r in cursor.fetchall()}
async def process_pareto_update(self, file_path):
# This is tricky because study.db is binary.
# Instead of reading it directly, we'll trigger a re-fetch of the Pareto front via Optuna
# We debounce this to avoid excessive reads
try:
# Import here to avoid circular imports or heavy load at startup
import optuna
# Connect to DB
storage = optuna.storages.RDBStorage(f"sqlite:///{file_path}")
study = optuna.load_study(study_name=self.study_id, storage=storage)
# Check if multi-objective
if len(study.directions) > 1:
pareto_trials = study.best_trials
# Only broadcast if count changed (simple heuristic)
# In a real app, we might check content hash
if len(pareto_trials) != self.last_pareto_count:
pareto_data = [
{
"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
]
await self.callback({
"type": "pareto_front",
"type": "new_best",
"data": {
"pareto_front": pareto_data,
"count": len(pareto_trials)
"trial_number": best_row['trial_id'],
"value": best_value,
"params": params,
"improvement": (
((self.last_best_value - best_value) / abs(self.last_best_value) * 100)
if self.last_best_value else 0
)
}
})
self.last_pareto_count = len(pareto_trials)
self.last_best_value = best_value
async def _check_pareto_front(self, cursor, db_path: Path):
"""Check for Pareto front updates in multi-objective studies"""
try:
# Check if multi-objective by counting distinct objectives
cursor.execute("""
SELECT COUNT(DISTINCT objective) as obj_count
FROM trial_values
WHERE trial_id IN (SELECT trial_id FROM trials WHERE state = 'COMPLETE')
""")
result = cursor.fetchone()
if result and result['obj_count'] > 1:
# Multi-objective - compute Pareto front
import optuna
storage = optuna.storages.RDBStorage(f"sqlite:///{db_path}")
# Get all study names
cursor.execute("SELECT study_name FROM studies")
study_names = [r[0] for r in cursor.fetchall()]
for study_name in study_names:
try:
study = optuna.load_study(study_name=study_name, storage=storage)
if len(study.directions) > 1:
pareto_trials = study.best_trials
if len(pareto_trials) != self.last_pareto_count:
pareto_data = [
{
"trial_number": t.number,
"values": t.values,
"params": t.params,
"constraint_satisfied": t.user_attrs.get("constraint_satisfied", True),
"source": t.user_attrs.get("source", "FEA")
}
for t in pareto_trials
]
await self.callback({
"type": "pareto_update",
"data": {
"pareto_front": pareto_data,
"count": len(pareto_trials)
}
})
self.last_pareto_count = len(pareto_trials)
break
except:
continue
except Exception as e:
# DB might be locked, ignore transient errors
# Non-critical - skip Pareto check
pass
async def process_state_update(self, file_path):
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
state = json.loads(content)
# Check timestamp to avoid duplicate broadcasts
if state.get("timestamp") != self.last_state_timestamp:
await self.callback({
"type": "optimizer_state",
"data": state
})
self.last_state_timestamp = state.get("timestamp")
except Exception as e:
print(f"Error processing state update: {e}")
async def _send_progress(self, cursor, total_count: int):
"""Send progress update"""
# Get total from config if available
study_dir = STUDIES_DIR / self.study_id
config_path = study_dir / "1_setup" / "optimization_config.json"
if not config_path.exists():
config_path = study_dir / "optimization_config.json"
total_target = 100 # Default
if config_path.exists():
try:
with open(config_path) as f:
config = json.load(f)
total_target = config.get('optimization_settings', {}).get('n_trials', 100)
except:
pass
# Count FEA vs NN trials
cursor.execute("""
SELECT
COUNT(CASE WHEN s.study_name LIKE '%_nn%' THEN 1 END) as nn_count,
COUNT(CASE WHEN s.study_name NOT LIKE '%_nn%' THEN 1 END) as fea_count
FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE'
""")
counts = cursor.fetchone()
await self.callback({
"type": "progress",
"data": {
"current": total_count,
"total": total_target,
"percentage": min(100, (total_count / total_target * 100)) if total_target > 0 else 0,
"fea_count": counts['fea_count'] if counts else total_count,
"nn_count": counts['nn_count'] if counts else 0,
"timestamp": datetime.now().isoformat()
}
})
class ConnectionManager:
"""
Manages WebSocket connections and database pollers for real-time updates.
Uses database polling instead of file watching for better reliability on Windows.
"""
def __init__(self):
self.active_connections: Dict[str, Set[WebSocket]] = {}
self.observers: Dict[str, Observer] = {}
self.pollers: Dict[str, DatabasePoller] = {}
async def connect(self, websocket: WebSocket, study_id: str):
"""Connect a new WebSocket client"""
await websocket.accept()
if study_id not in self.active_connections:
self.active_connections[study_id] = set()
self.start_watching(study_id)
self.active_connections[study_id].add(websocket)
def disconnect(self, websocket: WebSocket, study_id: str):
# Start polling if not already running
if study_id not in self.pollers:
await self._start_polling(study_id)
async def disconnect(self, websocket: WebSocket, study_id: str):
"""Disconnect a WebSocket client"""
if study_id in self.active_connections:
self.active_connections[study_id].remove(websocket)
self.active_connections[study_id].discard(websocket)
# Stop polling if no more connections
if not self.active_connections[study_id]:
del self.active_connections[study_id]
self.stop_watching(study_id)
await self._stop_polling(study_id)
async def broadcast(self, message: dict, study_id: str):
if study_id in self.active_connections:
for connection in self.active_connections[study_id]:
try:
await connection.send_json(message)
except Exception as e:
print(f"Error broadcasting to client: {e}")
def start_watching(self, study_id: str):
study_dir = STUDIES_DIR / study_id / "2_results"
if not study_dir.exists():
"""Broadcast message to all connected clients for a study"""
if study_id not in self.active_connections:
return
disconnected = []
for connection in self.active_connections[study_id]:
try:
await connection.send_json(message)
except Exception as e:
print(f"[WebSocket] Error broadcasting to client: {e}")
disconnected.append(connection)
# Clean up disconnected clients
for conn in disconnected:
self.active_connections[study_id].discard(conn)
async def _start_polling(self, study_id: str):
"""Start database polling for a study"""
async def callback(message):
await self.broadcast(message, study_id)
event_handler = OptimizationFileHandler(study_id, callback)
observer = Observer()
observer.schedule(event_handler, str(study_dir), recursive=True)
observer.start()
self.observers[study_id] = observer
poller = DatabasePoller(study_id, callback)
self.pollers[study_id] = poller
await poller.start()
print(f"[WebSocket] Started polling for study: {study_id}")
async def _stop_polling(self, study_id: str):
"""Stop database polling for a study"""
if study_id in self.pollers:
await self.pollers[study_id].stop()
del self.pollers[study_id]
print(f"[WebSocket] Stopped polling for study: {study_id}")
def stop_watching(self, study_id: str):
if study_id in self.observers:
self.observers[study_id].stop()
self.observers[study_id].join()
del self.observers[study_id]
manager = ConnectionManager()
@router.websocket("/optimization/{study_id}")
async def optimization_stream(websocket: WebSocket, study_id: str):
"""
WebSocket endpoint for real-time optimization updates.
Sends messages:
- connected: Initial connection confirmation
- trial_completed: New trial completed with full data
- new_best: New best value found
- progress: Progress update (current/total, FEA/NN counts)
- pareto_update: Pareto front updated (multi-objective)
"""
await manager.connect(websocket, study_id)
try:
# Send initial connection message
await websocket.send_json({
"type": "connected",
"data": {"message": f"Connected to stream for study {study_id}"}
"data": {
"study_id": study_id,
"message": f"Connected to real-time stream for study {study_id}",
"timestamp": datetime.now().isoformat()
}
})
# Keep connection alive
while True:
# Keep connection alive and handle incoming messages if needed
data = await websocket.receive_text()
# We could handle client commands here (e.g., "pause", "stop")
try:
# Wait for messages (ping/pong or commands)
data = await asyncio.wait_for(
websocket.receive_text(),
timeout=30.0 # 30 second timeout for ping
)
# Handle client commands
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except asyncio.TimeoutError:
# Send heartbeat
try:
await websocket.send_json({"type": "heartbeat"})
except:
break
except WebSocketDisconnect:
manager.disconnect(websocket, study_id)
pass
except Exception as e:
print(f"WebSocket error: {e}")
manager.disconnect(websocket, study_id)
print(f"[WebSocket] Connection error for {study_id}: {e}")
finally:
await manager.disconnect(websocket, study_id)

View File

@@ -5,7 +5,9 @@ import { ClaudeTerminalProvider } from './context/ClaudeTerminalContext';
import { MainLayout } from './components/layout/MainLayout';
import { GlobalClaudeTerminal } from './components/GlobalClaudeTerminal';
import Home from './pages/Home';
import Setup from './pages/Setup';
import Dashboard from './pages/Dashboard';
import Analysis from './pages/Analysis';
import Results from './pages/Results';
const queryClient = new QueryClient({
@@ -29,9 +31,10 @@ function App() {
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>
<Route path="setup" element={<Setup />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analysis" element={<Analysis />} />
<Route path="results" element={<Results />} />
<Route path="analytics" element={<Dashboard />} />
</Route>
</Routes>

View File

@@ -52,6 +52,22 @@ export interface ProcessStatus {
nn_count?: number;
}
export interface ModelFile {
name: string;
path: string;
extension: string;
size_bytes: number;
size_display: string;
modified: string;
}
export interface ModelFilesResponse {
study_id: string;
model_dir: string;
files: ModelFile[];
count: number;
}
class ApiClient {
async getStudies(): Promise<StudyListResponse> {
const response = await fetch(`${API_BASE}/optimization/studies`);
@@ -193,6 +209,64 @@ class ApiClient {
}
return response.json();
}
// Model files
async getModelFiles(studyId: string): Promise<ModelFilesResponse> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/model-files`);
if (!response.ok) throw new Error('Failed to fetch model files');
return response.json();
}
async openFolder(studyId: string, folderType: 'model' | 'results' | 'setup' = 'model'): Promise<{ success: boolean; message: string; path: string }> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/open-folder?folder_type=${folderType}`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to open folder');
}
return response.json();
}
async getBestSolution(studyId: string): Promise<{
study_id: string;
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/best-solution`);
if (!response.ok) throw new Error('Failed to fetch best solution');
return response.json();
}
async exportData(studyId: string, format: 'csv' | 'json' | 'config'): Promise<{
filename?: string;
content: string;
content_type?: string;
study_id?: string;
total_trials?: number;
trials?: any[];
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/export/${format}`);
if (!response.ok) throw new Error(`Failed to export ${format}`);
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -10,7 +10,8 @@ import {
X,
RefreshCw,
AlertCircle,
FolderOpen
FolderOpen,
Plus
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
@@ -256,16 +257,29 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
// Set study context - sends context message to Claude silently
const setStudyContext = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !selectedStudy?.id) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setSettingContext(true);
// Send context message with POS bootstrap instructions and study context
const contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
let contextMessage: string;
if (selectedStudy?.id) {
// Existing study context
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
} else {
// No study selected - offer to create new study
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`No study is currently selected. ` +
`Read .claude/skills/guided-study-wizard.md and help the user create a new optimization study. ` +
`Use atomizer conda env. Start the guided wizard by asking what they want to optimize.`;
}
wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' }));
// Mark as done after Claude has had time to process
@@ -325,33 +339,37 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{/* Set Context button - always show, with different states */}
{/* Set Context button - works for both existing study and new study creation */}
<button
onClick={setStudyContext}
disabled={!selectedStudy?.id || !isConnected || settingContext || contextSet}
disabled={!isConnected || settingContext || contextSet}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
contextSet
? 'bg-green-600/20 text-green-400'
: !selectedStudy?.id || !isConnected
: !isConnected
? 'bg-dark-600 text-dark-400'
: 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: selectedStudy?.id
? 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: 'bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={
!selectedStudy?.id
? 'No study selected - select a study from Home page'
: !isConnected
? 'Connect first to set study context'
: contextSet
? 'Context already set'
: `Set context to study: ${selectedStudy.id}`
!isConnected
? 'Connect first to set context'
: contextSet
? 'Context already set'
: selectedStudy?.id
? `Set context to study: ${selectedStudy.id}`
: 'Start guided study creation wizard'
}
>
{settingContext ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
) : selectedStudy?.id ? (
<FolderOpen className="w-3 h-3" />
) : (
<Plus className="w-3 h-3" />
)}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'No Study'}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'New Study'}
</button>
{onToggleExpand && (

View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useCallback } from 'react';
import { Settings, Save, X, AlertTriangle, Check, RotateCcw } from 'lucide-react';
import { Card } from './common/Card';
interface DesignVariable {
name: string;
min: number;
max: number;
type?: string;
description?: string;
}
interface Objective {
name: string;
direction: 'minimize' | 'maximize';
description?: string;
unit?: string;
}
interface OptimizationConfig {
study_name?: string;
description?: string;
design_variables?: DesignVariable[];
objectives?: Objective[];
constraints?: any[];
optimization_settings?: {
n_trials?: number;
sampler?: string;
[key: string]: any;
};
[key: string]: any;
}
interface ConfigEditorProps {
studyId: string;
onClose: () => void;
onSaved?: () => void;
}
export function ConfigEditor({ studyId, onClose, onSaved }: ConfigEditorProps) {
const [config, setConfig] = useState<OptimizationConfig | null>(null);
const [originalConfig, setOriginalConfig] = useState<OptimizationConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'variables' | 'objectives' | 'settings' | 'json'>('general');
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
// Load config
useEffect(() => {
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
// Check if optimization is running
const processRes = await fetch(`/api/optimization/studies/${studyId}/process`);
const processData = await processRes.json();
setIsRunning(processData.is_running);
// Load config
const configRes = await fetch(`/api/optimization/studies/${studyId}/config`);
if (!configRes.ok) {
throw new Error('Failed to load config');
}
const configData = await configRes.json();
setConfig(configData.config);
setOriginalConfig(JSON.parse(JSON.stringify(configData.config)));
setJsonText(JSON.stringify(configData.config, null, 2));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
};
loadConfig();
}, [studyId]);
// Handle JSON text changes
const handleJsonChange = useCallback((text: string) => {
setJsonText(text);
setJsonError(null);
try {
const parsed = JSON.parse(text);
setConfig(parsed);
} catch (err) {
setJsonError('Invalid JSON');
}
}, []);
// Save config
const handleSave = async () => {
if (!config || isRunning) return;
try {
setSaving(true);
setError(null);
setSuccess(null);
const res = await fetch(`/api/optimization/studies/${studyId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to save config');
}
setSuccess('Configuration saved successfully');
setOriginalConfig(JSON.parse(JSON.stringify(config)));
onSaved?.();
// Clear success after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save config');
} finally {
setSaving(false);
}
};
// Reset to original
const handleReset = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)));
setJsonText(JSON.stringify(originalConfig, null, 2));
setJsonError(null);
}
};
// Check if there are unsaved changes
const hasChanges = config && originalConfig
? JSON.stringify(config) !== JSON.stringify(originalConfig)
: false;
// Update a design variable
const updateDesignVariable = (index: number, field: keyof DesignVariable, value: any) => {
if (!config?.design_variables) return;
const newVars = [...config.design_variables];
newVars[index] = { ...newVars[index], [field]: value };
setConfig({ ...config, design_variables: newVars });
setJsonText(JSON.stringify({ ...config, design_variables: newVars }, null, 2));
};
// Update an objective
const updateObjective = (index: number, field: keyof Objective, value: any) => {
if (!config?.objectives) return;
const newObjs = [...config.objectives];
newObjs[index] = { ...newObjs[index], [field]: value };
setConfig({ ...config, objectives: newObjs });
setJsonText(JSON.stringify({ ...config, objectives: newObjs }, null, 2));
};
// Update optimization settings
const updateSettings = (field: string, value: any) => {
if (!config) return;
const newSettings = { ...config.optimization_settings, [field]: value };
setConfig({ ...config, optimization_settings: newSettings });
setJsonText(JSON.stringify({ ...config, optimization_settings: newSettings }, null, 2));
};
if (loading) {
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<Card className="w-full max-w-4xl max-h-[90vh] p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full"></div>
</div>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-600">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Edit Configuration</h2>
{hasChanges && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">
Unsaved changes
</span>
)}
</div>
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Warning if running */}
{isRunning && (
<div className="m-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">
Optimization is running. Stop it before editing configuration.
</span>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-dark-600 px-4">
{(['general', 'variables', 'objectives', 'settings', 'json'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: 'text-dark-400 hover:text-white'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-sm flex items-center gap-2">
<Check className="w-4 h-4" />
{success}
</div>
)}
{config && (
<>
{/* General Tab */}
{activeTab === 'general' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Study Name
</label>
<input
type="text"
value={config.study_name || ''}
onChange={(e) => {
setConfig({ ...config, study_name: e.target.value });
setJsonText(JSON.stringify({ ...config, study_name: e.target.value }, null, 2));
}}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Description
</label>
<textarea
value={config.description || ''}
onChange={(e) => {
setConfig({ ...config, description: e.target.value });
setJsonText(JSON.stringify({ ...config, description: e.target.value }, null, 2));
}}
disabled={isRunning}
rows={3}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
)}
{/* Design Variables Tab */}
{activeTab === 'variables' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Edit design variable bounds. These control the parameter search space.
</p>
{config.design_variables?.map((dv, index) => (
<div key={dv.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{dv.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Min</label>
<input
type="number"
value={dv.min}
onChange={(e) => updateDesignVariable(index, 'min', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Max</label>
<input
type="number"
value={dv.max}
onChange={(e) => updateDesignVariable(index, 'max', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Objectives Tab */}
{activeTab === 'objectives' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Configure optimization objectives and their directions.
</p>
{config.objectives?.map((obj, index) => (
<div key={obj.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{obj.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Direction</label>
<select
value={obj.direction}
onChange={(e) => updateObjective(index, 'direction', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Unit</label>
<input
type="text"
value={obj.unit || ''}
onChange={(e) => updateObjective(index, 'unit', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Optimization algorithm settings.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Number of Trials
</label>
<input
type="number"
value={config.optimization_settings?.n_trials || 100}
onChange={(e) => updateSettings('n_trials', parseInt(e.target.value))}
disabled={isRunning}
min={1}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Sampler
</label>
<select
value={config.optimization_settings?.sampler || 'TPE'}
onChange={(e) => updateSettings('sampler', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="TPE">TPE (Tree-structured Parzen Estimator)</option>
<option value="CMA-ES">CMA-ES (Evolution Strategy)</option>
<option value="NSGA-II">NSGA-II (Multi-objective)</option>
<option value="Random">Random</option>
<option value="QMC">QMC (Quasi-Monte Carlo)</option>
</select>
</div>
</div>
</div>
)}
{/* JSON Tab */}
{activeTab === 'json' && (
<div className="space-y-2">
<p className="text-dark-400 text-sm">
Edit the raw JSON configuration. Be careful with syntax.
</p>
{jsonError && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
{jsonError}
</div>
)}
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
disabled={isRunning}
spellCheck={false}
className="w-full h-96 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white font-mono text-sm disabled:opacity-50"
/>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-600">
<button
onClick={handleReset}
disabled={!hasChanges || isRunning}
className="flex items-center gap-2 px-4 py-2 text-dark-400 hover:text-white disabled:opacity-50 transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || !hasChanges || isRunning || !!jsonError}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</Card>
</div>
);
}
export default ConfigEditor;

View File

@@ -0,0 +1,184 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownRendererProps {
content: string;
className?: string;
}
/**
* Shared markdown renderer with syntax highlighting, GFM, and LaTeX support.
* Used by both the Home page (README display) and Results page (reports).
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
return (
<article className={`markdown-body max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{content}
</ReactMarkdown>
</article>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,107 @@
import { Bell, BellOff, BellRing } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
interface NotificationSettingsProps {
compact?: boolean;
}
export function NotificationSettings({ compact = false }: NotificationSettingsProps) {
const { permission, requestPermission, isEnabled, setEnabled } = useNotifications();
const handleToggle = async () => {
if (permission === 'unsupported') {
return;
}
if (!isEnabled) {
// Enabling - request permission if needed
if (permission !== 'granted') {
const granted = await requestPermission();
if (granted) {
setEnabled(true);
}
} else {
setEnabled(true);
}
} else {
// Disabling
setEnabled(false);
}
};
if (permission === 'unsupported') {
return null;
}
const getIcon = () => {
if (!isEnabled) return <BellOff className="w-4 h-4" />;
if (permission === 'denied') return <BellOff className="w-4 h-4 text-red-400" />;
return <BellRing className="w-4 h-4" />;
};
const getStatus = () => {
if (permission === 'denied') return 'Blocked';
if (!isEnabled) return 'Off';
return 'On';
};
if (compact) {
return (
<button
onClick={handleToggle}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400 hover:bg-primary-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={`Desktop notifications: ${getStatus()}`}
>
{getIcon()}
<span className="hidden sm:inline">{getStatus()}</span>
</button>
);
}
return (
<div className="flex items-center justify-between p-3 bg-dark-750 rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-700 text-dark-400'
}`}>
<Bell className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-medium text-white">Desktop Notifications</div>
<div className="text-xs text-dark-400">
{permission === 'denied'
? 'Blocked by browser - enable in browser settings'
: isEnabled
? 'Get notified when new best solutions are found'
: 'Enable to receive optimization updates'}
</div>
</div>
</div>
<button
onClick={handleToggle}
disabled={permission === 'denied'}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500'
: permission === 'denied'
? 'bg-dark-600 cursor-not-allowed'
: 'bg-dark-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isEnabled && permission === 'granted' ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default NotificationSettings;

View File

@@ -1,20 +1,25 @@
import { NavLink, useNavigate } from 'react-router-dom';
import {
Home,
Settings,
Activity,
FileText,
BarChart3,
TrendingUp,
ChevronLeft,
Play,
Pause,
CheckCircle,
Clock,
Zap
Zap,
Terminal
} from 'lucide-react';
import clsx from 'clsx';
import { useStudy } from '../../context/StudyContext';
import { useClaudeTerminal } from '../../context/ClaudeTerminalContext';
export const Sidebar = () => {
const { selectedStudy, clearStudy } = useStudy();
const { isConnected: claudeConnected, setIsOpen: setClaudeTerminalOpen } = useClaudeTerminal();
const navigate = useNavigate();
const handleBackToHome = () => {
@@ -26,8 +31,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'paused':
return <Pause className="w-3 h-3 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3 h-3 text-blue-400" />;
case 'not_started':
return <Clock className="w-3 h-3 text-dark-400" />;
default:
return <Clock className="w-3 h-3 text-dark-400" />;
}
@@ -37,8 +46,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return 'text-green-400';
case 'paused':
return 'text-orange-400';
case 'completed':
return 'text-blue-400';
case 'not_started':
return 'text-dark-400';
default:
return 'text-dark-400';
}
@@ -47,9 +60,10 @@ export const Sidebar = () => {
// Navigation items depend on whether a study is selected
const navItems = selectedStudy
? [
{ to: '/setup', icon: Settings, label: 'Setup' },
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
{ to: '/results', icon: FileText, label: 'Reports' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
{ to: '/results', icon: FileText, label: 'Results' },
]
: [
{ to: '/', icon: Home, label: 'Select Study' },
@@ -133,6 +147,23 @@ export const Sidebar = () => {
Optimization Running
</div>
)}
{selectedStudy && selectedStudy.status === 'paused' && (
<div className="flex items-center gap-2 text-sm text-orange-400 mt-1">
<div className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization Paused
</div>
)}
{/* Claude Terminal Status */}
<button
onClick={() => setClaudeTerminalOpen(true)}
className={clsx(
'flex items-center gap-2 text-sm mt-1 w-full text-left hover:opacity-80 transition-opacity',
claudeConnected ? 'text-green-400' : 'text-dark-400'
)}
>
<Terminal className="w-3 h-3" />
{claudeConnected ? 'Claude Connected' : 'Claude Disconnected'}
</button>
</div>
</div>
</aside>

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface PlotlyCorrelationHeatmapProps {
trials: TrialData[];
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0 || n !== y.length) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
export function PlotlyCorrelationHeatmap({
trials,
objectiveName = 'Objective',
height = 500
}: PlotlyCorrelationHeatmapProps) {
const { matrix, labels, annotations } = useMemo(() => {
if (trials.length < 3) {
return { matrix: [], labels: [], annotations: [] };
}
// Get parameter names
const paramNames = Object.keys(trials[0].params);
const allLabels = [...paramNames, objectiveName];
// Extract data columns
const columns: Record<string, number[]> = {};
paramNames.forEach(name => {
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
});
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
// Calculate correlation matrix
const n = allLabels.length;
const correlationMatrix: number[][] = [];
const annotationData: any[] = [];
for (let i = 0; i < n; i++) {
const row: number[] = [];
for (let j = 0; j < n; j++) {
const col1 = columns[allLabels[i]];
const col2 = columns[allLabels[j]];
// Ensure same length
const minLen = Math.min(col1.length, col2.length);
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
row.push(corr);
// Add annotation
annotationData.push({
x: allLabels[j],
y: allLabels[i],
text: corr.toFixed(2),
showarrow: false,
font: {
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
size: 11
}
});
}
correlationMatrix.push(row);
}
return {
matrix: correlationMatrix,
labels: allLabels,
annotations: annotationData
};
}, [trials, objectiveName]);
if (trials.length < 3) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>Need at least 3 trials to compute correlations</p>
</div>
);
}
return (
<Plot
data={[
{
z: matrix,
x: labels,
y: labels,
type: 'heatmap',
colorscale: [
[0, '#ef4444'], // -1: strong negative (red)
[0.25, '#f87171'], // -0.5: moderate negative
[0.5, '#1a1b26'], // 0: no correlation (dark)
[0.75, '#60a5fa'], // 0.5: moderate positive
[1, '#3b82f6'] // 1: strong positive (blue)
],
zmin: -1,
zmax: 1,
showscale: true,
colorbar: {
title: { text: 'Correlation', font: { color: '#888' } },
tickfont: { color: '#888' },
len: 0.8
},
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
}
]}
layout={{
title: {
text: 'Parameter-Objective Correlation Matrix',
font: { color: '#fff', size: 14 }
},
height,
margin: { l: 120, r: 60, t: 60, b: 120 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
tickangle: 45,
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
annotations: annotations
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
constraint_satisfied?: boolean;
}
interface PlotlyFeasibilityChartProps {
trials: TrialData[];
height?: number;
}
export function PlotlyFeasibilityChart({
trials,
height = 350
}: PlotlyFeasibilityChartProps) {
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
if (trials.length === 0) {
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
}
// Sort trials by number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const numbers: number[] = [];
const cumulative: number[] = [];
const windowed: number[] = [];
let feasibleCount = 0;
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
sorted.forEach((trial, idx) => {
numbers.push(trial.trial_number);
// Cumulative feasibility
if (trial.constraint_satisfied !== false) {
feasibleCount++;
}
cumulative.push((feasibleCount / (idx + 1)) * 100);
// Windowed (rolling) feasibility
const windowStart = Math.max(0, idx - windowSize + 1);
const windowTrials = sorted.slice(windowStart, idx + 1);
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
windowed.push((windowFeasible / windowTrials.length) * 100);
});
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
}, [trials]);
if (trials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No trials to display</p>
</div>
);
}
return (
<Plot
data={[
{
x: trialNumbers,
y: cumulativeFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Cumulative Feasibility',
line: { color: '#22c55e', width: 2 },
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
},
{
x: trialNumbers,
y: windowedFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Rolling (20-trial)',
line: { color: '#60a5fa', width: 2, dash: 'dot' },
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
}
]}
layout={{
height,
margin: { l: 60, r: 30, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)',
zeroline: false
},
yaxis: {
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)',
zeroline: false,
range: [0, 105]
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
x: 0.02,
y: 0.98,
xanchor: 'left',
yanchor: 'top'
},
showlegend: true,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -5,8 +5,10 @@
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Click to select trials
* - Pareto front connection line
* - FEA vs NN differentiation
* - Constraint satisfaction highlighting
* - Dark mode styling
* - Zoom, pan, and export
*/
@@ -19,6 +21,7 @@ interface Trial {
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
interface Objective {
@@ -32,28 +35,37 @@ interface PlotlyParetoPlotProps {
paretoFront: Trial[];
objectives: Objective[];
height?: number;
showParetoLine?: boolean;
showInfeasible?: boolean;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500
height = 500,
showParetoLine = true,
showInfeasible = true
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source and Pareto status
const { feaTrials, nnTrials, paretoTrials } = useMemo(() => {
// Separate trials by source, Pareto status, and constraint satisfaction
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
const infeasible: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
if (paretoSet.has(t.trial_number)) {
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
if (!isFeasible && showInfeasible) {
infeasible.push(t);
} else if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
@@ -62,8 +74,18 @@ export function PlotlyParetoPlot({
}
});
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto };
}, [trials, paretoSet]);
// Calculate statistics
const stats = {
totalTrials: trials.length,
paretoCount: pareto.length,
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
infeasibleCount: infeasible.length,
hypervolume: 0 // Could calculate if needed
};
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
}, [trials, paretoSet, showInfeasible]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
@@ -135,80 +157,129 @@ export function PlotlyParetoPlot({
}
};
// Sort Pareto trials by first objective for line connection
const sortedParetoTrials = useMemo(() => {
const [i] = selectedObjectives;
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
}, [paretoTrials, selectedObjectives]);
// Create Pareto front line trace (2D only)
const createParetoLine = () => {
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
const [i, j] = selectedObjectives;
return {
type: 'scatter' as const,
mode: 'lines' as const,
name: 'Pareto Front',
x: sortedParetoTrials.map(t => getObjValue(t, i)),
y: sortedParetoTrials.map(t => getObjValue(t, j)),
line: {
color: '#10B981',
width: 2,
dash: 'dot'
},
hoverinfo: 'skip' as const,
showlegend: false
};
};
const traces = [
// FEA trials (background, less prominent)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#93C5FD', 'circle', 8, 0.6),
// NN trials (background, less prominent)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#FDBA74', 'cross', 8, 0.5),
// Pareto front (highlighted)
createTrace(paretoTrials, `Pareto (${paretoTrials.length})`, '#10B981', 'diamond', 12, 1.0)
].filter(trace => (trace.x as number[]).length > 0);
// Infeasible trials (background, red X)
...(showInfeasible && infeasibleTrials.length > 0 ? [
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
] : []),
// FEA trials (blue circles)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
// NN trials (purple diamonds)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
// Pareto front line (2D only)
createParetoLine(),
// Pareto front points (highlighted)
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
].filter(trace => trace && (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
// Dark mode color scheme
const colors = {
text: '#E5E7EB',
textMuted: '#9CA3AF',
grid: 'rgba(255,255,255,0.1)',
zeroline: 'rgba(255,255,255,0.2)',
legendBg: 'rgba(30,30,30,0.9)',
legendBorder: 'rgba(255,255,255,0.1)'
};
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
zaxis: {
title: objectives[k]?.name || 'Objective 3',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
bgcolor: 'rgba(0,0,0,0)'
bgcolor: 'transparent'
},
legend: {
x: 1,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' }
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
<div className="flex items-center justify-center h-64 text-dark-400">
No trial data available
</div>
);
@@ -216,20 +287,54 @@ export function PlotlyParetoPlot({
return (
<div className="w-full">
{/* Stats Bar */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-dark-300">Pareto:</span>
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="text-dark-300">FEA:</span>
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="text-dark-300">NN:</span>
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
</div>
{stats.infeasibleCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-dark-300">Infeasible:</span>
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-gray-300">
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1 text-sm ${viewMode === '2d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '2d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1 text-sm ${viewMode === '3d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '3d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
3D
</button>
@@ -239,22 +344,22 @@ export function PlotlyParetoPlot({
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-gray-600">X:</label>
<label className="text-dark-400">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-gray-600 ml-2">Y:</label>
<label className="text-dark-400 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -263,11 +368,11 @@ export function PlotlyParetoPlot({
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-gray-600 ml-2">Z:</label>
<label className="text-dark-400 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -284,7 +389,7 @@ export function PlotlyParetoPlot({
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d'],
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
@@ -295,6 +400,49 @@ export function PlotlyParetoPlot({
}}
style={{ width: '100%' }}
/>
{/* Pareto Front Table for 2D view */}
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
<div className="mt-4 max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{sortedParetoTrials.slice(0, 10).map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, i).toExponential(4)}
</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, j).toExponential(4)}
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
(trial.source || trial.user_attrs?.source) === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source || trial.user_attrs?.source || 'FEA'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{sortedParetoTrials.length > 10 && (
<div className="text-center py-2 text-dark-500 text-xs">
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Run {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface PlotlyRunComparisonProps {
runs: Run[];
height?: number;
}
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
const chartData = useMemo(() => {
if (runs.length === 0) return null;
// Separate FEA and NN runs
const feaRuns = runs.filter(r => r.source === 'FEA');
const nnRuns = runs.filter(r => r.source === 'NN');
// Create bar chart for trial counts
const trialCountData = {
x: runs.map(r => r.name),
y: runs.map(r => r.trial_count),
type: 'bar' as const,
name: 'Trial Count',
marker: {
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
},
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
};
// Create line chart for best values
const bestValueData = {
x: runs.map(r => r.name),
y: runs.map(r => r.best_value),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Best Value',
yaxis: 'y2',
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
};
return { trialCountData, bestValueData, feaRuns, nnRuns };
}, [runs]);
// Calculate statistics
const stats = useMemo(() => {
if (runs.length === 0) return null;
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
// Calculate improvement from first FEA run to overall best
const feaRuns = runs.filter(r => r.source === 'FEA');
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
return {
totalTrials,
feaTrials,
nnTrials,
overallBest,
improvement,
totalRuns: runs.length,
feaRuns: runs.filter(r => r.source === 'FEA').length,
nnRuns: runs.filter(r => r.source === 'NN').length
};
}, [runs]);
if (!chartData || !stats) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No run data available
</div>
);
}
return (
<div className="space-y-4">
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Best Value</div>
<div className="text-xl font-bold text-green-400">
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Improvement</div>
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
{stats.improvement !== null ? (
<>
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
<Minus className="w-4 h-4" />}
{Math.abs(stats.improvement).toFixed(1)}%
</>
) : 'N/A'}
</div>
</div>
</div>
{/* Chart */}
<Plot
data={[chartData.trialCountData, chartData.bestValueData]}
layout={{
height,
margin: { l: 60, r: 60, t: 40, b: 100 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#9ca3af', size: 11 },
showlegend: true,
legend: {
orientation: 'h',
y: 1.12,
x: 0.5,
xanchor: 'center',
bgcolor: 'transparent'
},
xaxis: {
tickangle: -45,
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
tickfont: { size: 10 }
},
yaxis: {
title: { text: 'Trial Count' },
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false
},
yaxis2: {
title: { text: 'Best Value' },
overlaying: 'y',
side: 'right',
gridcolor: 'rgba(75, 85, 99, 0.1)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false,
tickformat: '.2e'
},
bargap: 0.3,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
}}
className="w-full"
useResizeHandler
style={{ width: '100%' }}
/>
{/* Runs Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Calculate duration if times available
let duration = '-';
if (run.first_trial && run.last_trial) {
const start = new Date(run.first_trial);
const end = new Date(run.last_trial);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
duration = `${diffMins}m`;
} else {
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
duration = `${hours}h ${mins}m`;
}
}
return (
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
<td className="py-2 px-3 text-right font-mono text-green-400">
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-right font-mono text-dark-300">
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-dark-400">{duration}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default PlotlyRunComparison;

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
source?: 'FEA' | 'NN' | 'V10_FEA';
user_attrs?: Record<string, any>;
}
interface PlotlySurrogateQualityProps {
trials: TrialData[];
height?: number;
}
export function PlotlySurrogateQuality({
trials,
height = 400
}: PlotlySurrogateQualityProps) {
const { feaTrials, nnTrials, timeline } = useMemo(() => {
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
const nn = trials.filter(t => t.source === 'NN');
// Sort by trial number for timeline
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
// Calculate source distribution over time
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
let feaCount = 0;
let nnCount = 0;
sorted.forEach(t => {
if (t.source === 'NN') nnCount++;
else feaCount++;
timeline.push({
trial: t.trial_number,
feaCount,
nnCount
});
});
return {
feaTrials: fea,
nnTrials: nn,
timeline
};
}, [trials]);
if (nnTrials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No neural network evaluations in this study</p>
</div>
);
}
// Objective distribution by source
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
return (
<div className="space-y-6">
{/* Source Distribution Over Time */}
<Plot
data={[
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.feaCount),
type: 'scatter',
mode: 'lines',
name: 'FEA Cumulative',
line: { color: '#3b82f6', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(59, 130, 246, 0.2)'
},
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.nnCount),
type: 'scatter',
mode: 'lines',
name: 'NN Cumulative',
line: { color: '#a855f7', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(168, 85, 247, 0.2)'
}
]}
layout={{
title: {
text: 'Evaluation Source Over Time',
font: { color: '#fff', size: 14 }
},
height: height * 0.6,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Cumulative Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* Objective Distribution by Source */}
<Plot
data={[
{
x: feaObjectives,
type: 'histogram',
name: 'FEA',
marker: { color: 'rgba(59, 130, 246, 0.7)' },
opacity: 0.8
} as any,
{
x: nnObjectives,
type: 'histogram',
name: 'NN',
marker: { color: 'rgba(168, 85, 247, 0.7)' },
opacity: 0.8
} as any
]}
layout={{
title: {
text: 'Objective Distribution by Source',
font: { color: '#fff', size: 14 }
},
height: height * 0.5,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Objective Value', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
barmode: 'overlay',
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* FEA vs NN Best Values Comparison */}
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
<div className="text-xl font-mono text-blue-400">
{Math.min(...feaObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {feaObjectives.length} evaluations
</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
<div className="text-xl font-mono text-purple-400">
{Math.min(...nnObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {nnObjectives.length} predictions
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { Activity, Clock, Cpu, Zap, CheckCircle } from 'lucide-react';
interface CurrentTrialProps {
studyId: string | null;
totalTrials: number;
completedTrials: number;
isRunning: boolean;
lastTrialTime?: number; // ms for last trial
}
type TrialPhase = 'idle' | 'sampling' | 'evaluating' | 'extracting' | 'complete';
export function CurrentTrialPanel({
studyId,
totalTrials,
completedTrials,
isRunning,
lastTrialTime
}: CurrentTrialProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [phase, setPhase] = useState<TrialPhase>('idle');
// Simulate phase progression when running
useEffect(() => {
if (!isRunning) {
setPhase('idle');
setElapsedTime(0);
return;
}
setPhase('sampling');
const interval = setInterval(() => {
setElapsedTime(prev => {
const newTime = prev + 1;
// Simulate phase transitions based on typical timing
if (newTime < 2) setPhase('sampling');
else if (newTime < 5) setPhase('evaluating');
else setPhase('extracting');
return newTime;
});
}, 1000);
return () => clearInterval(interval);
}, [isRunning, completedTrials]);
// Reset elapsed time when a new trial completes
useEffect(() => {
if (isRunning) {
setElapsedTime(0);
setPhase('sampling');
}
}, [completedTrials, isRunning]);
// Calculate ETA
const calculateETA = () => {
if (!isRunning || completedTrials === 0 || !lastTrialTime) return null;
const remainingTrials = totalTrials - completedTrials;
const avgTimePerTrial = lastTrialTime / 1000; // convert to seconds
const etaSeconds = remainingTrials * avgTimePerTrial;
if (etaSeconds < 60) return `~${Math.round(etaSeconds)}s`;
if (etaSeconds < 3600) return `~${Math.round(etaSeconds / 60)}m`;
return `~${(etaSeconds / 3600).toFixed(1)}h`;
};
const progressPercent = totalTrials > 0 ? (completedTrials / totalTrials) * 100 : 0;
const eta = calculateETA();
const getPhaseInfo = () => {
switch (phase) {
case 'sampling':
return { label: 'Sampling', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Zap };
case 'evaluating':
return { label: 'FEA Solving', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: Cpu };
case 'extracting':
return { label: 'Extracting', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: Activity };
case 'complete':
return { label: 'Complete', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: CheckCircle };
default:
return { label: 'Idle', color: 'text-dark-400', bgColor: 'bg-dark-600', icon: Clock };
}
};
const phaseInfo = getPhaseInfo();
const PhaseIcon = phaseInfo.icon;
if (!studyId) return null;
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className={`w-5 h-5 ${isRunning ? 'text-green-400 animate-pulse' : 'text-dark-400'}`} />
<span className="font-semibold text-white">
{isRunning ? `Trial #${completedTrials + 1}` : 'Optimization Status'}
</span>
</div>
{isRunning && (
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${phaseInfo.bgColor} ${phaseInfo.color}`}>
<PhaseIcon className="w-3 h-3" />
{phaseInfo.label}
</span>
)}
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-dark-400">Progress</span>
<span className="text-white font-medium">
{completedTrials} / {totalTrials} trials
</span>
</div>
<div className="h-2 bg-dark-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isRunning ? 'bg-gradient-to-r from-primary-600 to-primary-400' : 'bg-primary-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-3">
{/* Elapsed Time */}
<div className="text-center">
<div className={`text-lg font-mono ${isRunning ? 'text-white' : 'text-dark-400'}`}>
{isRunning ? `${elapsedTime}s` : '--'}
</div>
<div className="text-xs text-dark-400">Elapsed</div>
</div>
{/* Completion */}
<div className="text-center border-x border-dark-600">
<div className="text-lg font-mono text-primary-400">
{progressPercent.toFixed(1)}%
</div>
<div className="text-xs text-dark-400">Complete</div>
</div>
{/* ETA */}
<div className="text-center">
<div className={`text-lg font-mono ${eta ? 'text-blue-400' : 'text-dark-400'}`}>
{eta || '--'}
</div>
<div className="text-xs text-dark-400">ETA</div>
</div>
</div>
{/* Running indicator */}
{isRunning && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-green-400">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Optimization in progress...
</div>
</div>
)}
{/* Paused/Stopped indicator */}
{!isRunning && completedTrials > 0 && completedTrials < totalTrials && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-orange-400">
<span className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization paused
</div>
</div>
)}
{/* Completed indicator */}
{!isRunning && completedTrials >= totalTrials && totalTrials > 0 && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-blue-400">
<CheckCircle className="w-3 h-3" />
Optimization complete
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Cpu, Layers, Target, TrendingUp, Database, Brain } from 'lucide-react';
interface OptimizerStatePanelProps {
sampler?: string;
nTrials: number;
completedTrials: number;
feaTrials?: number;
nnTrials?: number;
objectives?: Array<{ name: string; direction: string }>;
isMultiObjective: boolean;
paretoSize?: number;
}
export function OptimizerStatePanel({
sampler = 'TPESampler',
nTrials,
completedTrials,
feaTrials = 0,
nnTrials = 0,
objectives = [],
isMultiObjective,
paretoSize = 0
}: OptimizerStatePanelProps) {
// Determine optimizer phase based on progress
const getPhase = () => {
if (completedTrials === 0) return 'Initializing';
if (completedTrials < 10) return 'Exploration';
if (completedTrials < nTrials * 0.5) return 'Exploitation';
if (completedTrials < nTrials * 0.9) return 'Refinement';
return 'Convergence';
};
const phase = getPhase();
// Format sampler name for display
const formatSampler = (s: string) => {
const samplers: Record<string, string> = {
'TPESampler': 'TPE (Bayesian)',
'NSGAIISampler': 'NSGA-II',
'NSGAIIISampler': 'NSGA-III',
'CmaEsSampler': 'CMA-ES',
'RandomSampler': 'Random',
'GridSampler': 'Grid',
'QMCSampler': 'Quasi-Monte Carlo'
};
return samplers[s] || s;
};
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<Cpu className="w-5 h-5 text-primary-400" />
<span className="font-semibold text-white">Optimizer State</span>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* Sampler */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Sampler</span>
</div>
<div className="text-sm font-medium text-white truncate" title={sampler}>
{formatSampler(sampler)}
</div>
</div>
{/* Phase */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Phase</span>
</div>
<div className={`text-sm font-medium ${
phase === 'Convergence' ? 'text-green-400' :
phase === 'Refinement' ? 'text-blue-400' :
phase === 'Exploitation' ? 'text-yellow-400' :
'text-primary-400'
}`}>
{phase}
</div>
</div>
</div>
{/* FEA vs NN Trials (for hybrid optimizations) */}
{(feaTrials > 0 || nnTrials > 0) && (
<div className="mb-4">
<div className="text-xs text-dark-400 uppercase mb-2">Trial Sources</div>
<div className="flex gap-2">
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Database className="w-4 h-4 text-blue-400 mx-auto mb-1" />
<div className="text-lg font-bold text-blue-400">{feaTrials}</div>
<div className="text-xs text-dark-400">FEA</div>
</div>
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Brain className="w-4 h-4 text-purple-400 mx-auto mb-1" />
<div className="text-lg font-bold text-purple-400">{nnTrials}</div>
<div className="text-xs text-dark-400">Neural Net</div>
</div>
</div>
{nnTrials > 0 && (
<div className="mt-2 text-xs text-dark-400 text-center">
{((nnTrials / (feaTrials + nnTrials)) * 100).toFixed(0)}% acceleration from surrogate
</div>
)}
</div>
)}
{/* Objectives */}
{objectives.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">
{isMultiObjective ? 'Multi-Objective' : 'Single Objective'}
</span>
</div>
<div className="space-y-1">
{objectives.slice(0, 3).map((obj, idx) => (
<div
key={idx}
className="flex items-center justify-between text-sm bg-dark-700 rounded px-2 py-1"
>
<span className="text-dark-300 truncate" title={obj.name}>
{obj.name.length > 20 ? obj.name.slice(0, 18) + '...' : obj.name}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
obj.direction === 'minimize' ? 'bg-green-900/50 text-green-400' : 'bg-blue-900/50 text-blue-400'
}`}>
{obj.direction === 'minimize' ? 'min' : 'max'}
</span>
</div>
))}
{objectives.length > 3 && (
<div className="text-xs text-dark-500 text-center">
+{objectives.length - 3} more
</div>
)}
</div>
</div>
)}
{/* Pareto Front Size (for multi-objective) */}
{isMultiObjective && paretoSize > 0 && (
<div className="pt-3 border-t border-dark-600">
<div className="flex items-center justify-between">
<span className="text-xs text-dark-400">Pareto Front Size</span>
<span className="text-sm font-medium text-primary-400">
{paretoSize} solutions
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentTrialPanel } from './CurrentTrialPanel';
export { OptimizerStatePanel } from './OptimizerStatePanel';

View File

@@ -8,6 +8,7 @@ interface StudyContextType {
studies: Study[];
refreshStudies: () => Promise<void>;
isLoading: boolean;
isInitialized: boolean; // True once initial load + localStorage restoration is complete
clearStudy: () => void;
}
@@ -17,6 +18,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const [selectedStudy, setSelectedStudyState] = useState<Study | null>(null);
const [studies, setStudies] = useState<Study[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const refreshStudies = async () => {
try {
@@ -55,16 +57,23 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
// Initial load
useEffect(() => {
const init = async () => {
await refreshStudies();
// Restore last selected study
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
try {
const response = await apiClient.getStudies();
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
setStudies(response.studies);
// Restore last selected study from localStorage
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
}
}
} catch (error) {
console.error('Failed to initialize studies:', error);
} finally {
setIsLoading(false);
setIsInitialized(true); // Mark as initialized AFTER localStorage restoration
}
};
init();
@@ -77,6 +86,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
studies,
refreshStudies,
isLoading,
isInitialized,
clearStudy
}}>
{children}

View File

@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react';
interface NotificationOptions {
title: string;
body: string;
icon?: string;
tag?: string;
requireInteraction?: boolean;
}
interface UseNotificationsReturn {
permission: NotificationPermission | 'unsupported';
requestPermission: () => Promise<boolean>;
showNotification: (options: NotificationOptions) => void;
isEnabled: boolean;
setEnabled: (enabled: boolean) => void;
}
const STORAGE_KEY = 'atomizer-notifications-enabled';
export function useNotifications(): UseNotificationsReturn {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
);
const [isEnabled, setIsEnabledState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'true';
});
// Update permission state when it changes
useEffect(() => {
if (typeof Notification === 'undefined') {
setPermission('unsupported');
return;
}
setPermission(Notification.permission);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported in this browser');
return false;
}
if (Notification.permission === 'granted') {
setPermission('granted');
return true;
}
if (Notification.permission === 'denied') {
setPermission('denied');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}, []);
const setEnabled = useCallback((enabled: boolean) => {
setIsEnabledState(enabled);
localStorage.setItem(STORAGE_KEY, enabled.toString());
}, []);
const showNotification = useCallback((options: NotificationOptions) => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported');
return;
}
if (!isEnabled) {
return;
}
if (Notification.permission !== 'granted') {
console.warn('Notification permission not granted');
return;
}
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || '/favicon.ico',
tag: options.tag,
requireInteraction: options.requireInteraction || false,
silent: false
});
// Auto close after 5 seconds unless requireInteraction is true
if (!options.requireInteraction) {
setTimeout(() => notification.close(), 5000);
}
// Focus window on click
notification.onclick = () => {
window.focus();
notification.close();
};
} catch (error) {
console.error('Error showing notification:', error);
}
}, [isEnabled]);
return {
permission,
requestPermission,
showNotification,
isEnabled,
setEnabled
};
}
// Notification types for optimization events
export interface OptimizationNotification {
type: 'new_best' | 'completed' | 'error' | 'milestone';
studyName: string;
message: string;
value?: number;
improvement?: number;
}
export function formatOptimizationNotification(notification: OptimizationNotification): NotificationOptions {
switch (notification.type) {
case 'new_best':
return {
title: `New Best Found - ${notification.studyName}`,
body: notification.improvement
? `${notification.message} (${notification.improvement.toFixed(1)}% improvement)`
: notification.message,
tag: `best-${notification.studyName}`,
requireInteraction: false
};
case 'completed':
return {
title: `Optimization Complete - ${notification.studyName}`,
body: notification.message,
tag: `complete-${notification.studyName}`,
requireInteraction: true
};
case 'error':
return {
title: `Error - ${notification.studyName}`,
body: notification.message,
tag: `error-${notification.studyName}`,
requireInteraction: true
};
case 'milestone':
return {
title: `Milestone Reached - ${notification.studyName}`,
body: notification.message,
tag: `milestone-${notification.studyName}`,
requireInteraction: false
};
default:
return {
title: notification.studyName,
body: notification.message
};
}
}
export default useNotifications;

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BarChart3,
TrendingUp,
Grid3X3,
Target,
Filter,
Brain,
RefreshCw,
Download,
Layers,
LucideIcon
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
// Lazy load charts
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
type AnalysisTab = 'overview' | 'parameters' | 'pareto' | 'correlations' | 'constraints' | 'surrogate' | 'runs';
interface RunData {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface ObjectiveData {
name: string;
direction: 'minimize' | 'maximize';
}
interface StudyMetadata {
objectives?: ObjectiveData[];
design_variables?: Array<{ name: string; min?: number; max?: number }>;
sampler?: string;
description?: string;
}
export default function Analysis() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [activeTab, setActiveTab] = useState<AnalysisTab>('overview');
const [loading, setLoading] = useState(true);
const [trials, setTrials] = useState<TrialData[]>([]);
const [metadata, setMetadata] = useState<StudyMetadata | null>(null);
const [paretoFront, setParetoFront] = useState<any[]>([]);
const [runs, setRuns] = useState<RunData[]>([]);
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study data
useEffect(() => {
if (!selectedStudy) return;
const loadData = async () => {
setLoading(true);
try {
// Load trial history
const historyRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/history?limit=500`);
const historyData = await historyRes.json();
const trialsData = historyData.trials.map((t: any) => {
let values: number[] = [];
if (t.objectives && Array.isArray(t.objectives)) {
values = t.objectives;
} else if (t.objective !== null && t.objective !== undefined) {
values = [t.objective];
}
const rawSource = t.source || t.user_attrs?.source || 'FEA';
const source: 'FEA' | 'NN' | 'V10_FEA' = rawSource === 'NN' ? 'NN' : rawSource === 'V10_FEA' ? 'V10_FEA' : 'FEA';
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source
};
});
setTrials(trialsData);
// Load metadata
const metadataRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/metadata`);
const metadataData = await metadataRes.json();
setMetadata(metadataData);
// Load Pareto front
const paretoRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/pareto-front`);
const paretoData = await paretoRes.json();
if (paretoData.is_multi_objective && paretoData.pareto_front) {
setParetoFront(paretoData.pareto_front);
}
// Load runs data for comparison
const runsRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/runs`);
const runsData = await runsRes.json();
if (runsData.runs) {
setRuns(runsData.runs);
}
} catch (err) {
console.error('Failed to load analysis data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedStudy]);
// Calculate statistics
const stats = useMemo(() => {
if (trials.length === 0) return null;
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
if (objectives.length === 0) return null;
const sorted = [...objectives].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const mean = objectives.reduce((a, b) => a + b, 0) / objectives.length;
const median = sorted[Math.floor(sorted.length / 2)];
const stdDev = Math.sqrt(objectives.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / objectives.length);
const p25 = sorted[Math.floor(sorted.length * 0.25)];
const p75 = sorted[Math.floor(sorted.length * 0.75)];
const p90 = sorted[Math.floor(sorted.length * 0.90)];
const feaTrials = trials.filter(t => t.source === 'FEA').length;
const nnTrials = trials.filter(t => t.source === 'NN').length;
const feasible = trials.filter(t => t.constraint_satisfied).length;
return {
min,
max,
mean,
median,
stdDev,
p25,
p75,
p90,
feaTrials,
nnTrials,
feasible,
total: trials.length,
feasibilityRate: (feasible / trials.length) * 100
};
}, [trials]);
// Tabs configuration
const tabs: { id: AnalysisTab; label: string; icon: LucideIcon; disabled?: boolean }[] = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'parameters', label: 'Parameters', icon: TrendingUp },
{ id: 'pareto', label: 'Pareto', icon: Target, disabled: (metadata?.objectives?.length || 0) <= 1 },
{ id: 'correlations', label: 'Correlations', icon: Grid3X3 },
{ id: 'constraints', label: 'Constraints', icon: Filter },
{ id: 'surrogate', label: 'Surrogate', icon: Brain, disabled: trials.filter(t => t.source === 'NN').length === 0 },
{ id: 'runs', label: 'Runs', icon: Layers, disabled: runs.length <= 1 },
];
// Export data
const handleExportCSV = () => {
if (trials.length === 0) return;
const paramNames = Object.keys(trials[0].params);
const headers = ['trial', 'objective', ...paramNames, 'source', 'feasible'].join(',');
const rows = trials.map(t => [
t.trial_number,
t.values[0],
...paramNames.map(p => t.params[p]),
t.source,
t.constraint_satisfied
].join(','));
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id}_analysis.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (!isInitialized || !selectedStudy) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading...</p>
</div>
</div>
);
}
const isMultiObjective = (metadata?.objectives?.length || 0) > 1;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-primary-400">Analysis</h1>
<p className="text-dark-400 text-sm">Deep analysis for {selectedStudy.name || selectedStudy.id}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
disabled={trials.length === 0}
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</header>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-dark-600 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: tab.disabled
? 'text-dark-600 cursor-not-allowed'
: 'text-dark-400 hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
<div className="text-2xl font-bold text-green-400">{stats.min.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Mean</div>
<div className="text-2xl font-bold text-white">{stats.mean.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Median</div>
<div className="text-2xl font-bold text-white">{stats.median.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Std Dev</div>
<div className="text-2xl font-bold text-white">{stats.stdDev.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
)}
{/* Percentile Distribution */}
{stats && (
<Card title="Objective Distribution">
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">Min</div>
<div className="text-lg font-mono text-green-400">{stats.min.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">25th %</div>
<div className="text-lg font-mono text-white">{stats.p25.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">75th %</div>
<div className="text-lg font-mono text-white">{stats.p75.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">90th %</div>
<div className="text-lg font-mono text-white">{stats.p90.toExponential(3)}</div>
</div>
</div>
</Card>
)}
{/* Convergence Plot */}
{trials.length > 0 && (
<Card title="Convergence Plot">
<Suspense fallback={<ChartLoading />}>
<PlotlyConvergencePlot
trials={trials}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={350}
/>
</Suspense>
</Card>
)}
{/* Best Trials Table */}
<Card title="Top 10 Best Trials">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Rank</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<th key={p} className="text-left py-2 px-3 text-dark-400 font-medium">{p}</th>
))}
</tr>
</thead>
<tbody>
{[...trials]
.sort((a, b) => (a.values[0] ?? Infinity) - (b.values[0] ?? Infinity))
.slice(0, 10)
.map((trial, idx) => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3">
<span className={`inline-flex w-6 h-6 items-center justify-center rounded-full text-xs font-bold ${
idx === 0 ? 'bg-yellow-500/20 text-yellow-400' :
idx === 1 ? 'bg-gray-400/20 text-gray-300' :
idx === 2 ? 'bg-orange-700/20 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{idx + 1}
</span>
</td>
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">{trial.values[0]?.toExponential(4)}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<td key={p} className="py-2 px-3 font-mono text-dark-300">
{trial.params[p]?.toFixed(4)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Parameters Tab */}
{activeTab === 'parameters' && (
<div className="space-y-6">
{/* Parameter Importance */}
{trials.length > 0 && metadata?.design_variables && (
<Card title="Parameter Importance">
<Suspense fallback={<ChartLoading />}>
<PlotlyParameterImportance
trials={trials}
designVariables={metadata.design_variables}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={400}
/>
</Suspense>
</Card>
)}
{/* Parallel Coordinates */}
{trials.length > 0 && metadata && (
<Card title="Parallel Coordinates">
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={trials}
objectives={metadata.objectives || []}
designVariables={metadata.design_variables || []}
paretoFront={paretoFront}
height={450}
/>
</Suspense>
</Card>
)}
</div>
)}
{/* Pareto Tab */}
{activeTab === 'pareto' && isMultiObjective && (
<div className="space-y-6">
{/* Pareto Metrics */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Pareto Solutions</div>
<div className="text-2xl font-bold text-primary-400">{paretoFront.length}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Objectives</div>
<div className="text-2xl font-bold text-white">{metadata?.objectives?.length || 0}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Dominated Ratio</div>
<div className="text-2xl font-bold text-white">
{trials.length > 0 ? ((1 - paretoFront.length / trials.length) * 100).toFixed(1) : 0}%
</div>
</Card>
</div>
{/* Pareto Front Plot */}
{paretoFront.length > 0 && (
<Card title="Pareto Front">
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot
trials={trials}
paretoFront={paretoFront}
objectives={metadata?.objectives || []}
height={500}
/>
</Suspense>
</Card>
)}
{/* Pareto Solutions Table */}
<Card title="Pareto-Optimal Solutions">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
{metadata?.objectives?.map(obj => (
<th key={obj.name} className="text-left py-2 px-3 text-dark-400 font-medium">{obj.name}</th>
))}
</tr>
</thead>
<tbody>
{paretoFront.slice(0, 20).map((sol, idx) => (
<tr key={idx} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{sol.trial_number}</td>
{sol.values?.map((v: number, i: number) => (
<td key={i} className="py-2 px-3 font-mono text-primary-400">{v?.toExponential(4)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Correlations Tab */}
{activeTab === 'correlations' && (
<div className="space-y-6">
{/* Correlation Heatmap */}
{trials.length > 2 && (
<Card title="Parameter-Objective Correlation Matrix">
<Suspense fallback={<ChartLoading />}>
<PlotlyCorrelationHeatmap
trials={trials}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
/>
</Suspense>
</Card>
)}
{/* Correlation Interpretation Guide */}
<Card title="Interpreting Correlations">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="p-3 bg-blue-500/10 rounded-lg border border-blue-500/30">
<div className="text-blue-400 font-semibold mb-1">Strong Positive (0.7 to 1.0)</div>
<p className="text-dark-400 text-xs">Increasing parameter increases objective</p>
</div>
<div className="p-3 bg-blue-500/5 rounded-lg border border-blue-500/20">
<div className="text-blue-300 font-semibold mb-1">Moderate Positive (0.3 to 0.7)</div>
<p className="text-dark-400 text-xs">Some positive relationship</p>
</div>
<div className="p-3 bg-red-500/5 rounded-lg border border-red-500/20">
<div className="text-red-300 font-semibold mb-1">Moderate Negative (-0.7 to -0.3)</div>
<p className="text-dark-400 text-xs">Some negative relationship</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<div className="text-red-400 font-semibold mb-1">Strong Negative (-1.0 to -0.7)</div>
<p className="text-dark-400 text-xs">Increasing parameter decreases objective</p>
</div>
</div>
</Card>
{/* Top Correlations Table */}
{trials.length > 2 && (
<Card title="Strongest Parameter Correlations with Objective">
<CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
</Card>
)}
</div>
)}
{/* Constraints Tab */}
{activeTab === 'constraints' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasible Trials</div>
<div className="text-2xl font-bold text-green-400">{stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Infeasible Trials</div>
<div className="text-2xl font-bold text-red-400">{stats.total - stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility Rate</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
{/* Feasibility Over Time Chart */}
<Card title="Feasibility Rate Over Time">
<Suspense fallback={<ChartLoading />}>
<PlotlyFeasibilityChart trials={trials} height={350} />
</Suspense>
</Card>
{/* Infeasible Trials List */}
{stats.total - stats.feasible > 0 && (
<Card title="Recent Infeasible Trials">
<div className="overflow-x-auto max-h-64">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{trials
.filter(t => !t.constraint_satisfied)
.slice(-20)
.reverse()
.map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-red-400">{trial.values[0]?.toExponential(4) || 'N/A'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)}
{/* Surrogate Tab */}
{activeTab === 'surrogate' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">FEA Evaluations</div>
<div className="text-2xl font-bold text-blue-400">{stats.feaTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Predictions</div>
<div className="text-2xl font-bold text-purple-400">{stats.nnTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Ratio</div>
<div className="text-2xl font-bold text-green-400">
{stats.nnTrials > 0 ? `${((stats.nnTrials / stats.total) * 100).toFixed(0)}%` : '0%'}
</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Speedup Factor</div>
<div className="text-2xl font-bold text-primary-400">
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
</div>
</Card>
</div>
{/* Surrogate Quality Charts */}
<Card title="Surrogate Model Analysis">
<Suspense fallback={<ChartLoading />}>
<PlotlySurrogateQuality trials={trials} height={400} />
</Suspense>
</Card>
</div>
)}
{/* Runs Tab */}
{activeTab === 'runs' && runs.length > 0 && (
<div className="space-y-6">
<Card title="Optimization Runs Comparison">
<p className="text-dark-400 text-sm mb-4">
Compare different optimization runs within this study. Studies with adaptive optimization
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
</p>
<Suspense fallback={<ChartLoading />}>
<PlotlyRunComparison runs={runs} height={400} />
</Suspense>
</Card>
</div>
)}
</>
)}
</div>
);
}
// Helper component for correlation table
function CorrelationTable({ trials, objectiveName }: { trials: TrialData[]; objectiveName: string }) {
const correlations = useMemo(() => {
if (trials.length < 3) return [];
const paramNames = Object.keys(trials[0].params);
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const results: { param: string; correlation: number; absCorr: number }[] = [];
paramNames.forEach(param => {
const paramValues = trials.map(t => t.params[param]).filter(v => v !== undefined && !isNaN(v));
const minLen = Math.min(paramValues.length, objectives.length);
if (minLen < 3) return;
// Calculate Pearson correlation
const x = paramValues.slice(0, minLen);
const y = objectives.slice(0, minLen);
const n = x.length;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
const corr = denominator === 0 ? 0 : numerator / denominator;
results.push({ param, correlation: corr, absCorr: Math.abs(corr) });
});
return results.sort((a, b) => b.absCorr - a.absCorr);
}, [trials]);
if (correlations.length === 0) {
return <p className="text-dark-400 text-center py-4">Not enough data for correlation analysis</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Parameter</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Correlation with {objectiveName}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Strength</th>
</tr>
</thead>
<tbody>
{correlations.slice(0, 10).map(({ param, correlation, absCorr }) => (
<tr key={param} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">{param}</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${correlation > 0 ? 'bg-blue-500' : 'bg-red-500'}`}
style={{ width: `${absCorr * 100}%`, marginLeft: correlation < 0 ? 'auto' : 0 }}
/>
</div>
<span className={`font-mono ${
absCorr > 0.7 ? 'text-white font-bold' :
absCorr > 0.3 ? 'text-dark-200' : 'text-dark-400'
}`}>
{correlation > 0 ? '+' : ''}{correlation.toFixed(3)}
</span>
</div>
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
absCorr > 0.7 ? 'bg-primary-500/20 text-primary-400' :
absCorr > 0.3 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-dark-600 text-dark-400'
}`}>
{absCorr > 0.7 ? 'Strong' : absCorr > 0.3 ? 'Moderate' : 'Weak'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { Terminal, Settings } from 'lucide-react';
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
import { useNotifications, formatOptimizationNotification } from '../hooks/useNotifications';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
import { Card } from '../components/common/Card';
import { ControlPanel } from '../components/dashboard/ControlPanel';
import { NotificationSettings } from '../components/NotificationSettings';
import { ConfigEditor } from '../components/ConfigEditor';
import { ParetoPlot } from '../components/ParetoPlot';
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
@@ -14,6 +17,7 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import { ConsoleOutput } from '../components/ConsoleOutput';
import { ExpandableChart } from '../components/ExpandableChart';
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
@@ -31,16 +35,10 @@ const ChartLoading = () => (
export default function Dashboard() {
const navigate = useNavigate();
const { selectedStudy, refreshStudies } = useStudy();
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
const selectedStudyId = selectedStudy?.id || null;
// Redirect to home if no study selected
useEffect(() => {
if (!selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
// All hooks must be declared before any conditional returns
const [allTrials, setAllTrials] = useState<Trial[]>([]);
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
const [bestValue, setBestValue] = useState<number>(Infinity);
@@ -52,9 +50,9 @@ export default function Dashboard() {
const [trialsPage, setTrialsPage] = useState(0);
const trialsPerPage = 50; // Limit trials per page for performance
// Parameter Space axis selection
const [paramXIndex, setParamXIndex] = useState(0);
const [paramYIndex, setParamYIndex] = useState(1);
// Parameter Space axis selection (reserved for future use)
const [_paramXIndex, _setParamXIndex] = useState(0);
const [_paramYIndex, _setParamYIndex] = useState(1);
// Protocol 13: New state for metadata and Pareto front
const [studyMetadata, setStudyMetadata] = useState<any>(null);
@@ -64,9 +62,27 @@ export default function Dashboard() {
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
// Process status for tracker panels
const [isRunning, setIsRunning] = useState(false);
const [lastTrialTime, _setLastTrialTime] = useState<number | undefined>(undefined);
// Config editor modal
const [showConfigEditor, setShowConfigEditor] = useState(false);
// Claude terminal from global context
const { isOpen: claudeTerminalOpen, setIsOpen: setClaudeTerminalOpen, isConnected: claudeConnected } = useClaudeTerminal();
// Desktop notifications
const { showNotification } = useNotifications();
const previousBestRef = useRef<number>(Infinity);
// Redirect to home if no study selected (but only after initialization completes)
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
const showAlert = (type: 'success' | 'warning', message: string) => {
const id = alertIdCounter;
setAlertIdCounter(prev => prev + 1);
@@ -84,8 +100,22 @@ export default function Dashboard() {
const trial = msg.data as Trial;
setAllTrials(prev => [...prev, trial]);
if (trial.objective !== null && trial.objective !== undefined && trial.objective < bestValue) {
const improvement = previousBestRef.current !== Infinity
? ((previousBestRef.current - trial.objective) / Math.abs(previousBestRef.current)) * 100
: 0;
setBestValue(trial.objective);
previousBestRef.current = trial.objective;
showAlert('success', `New best: ${trial.objective.toFixed(4)} (Trial #${trial.trial_number})`);
// Desktop notification for new best
showNotification(formatOptimizationNotification({
type: 'new_best',
studyName: selectedStudy?.name || selectedStudyId || 'Study',
message: `Best value: ${trial.objective.toExponential(4)}`,
value: trial.objective,
improvement
}));
}
} else if (msg.type === 'trial_pruned') {
setPrunedCount(prev => prev + 1);
@@ -162,9 +192,31 @@ export default function Dashboard() {
}
})
.catch(err => console.error('Failed to load Pareto front:', err));
// Check process status
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(err => console.error('Failed to load process status:', err));
}
}, [selectedStudyId]);
// Poll process status periodically
useEffect(() => {
if (!selectedStudyId) return;
const pollStatus = setInterval(() => {
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(() => {});
}, 5000);
return () => clearInterval(pollStatus);
}, [selectedStudyId]);
// Sort trials based on selected sort order
useEffect(() => {
let sorted = [...allTrials];
@@ -223,50 +275,19 @@ export default function Dashboard() {
return () => clearInterval(refreshInterval);
}, [selectedStudyId]);
// Sample data for charts when there are too many trials (performance optimization)
const MAX_CHART_POINTS = 200; // Reduced for better performance
const sampleData = <T,>(data: T[], maxPoints: number): T[] => {
if (data.length <= maxPoints) return data;
const step = Math.ceil(data.length / maxPoints);
return data.filter((_, i) => i % step === 0 || i === data.length - 1);
};
// Show loading state while initializing (restoring study from localStorage)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
// Prepare chart data with proper null/undefined handling
const allValidTrials = allTrials
.filter(t => t.objective !== null && t.objective !== undefined)
.sort((a, b) => a.trial_number - b.trial_number);
// Calculate best_so_far for each trial
let runningBest = Infinity;
const convergenceDataFull: ConvergenceDataPoint[] = allValidTrials.map(trial => {
if (trial.objective < runningBest) {
runningBest = trial.objective;
}
return {
trial_number: trial.trial_number,
objective: trial.objective,
best_so_far: runningBest,
};
});
// Sample for chart rendering performance
const convergenceData = sampleData(convergenceDataFull, MAX_CHART_POINTS);
const parameterSpaceDataFull: ParameterSpaceDataPoint[] = allTrials
.filter(t => t.objective !== null && t.objective !== undefined && t.design_variables)
.map(trial => {
const params = Object.values(trial.design_variables);
return {
trial_number: trial.trial_number,
x: params[paramXIndex] || 0,
y: params[paramYIndex] || 0,
objective: trial.objective,
isBest: trial.objective === bestValue,
};
});
// Sample for chart rendering performance
const parameterSpaceData = sampleData(parameterSpaceDataFull, MAX_CHART_POINTS);
// Note: Chart data sampling is handled by individual chart components
// Calculate average objective
const validObjectives = allTrials.filter(t => t.objective !== null && t.objective !== undefined).map(t => t.objective);
@@ -350,6 +371,21 @@ export default function Dashboard() {
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
</div>
<div className="flex gap-2">
{/* Config Editor Button */}
{selectedStudyId && (
<button
onClick={() => setShowConfigEditor(true)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs bg-dark-700 text-dark-400 hover:bg-dark-600 hover:text-white transition-colors"
title="Edit study configuration"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Config</span>
</button>
)}
{/* Notification Toggle */}
<NotificationSettings compact />
{/* Claude Code Terminal Toggle Button */}
<button
onClick={() => setClaudeTerminalOpen(!claudeTerminalOpen)}
@@ -421,6 +457,27 @@ export default function Dashboard() {
<ControlPanel onStatusChange={refreshStudies} horizontal />
</div>
{/* Tracker Panels - Current Trial and Optimizer State */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<CurrentTrialPanel
studyId={selectedStudyId}
totalTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
isRunning={isRunning}
lastTrialTime={lastTrialTime}
/>
<OptimizerStatePanel
sampler={studyMetadata?.sampler}
nTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
feaTrials={allTrialsRaw.filter(t => t.source === 'FEA').length}
nnTrials={allTrialsRaw.filter(t => t.source === 'NN').length}
objectives={studyMetadata?.objectives || []}
isMultiObjective={(studyMetadata?.objectives?.length || 0) > 1}
paretoSize={paretoFront.length}
/>
</div>
{/* Main Layout: Charts (Claude Terminal is now global/floating) */}
<div className="grid gap-4 grid-cols-1">
{/* Main Content - Charts stacked vertically */}
@@ -795,6 +852,15 @@ export default function Dashboard() {
</div>
</main>
</div>
{/* Config Editor Modal */}
{showConfigEditor && selectedStudyId && (
<ConfigEditor
studyId={selectedStudyId}
onClose={() => setShowConfigEditor(false)}
onSaved={() => refreshStudies()}
/>
)}
</div>
);
}

View File

@@ -1,38 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FolderOpen,
Play,
Pause,
CheckCircle,
Clock,
AlertCircle,
ArrowRight,
RefreshCw,
Zap,
FileText,
ChevronDown,
ChevronUp,
Target,
Activity
Activity,
BarChart3,
TrendingUp,
ArrowRight
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Study } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { apiClient } from '../api/client';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
const Home: React.FC = () => {
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
const [selectedPreview, setSelectedPreview] = useState<Study | null>(null);
const [readme, setReadme] = useState<string>('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [showAllStudies, setShowAllStudies] = useState(false);
const [sortField, setSortField] = useState<'name' | 'status' | 'trials' | 'bestValue'>('trials');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const navigate = useNavigate();
// Load README when a study is selected for preview
@@ -49,7 +44,7 @@ const Home: React.FC = () => {
try {
const response = await apiClient.getStudyReadme(studyId);
setReadme(response.content || 'No README found for this study.');
} catch (error) {
} catch {
setReadme('No README found for this study.');
} finally {
setReadmeLoading(false);
@@ -61,78 +56,88 @@ const Home: React.FC = () => {
navigate('/dashboard');
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('desc');
}
};
// Sort studies
const sortedStudies = useMemo(() => {
return [...studies].sort((a, b) => {
let aVal: any, bVal: any;
switch (sortField) {
case 'name':
aVal = (a.name || a.id).toLowerCase();
bVal = (b.name || b.id).toLowerCase();
break;
case 'trials':
aVal = a.progress.current;
bVal = b.progress.current;
break;
case 'bestValue':
aVal = a.best_value ?? Infinity;
bVal = b.best_value ?? Infinity;
break;
case 'status':
const statusOrder = { running: 0, paused: 1, completed: 2, not_started: 3 };
aVal = statusOrder[a.status as keyof typeof statusOrder] ?? 4;
bVal = statusOrder[b.status as keyof typeof statusOrder] ?? 4;
break;
default:
return 0;
}
if (sortDir === 'asc') {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
} else {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
});
}, [studies, sortField, sortDir]);
// Aggregate stats
const aggregateStats = useMemo(() => {
const totalStudies = studies.length;
const runningStudies = studies.filter(s => s.status === 'running').length;
const completedStudies = studies.filter(s => s.status === 'completed').length;
const totalTrials = studies.reduce((sum, s) => sum + s.progress.current, 0);
const studiesWithValues = studies.filter(s => s.best_value !== null);
const bestOverall = studiesWithValues.length > 0
? studiesWithValues.reduce((best, curr) =>
(curr.best_value! < best.best_value!) ? curr : best
)
: null;
return { totalStudies, runningStudies, completedStudies, totalTrials, bestOverall };
}, [studies]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-3.5 h-3.5" />;
return <Play className="w-4 h-4 text-green-400" />;
case 'paused':
return <Pause className="w-3.5 h-3.5" />;
return <Pause className="w-4 h-4 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3.5 h-3.5" />;
case 'not_started':
return <Clock className="w-3.5 h-3.5" />;
return <CheckCircle className="w-4 h-4 text-blue-400" />;
default:
return <AlertCircle className="w-3.5 h-3.5" />;
return <Clock className="w-4 h-4 text-dark-400" />;
}
};
const getStatusStyles = (status: string) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return {
badge: 'bg-green-500/20 text-green-400 border-green-500/30',
card: 'border-green-500/30 hover:border-green-500/50',
glow: 'shadow-green-500/10'
};
case 'paused':
return {
badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
card: 'border-orange-500/30 hover:border-orange-500/50',
glow: 'shadow-orange-500/10'
};
case 'completed':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
card: 'border-blue-500/30 hover:border-blue-500/50',
glow: 'shadow-blue-500/10'
};
case 'not_started':
return {
badge: 'bg-dark-600 text-dark-400 border-dark-500',
card: 'border-dark-600 hover:border-dark-500',
glow: ''
};
default:
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
card: 'border-yellow-500/30 hover:border-yellow-500/50',
glow: 'shadow-yellow-500/10'
};
case 'running': return 'text-green-400 bg-green-500/10';
case 'paused': return 'text-orange-400 bg-orange-500/10';
case 'completed': return 'text-blue-400 bg-blue-500/10';
default: return 'text-dark-400 bg-dark-600';
}
};
// Study sort options
const [studySort, setStudySort] = useState<'date' | 'running' | 'trials'>('date');
// Sort studies based on selected sort option
const sortedStudies = [...studies].sort((a, b) => {
if (studySort === 'running') {
// Running first, then by date
if (a.status === 'running' && b.status !== 'running') return -1;
if (b.status === 'running' && a.status !== 'running') return 1;
}
if (studySort === 'trials') {
// By trial count (most trials first)
return b.progress.current - a.progress.current;
}
// Default: sort by date (newest first)
const aDate = a.last_modified || a.created_at || '';
const bDate = b.last_modified || b.created_at || '';
return bDate.localeCompare(aDate);
});
const displayedStudies = showAllStudies ? sortedStudies : sortedStudies.slice(0, 6);
return (
<div className="min-h-screen bg-dark-900">
{/* Header */}
@@ -162,352 +167,254 @@ const Home: React.FC = () => {
</header>
<main className="max-w-[1920px] mx-auto px-6 py-8">
{/* Study Selection Section */}
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary-400" />
Select a Study
</h2>
<div className="flex items-center gap-4">
{/* Sort Controls */}
<div className="flex items-center gap-2">
<span className="text-sm text-dark-400">Sort:</span>
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setStudySort('date')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'date'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Newest
</button>
<button
onClick={() => setStudySort('running')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'running'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Running
</button>
<button
onClick={() => setStudySort('trials')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'trials'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Most Trials
</button>
</div>
</div>
{studies.length > 6 && (
<button
onClick={() => setShowAllStudies(!showAllStudies)}
className="text-sm text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllStudies ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All ({studies.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
{/* Aggregate Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<BarChart3 className="w-4 h-4" />
Total Studies
</div>
<div className="text-3xl font-bold text-white">{aggregateStats.totalStudies}</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-green-400 text-sm mb-2">
<Play className="w-4 h-4" />
Running
</div>
) : studies.length === 0 ? (
<div className="text-center py-12 text-dark-400">
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No studies found. Create a new study to get started.</p>
<div className="text-3xl font-bold text-green-400">{aggregateStats.runningStudies}</div>
</div>
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<Activity className="w-4 h-4" />
Total Trials
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedStudies.map((study) => {
const styles = getStatusStyles(study.status);
const isSelected = selectedPreview?.id === study.id;
<div className="text-3xl font-bold text-white">{aggregateStats.totalTrials.toLocaleString()}</div>
</div>
return (
<div
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`
relative p-4 rounded-xl border cursor-pointer transition-all duration-200
bg-dark-800 hover:bg-dark-750
${styles.card} ${styles.glow}
${isSelected ? 'ring-2 ring-primary-500 border-primary-500' : ''}
`}
>
{/* Status Badge */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 pr-2">
<h3 className="text-white font-medium truncate">{study.name || study.id}</h3>
<p className="text-dark-500 text-xs truncate mt-0.5">{study.id}</p>
</div>
<span className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-full border ${styles.badge}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mb-3">
<div className="flex items-center gap-1.5 text-dark-400">
<Activity className="w-3.5 h-3.5" />
<span>{study.progress.current} trials</span>
</div>
{study.best_value !== null && (
<div className="flex items-center gap-1.5 text-primary-400">
<Target className="w-3.5 h-3.5" />
<span>{study.best_value.toFixed(4)}</span>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
study.status === 'running' ? 'bg-green-500' :
study.status === 'completed' ? 'bg-blue-500' : 'bg-primary-500'
}`}
style={{ width: `${Math.min((study.progress.current / study.progress.total) * 100, 100)}%` }}
/>
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute -bottom-px left-1/2 -translate-x-1/2 w-12 h-1 bg-primary-500 rounded-t-full" />
)}
</div>
);
})}
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-primary-400 text-sm mb-2">
<Target className="w-4 h-4" />
Best Overall
</div>
)}
</section>
{/* Study Documentation Section */}
{selectedPreview && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Documentation Header */}
<div className="bg-dark-800 rounded-t-xl border border-dark-600 border-b-0">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">{selectedPreview.name || selectedPreview.id}</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-5 py-2.5 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30"
>
Open Dashboard
<ArrowRight className="w-4 h-4" />
</button>
<div className="text-2xl font-bold text-primary-400">
{aggregateStats.bestOverall?.best_value !== null && aggregateStats.bestOverall?.best_value !== undefined
? aggregateStats.bestOverall.best_value.toExponential(3)
: 'N/A'}
</div>
{aggregateStats.bestOverall && (
<div className="text-xs text-dark-400 mt-1 truncate">
{aggregateStats.bestOverall.name || aggregateStats.bestOverall.id}
</div>
)}
</div>
</div>
{/* Two-column layout: Table + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Study Table */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary-400" />
Studies
</h2>
<span className="text-sm text-dark-400">{studies.length} studies</span>
</div>
{/* README Content */}
<div className="bg-dark-850 rounded-b-xl border border-dark-600 border-t-0 overflow-hidden">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<div className="p-8 overflow-x-auto">
<article className="markdown-body max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
{isLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
</div>
) : studies.length === 0 ? (
<div className="text-center py-16 text-dark-400">
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No studies found</p>
<p className="text-sm mt-1 text-dark-500">Create a new study to get started</p>
</div>
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-dark-750 z-10">
<tr className="border-b border-dark-600">
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Study Name
{sortField === 'name' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('trials')}
>
<div className="flex items-center gap-1">
Progress
{sortField === 'trials' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('bestValue')}
>
<div className="flex items-center gap-1">
Best
{sortField === 'bestValue' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
</tr>
</thead>
<tbody>
{sortedStudies.map((study) => {
const completionPercent = study.progress.total > 0
? Math.round((study.progress.current / study.progress.total) * 100)
: 0;
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
return (
<tr
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`border-b border-dark-700 hover:bg-dark-750 transition-colors cursor-pointer ${
selectedPreview?.id === study.id ? 'bg-primary-900/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="text-white font-medium truncate max-w-[200px]">
{study.name || study.id}
</span>
{study.name && (
<span className="text-xs text-dark-500 truncate max-w-[200px]">{study.id}</span>
)}
</div>
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{readme}
</ReactMarkdown>
</article>
</div>
)}
</div>
</section>
)}
<td className="py-3 px-4">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(study.status)}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-dark-600 rounded-full overflow-hidden max-w-[80px]">
<div
className={`h-full transition-all ${
completionPercent >= 100 ? 'bg-green-500' :
completionPercent >= 50 ? 'bg-primary-500' :
'bg-yellow-500'
}`}
style={{ width: `${Math.min(completionPercent, 100)}%` }}
/>
</div>
<span className="text-dark-400 text-sm font-mono w-16">
{study.progress.current}/{study.progress.total}
</span>
</div>
</td>
<td className="py-3 px-4">
<span className={`font-mono text-sm ${study.best_value !== null ? 'text-primary-400' : 'text-dark-500'}`}>
{study.best_value !== null ? study.best_value.toExponential(3) : 'N/A'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Empty State when no study selected */}
{!selectedPreview && studies.length > 0 && (
<section className="flex items-center justify-center py-16 text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to view its documentation</p>
<p className="text-sm mt-1 text-dark-500">Click on any study card above</p>
</div>
</section>
)}
{/* Study Preview */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden flex flex-col">
{selectedPreview ? (
<>
{/* Preview Header */}
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white truncate">
{selectedPreview.name || selectedPreview.id}
</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30 whitespace-nowrap"
>
Open
<ArrowRight className="w-4 h-4" />
</button>
</div>
{/* Study Quick Stats */}
<div className="px-6 py-3 border-b border-dark-600 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
{getStatusIcon(selectedPreview.status)}
<span className="text-dark-300 capitalize">{selectedPreview.status}</span>
</div>
<div className="flex items-center gap-2 text-dark-400">
<Activity className="w-4 h-4" />
<span>{selectedPreview.progress.current} / {selectedPreview.progress.total} trials</span>
</div>
{selectedPreview.best_value !== null && (
<div className="flex items-center gap-2 text-primary-400">
<Target className="w-4 h-4" />
<span>Best: {selectedPreview.best_value.toExponential(4)}</span>
</div>
)}
</div>
{/* README Content */}
<div className="flex-1 overflow-y-auto p-6">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<MarkdownRenderer content={readme} />
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to preview</p>
<p className="text-sm mt-1 text-dark-500">Click on any row in the table</p>
</div>
</div>
)}
</div>
</div>
</main>
</div>
);

View File

@@ -10,14 +10,45 @@ import {
Loader2,
AlertTriangle,
CheckCircle,
Copy
Copy,
Trophy,
TrendingUp,
FileJson,
FileSpreadsheet,
Settings,
ArrowRight,
ChevronDown,
ChevronUp,
Printer
} from 'lucide-react';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import ReactMarkdown from 'react-markdown';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
interface BestSolution {
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}
export default function Results() {
const { selectedStudy } = useStudy();
const { selectedStudy, isInitialized } = useStudy();
const navigate = useNavigate();
const [reportContent, setReportContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -25,21 +56,37 @@ export default function Results() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [lastGenerated, setLastGenerated] = useState<string | null>(null);
const [bestSolution, setBestSolution] = useState<BestSolution | null>(null);
const [showAllParams, setShowAllParams] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
// Redirect if no study selected
// Redirect if no study selected (but only after initialization completes)
useEffect(() => {
if (!selectedStudy) {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
}, [selectedStudy, navigate, isInitialized]);
// Load report when study changes
// Load report and best solution when study changes
useEffect(() => {
if (selectedStudy) {
loadReport();
loadBestSolution();
}
}, [selectedStudy]);
// Show loading state while initializing (must be after all hooks)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
const loadReport = async () => {
if (!selectedStudy) return;
@@ -52,7 +99,7 @@ export default function Results() {
if (data.generated_at) {
setLastGenerated(data.generated_at);
}
} catch (err: any) {
} catch {
// No report yet - show placeholder
setReportContent(null);
} finally {
@@ -60,6 +107,17 @@ export default function Results() {
}
};
const loadBestSolution = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getBestSolution(selectedStudy.id);
setBestSolution(data);
} catch {
setBestSolution(null);
}
};
const handleGenerate = async () => {
if (!selectedStudy) return;
@@ -101,17 +159,148 @@ export default function Results() {
URL.revokeObjectURL(url);
};
const handlePrintPDF = () => {
if (!reportContent || !selectedStudy) return;
// Create a printable version of the report
const printWindow = window.open('', '_blank');
if (!printWindow) {
setError('Pop-up blocked. Please allow pop-ups to print PDF.');
return;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${selectedStudy.name} - Optimization Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px;
color: #1a1a1a;
line-height: 1.6;
}
h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
h2 { color: #1e40af; margin-top: 30px; }
h3 { color: #3730a3; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
tr:nth-child(even) { background: #f9fafb; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-family: 'Monaco', monospace; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 4px solid #2563eb; margin: 20px 0; padding: 10px 20px; background: #eff6ff; }
.header-info { color: #666; margin-bottom: 30px; }
@media print {
body { padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
</style>
</head>
<body>
<div class="header-info">
<strong>Study:</strong> ${selectedStudy.name}<br>
<strong>Generated:</strong> ${new Date().toLocaleString()}<br>
<strong>Trials:</strong> ${selectedStudy.progress.current} / ${selectedStudy.progress.total}
</div>
${convertMarkdownToHTML(reportContent)}
</body>
</html>
`);
printWindow.document.close();
// Wait for content to load then print
printWindow.onload = () => {
printWindow.print();
};
};
// Simple markdown to HTML converter for print
const convertMarkdownToHTML = (md: string): string => {
return md
// Headers
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Lists
.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)\n(?!<li>)/g, '</ul>$1\n')
.replace(/(?<!<\/ul>)(<li>)/g, '<ul>$1')
// Blockquotes
.replace(/^>\s*(.*)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return match;
});
};
const handleExport = async (format: 'csv' | 'json' | 'config') => {
if (!selectedStudy) return;
setExporting(format);
try {
const data = await apiClient.exportData(selectedStudy.id, format);
if (data.filename && data.content) {
const blob = new Blob([data.content], { type: data.content_type || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else if (format === 'json' && data.trials) {
// Direct JSON response
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy.id}_data.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err: any) {
setError(err.message || `Failed to export ${format}`);
} finally {
setExporting(null);
}
};
if (!selectedStudy) {
return null;
}
const paramEntries = bestSolution?.best_trial?.design_variables
? Object.entries(bestSolution.best_trial.design_variables)
: [];
const visibleParams = showAllParams ? paramEntries : paramEntries.slice(0, 6);
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between">
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
<h1 className="text-2xl font-bold text-primary-400">Results</h1>
<p className="text-dark-400 text-sm">{selectedStudy.name}</p>
</div>
<div className="flex gap-2">
<Button
@@ -153,7 +342,156 @@ export default function Results() {
</div>
)}
{/* Main Content */}
{/* Best Solution Card */}
{bestSolution?.best_trial && (
<Card className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Best Solution</h2>
<p className="text-sm text-dark-400">Trial #{bestSolution.best_trial.trial_number} of {bestSolution.total_trials}</p>
</div>
{bestSolution.improvements.objective && (
<div className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-900/20 rounded-lg border border-green-800/30">
<TrendingUp className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-bold text-lg">
{bestSolution.improvements.objective.improvement_pct > 0 ? '+' : ''}
{bestSolution.improvements.objective.improvement_pct.toFixed(1)}%
</span>
<span className="text-dark-400 text-sm">improvement</span>
</div>
)}
</div>
{/* Objective Value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Objective</div>
<div className="text-2xl font-bold text-primary-400">
{bestSolution.best_trial.objective.toExponential(4)}
</div>
</div>
{bestSolution.first_trial && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Initial Value</div>
<div className="text-2xl font-bold text-dark-300">
{bestSolution.first_trial.objective.toExponential(4)}
</div>
</div>
)}
{bestSolution.improvements.objective && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Absolute Change</div>
<div className="text-2xl font-bold text-green-400 flex items-center gap-2">
<ArrowRight className="w-5 h-5" />
{bestSolution.improvements.objective.absolute_change.toExponential(4)}
</div>
</div>
)}
</div>
{/* Design Variables */}
<div className="border-t border-dark-600 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-dark-300">Optimal Design Variables</h3>
{paramEntries.length > 6 && (
<button
onClick={() => setShowAllParams(!showAllParams)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllParams ? (
<>Show Less <ChevronUp className="w-3 h-3" /></>
) : (
<>Show All ({paramEntries.length}) <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{visibleParams.map(([name, value]) => (
<div key={name} className="bg-dark-800 rounded px-3 py-2">
<div className="text-xs text-dark-400 truncate" title={name}>{name}</div>
<div className="text-sm font-mono text-white">
{typeof value === 'number' ? value.toFixed(4) : value}
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Export Options */}
<Card className="mb-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Download className="w-5 h-5 text-primary-400" />
Export Data
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => handleExport('csv')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileSpreadsheet className="w-8 h-8 text-green-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">CSV</div>
<div className="text-xs text-dark-400">Spreadsheet</div>
</div>
{exporting === 'csv' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('json')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileJson className="w-8 h-8 text-blue-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">JSON</div>
<div className="text-xs text-dark-400">Full data</div>
</div>
{exporting === 'json' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('config')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<Settings className="w-8 h-8 text-purple-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Config</div>
<div className="text-xs text-dark-400">Settings</div>
</div>
{exporting === 'config' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={handleDownload}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-8 h-8 text-orange-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Report</div>
<div className="text-xs text-dark-400">Markdown</div>
</div>
</button>
<button
onClick={handlePrintPDF}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-8 h-8 text-red-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">PDF</div>
<div className="text-xs text-dark-400">Print report</div>
</div>
</button>
</div>
</Card>
{/* Main Content - Report */}
<div className="flex-1 min-h-0">
<Card className="h-full overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
@@ -175,18 +513,8 @@ export default function Results() {
<span>Loading report...</span>
</div>
) : reportContent ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-dark-300 prose-strong:text-white
prose-code:text-primary-400 prose-code:bg-dark-700 prose-code:px-1 prose-code:rounded
prose-pre:bg-dark-700 prose-pre:border prose-pre:border-dark-600
prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline
prose-ul:text-dark-300 prose-ol:text-dark-300
prose-li:text-dark-300
prose-table:border-collapse prose-th:border prose-th:border-dark-600 prose-th:p-2 prose-th:bg-dark-700
prose-td:border prose-td:border-dark-600 prose-td:p-2
prose-hr:border-dark-600">
<ReactMarkdown>{reportContent}</ReactMarkdown>
<div className="p-2">
<MarkdownRenderer content={reportContent} />
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-dark-400">

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Settings,
Target,
Sliders,
AlertTriangle,
Cpu,
Box,
Layers,
Play,
Download,
RefreshCw,
ChevronDown,
ChevronUp,
ArrowUp,
ArrowDown,
CheckCircle,
Info,
FileBox,
FolderOpen,
File
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
import { Button } from '../components/common/Button';
import { apiClient, ModelFile } from '../api/client';
interface StudyConfig {
study_name: string;
description?: string;
objectives: {
name: string;
direction: 'minimize' | 'maximize';
unit?: string;
target?: number;
weight?: number;
}[];
design_variables: {
name: string;
type: 'float' | 'int' | 'categorical';
low?: number;
high?: number;
step?: number;
choices?: string[];
unit?: string;
}[];
constraints: {
name: string;
type: 'le' | 'ge' | 'eq';
bound: number;
unit?: string;
}[];
algorithm: {
name: string;
sampler: string;
pruner?: string;
n_trials: number;
timeout?: number;
};
fea_model?: {
software: string;
solver: string;
sim_file?: string;
mesh_elements?: number;
};
extractors?: {
name: string;
type: string;
source?: string;
}[];
}
export default function Setup() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [config, setConfig] = useState<StudyConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['objectives', 'variables', 'constraints', 'algorithm', 'modelFiles'])
);
const [modelFiles, setModelFiles] = useState<ModelFile[]>([]);
const [modelDir, setModelDir] = useState<string>('');
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study configuration
useEffect(() => {
if (selectedStudy) {
loadConfig();
loadModelFiles();
}
}, [selectedStudy]);
const loadModelFiles = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getModelFiles(selectedStudy.id);
setModelFiles(data.files);
setModelDir(data.model_dir);
} catch (err) {
console.error('Failed to load model files:', err);
}
};
const handleOpenFolder = async () => {
if (!selectedStudy) return;
try {
await apiClient.openFolder(selectedStudy.id, 'model');
} catch (err: any) {
setError(err.message || 'Failed to open folder');
}
};
const loadConfig = async () => {
if (!selectedStudy) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.getStudyConfig(selectedStudy.id);
const rawConfig = response.config;
// Transform backend config format to our StudyConfig format
const transformedConfig: StudyConfig = {
study_name: rawConfig.study_name || selectedStudy.name || selectedStudy.id,
description: rawConfig.description,
objectives: (rawConfig.objectives || []).map((obj: any) => ({
name: obj.name,
direction: obj.direction || 'minimize',
unit: obj.unit || obj.units,
target: obj.target,
weight: obj.weight
})),
design_variables: (rawConfig.design_variables || []).map((dv: any) => ({
name: dv.name,
type: dv.type || 'float',
low: dv.min ?? dv.low,
high: dv.max ?? dv.high,
step: dv.step,
choices: dv.choices,
unit: dv.unit || dv.units
})),
constraints: (rawConfig.constraints || []).map((c: any) => ({
name: c.name,
type: c.type || 'le',
bound: c.max_value ?? c.min_value ?? c.bound ?? 0,
unit: c.unit || c.units
})),
algorithm: {
name: rawConfig.optimizer?.name || rawConfig.algorithm?.name || 'Optuna',
sampler: rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler',
pruner: rawConfig.optimization_settings?.pruner || rawConfig.algorithm?.pruner,
n_trials: rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total,
timeout: rawConfig.optimization_settings?.timeout
},
fea_model: rawConfig.fea_model || rawConfig.solver ? {
software: rawConfig.fea_model?.software || rawConfig.solver?.type || 'NX Nastran',
solver: rawConfig.fea_model?.solver || rawConfig.solver?.name || 'SOL 103',
sim_file: rawConfig.sim_file || rawConfig.fea_model?.sim_file,
mesh_elements: rawConfig.fea_model?.mesh_elements
} : undefined,
extractors: rawConfig.extractors
};
setConfig(transformedConfig);
} catch (err: any) {
// If no config endpoint, create mock from available data
setConfig({
study_name: selectedStudy.name || selectedStudy.id,
objectives: [{ name: 'objective', direction: 'minimize' }],
design_variables: [],
constraints: [],
algorithm: {
name: 'Optuna',
sampler: 'TPESampler',
n_trials: selectedStudy.progress.total
}
});
setError('Configuration loaded with limited data');
} finally {
setLoading(false);
}
};
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const handleStartOptimization = async () => {
if (!selectedStudy) return;
try {
await apiClient.startOptimization(selectedStudy.id);
navigate('/dashboard');
} catch (err: any) {
setError(err.message || 'Failed to start optimization');
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id || 'study'}_config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Loading state
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
if (!selectedStudy) return null;
// Calculate design space size
const designSpaceSize = config?.design_variables.reduce((acc, v) => {
if (v.type === 'categorical' && v.choices) {
return acc * v.choices.length;
} else if (v.type === 'int' && v.low !== undefined && v.high !== undefined) {
return acc * (v.high - v.low + 1);
}
return acc * 1000; // Approximate for continuous
}, 1) || 0;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<div className="flex items-center gap-3">
<Settings className="w-8 h-8 text-primary-400" />
<div>
<h1 className="text-2xl font-bold text-white">{config?.study_name || selectedStudy.name}</h1>
<p className="text-dark-400 text-sm">Study Configuration</p>
</div>
</div>
{config?.description && (
<p className="text-dark-300 mt-2 max-w-2xl">{config.description}</p>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
onClick={loadConfig}
disabled={loading}
>
Refresh
</Button>
<Button
variant="secondary"
icon={<Download className="w-4 h-4" />}
onClick={handleExportConfig}
disabled={!config}
>
Export
</Button>
{selectedStudy.status === 'not_started' && (
<Button
variant="primary"
icon={<Play className="w-4 h-4" />}
onClick={handleStartOptimization}
>
Start Optimization
</Button>
)}
</div>
</header>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-yellow-900/20 border border-yellow-800/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<Info className="w-4 h-4" />
<span>{error}</span>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6">
{/* Objectives Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('objectives')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Objectives</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.objectives.length || 0}
</span>
</div>
{expandedSections.has('objectives') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('objectives') && (
<div className="px-4 pb-4 space-y-3">
{config?.objectives.map((obj, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-4 border border-dark-600"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{obj.name}</span>
<span className={`flex items-center gap-1 text-sm px-2 py-1 rounded ${
obj.direction === 'minimize'
? 'bg-green-500/10 text-green-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{obj.direction === 'minimize' ? (
<ArrowDown className="w-3 h-3" />
) : (
<ArrowUp className="w-3 h-3" />
)}
{obj.direction}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{obj.unit && <span>Unit: {obj.unit}</span>}
{obj.target !== undefined && (
<span>Target: {obj.target}</span>
)}
{obj.weight !== undefined && (
<span>Weight: {obj.weight}</span>
)}
</div>
</div>
))}
{config?.objectives.length === 0 && (
<p className="text-dark-500 text-sm italic">No objectives configured</p>
)}
<div className="text-xs text-dark-500 pt-2">
Type: {(config?.objectives.length || 0) > 1 ? 'Multi-Objective' : 'Single-Objective'}
</div>
</div>
)}
</Card>
{/* Design Variables Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('variables')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Sliders className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Design Variables</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.design_variables.length || 0}
</span>
</div>
{expandedSections.has('variables') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('variables') && (
<div className="px-4 pb-4 space-y-2">
{config?.design_variables.map((v, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white font-mono text-sm">{v.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{v.type}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400 mt-1">
{v.low !== undefined && v.high !== undefined && (
<span>Range: [{v.low}, {v.high}]</span>
)}
{v.step && <span>Step: {v.step}</span>}
{v.unit && <span>{v.unit}</span>}
{v.choices && (
<span>Choices: {v.choices.join(', ')}</span>
)}
</div>
</div>
))}
{config?.design_variables.length === 0 && (
<p className="text-dark-500 text-sm italic">No design variables configured</p>
)}
{designSpaceSize > 0 && (
<div className="text-xs text-dark-500 pt-2">
Design Space: ~{designSpaceSize.toExponential(2)} combinations
</div>
)}
</div>
)}
</Card>
{/* Constraints Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('constraints')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white">Constraints</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.constraints.length || 0}
</span>
</div>
{expandedSections.has('constraints') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('constraints') && (
<div className="px-4 pb-4">
{(config?.constraints.length || 0) > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-dark-400 text-left">
<th className="pb-2">Name</th>
<th className="pb-2">Type</th>
<th className="pb-2">Bound</th>
<th className="pb-2">Unit</th>
</tr>
</thead>
<tbody className="text-dark-300">
{config?.constraints.map((c, idx) => (
<tr key={idx} className="border-t border-dark-700">
<td className="py-2 font-mono">{c.name}</td>
<td className="py-2">
{c.type === 'le' ? '≤' : c.type === 'ge' ? '≥' : '='}
</td>
<td className="py-2">{c.bound}</td>
<td className="py-2 text-dark-500">{c.unit || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-dark-500 text-sm italic">No constraints configured</p>
)}
</div>
)}
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Algorithm Configuration */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('algorithm')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Algorithm Configuration</h2>
</div>
{expandedSections.has('algorithm') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('algorithm') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Optimizer</div>
<div className="text-white font-medium">{config?.algorithm.name || 'Optuna'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Sampler</div>
<div className="text-white font-medium">{config?.algorithm.sampler || 'TPE'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-white font-medium">{config?.algorithm.n_trials || selectedStudy.progress.total}</div>
</div>
{config?.algorithm.pruner && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Pruner</div>
<div className="text-white font-medium">{config.algorithm.pruner}</div>
</div>
)}
{config?.algorithm.timeout && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Timeout</div>
<div className="text-white font-medium">{config.algorithm.timeout}s</div>
</div>
)}
</div>
</div>
)}
</Card>
{/* FEA Model Info */}
{config?.fea_model && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('model')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">FEA Model</h2>
</div>
{expandedSections.has('model') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('model') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Software</div>
<div className="text-white font-medium">{config.fea_model.software}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Solver</div>
<div className="text-white font-medium">{config.fea_model.solver}</div>
</div>
{config.fea_model.mesh_elements && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Mesh Elements</div>
<div className="text-white font-medium">
{config.fea_model.mesh_elements.toLocaleString()}
</div>
</div>
)}
{config.fea_model.sim_file && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600 col-span-2">
<div className="text-xs text-dark-400 uppercase mb-1">Simulation File</div>
<div className="text-white font-mono text-sm truncate">
{config.fea_model.sim_file}
</div>
</div>
)}
</div>
</div>
)}
</Card>
)}
{/* NX Model Files */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('modelFiles')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<FileBox className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">NX Model Files</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{modelFiles.length}
</span>
</div>
{expandedSections.has('modelFiles') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('modelFiles') && (
<div className="px-4 pb-4 space-y-3">
{/* Open Folder Button */}
<button
onClick={handleOpenFolder}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 hover:text-white rounded-lg border border-dark-600 transition-colors"
>
<FolderOpen className="w-4 h-4" />
<span>Open Model Folder</span>
</button>
{/* Model Directory Path */}
{modelDir && (
<div className="text-xs text-dark-500 font-mono truncate" title={modelDir}>
{modelDir}
</div>
)}
{/* File List */}
{modelFiles.length > 0 ? (
<div className="space-y-2">
{modelFiles.map((file, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<File className={`w-4 h-4 ${
file.extension === '.prt' ? 'text-blue-400' :
file.extension === '.sim' ? 'text-green-400' :
file.extension === '.fem' ? 'text-yellow-400' :
file.extension === '.bdf' || file.extension === '.dat' ? 'text-orange-400' :
file.extension === '.op2' ? 'text-purple-400' :
'text-dark-400'
}`} />
<span className="font-medium text-white text-sm truncate" title={file.name}>
{file.name}
</span>
</div>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded uppercase">
{file.extension.slice(1)}
</span>
</div>
<div className="flex items-center justify-between mt-1 text-xs text-dark-500">
<span>{file.size_display}</span>
<span>{new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-dark-500 text-sm italic text-center py-4">
No model files found
</p>
)}
{/* File Type Legend */}
{modelFiles.length > 0 && (
<div className="pt-2 border-t border-dark-700">
<div className="flex flex-wrap gap-3 text-xs text-dark-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-blue-400 rounded-full"></span>.prt = Part</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-green-400 rounded-full"></span>.sim = Simulation</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-yellow-400 rounded-full"></span>.fem = FEM</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-orange-400 rounded-full"></span>.bdf = Nastran</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-purple-400 rounded-full"></span>.op2 = Results</span>
</div>
</div>
)}
</div>
)}
</Card>
{/* Extractors */}
{config?.extractors && config.extractors.length > 0 && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('extractors')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Layers className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Extractors</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config.extractors.length}
</span>
</div>
{expandedSections.has('extractors') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('extractors') && (
<div className="px-4 pb-4 space-y-2">
{config.extractors.map((ext, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white">{ext.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{ext.type}
</span>
</div>
{ext.source && (
<div className="text-xs text-dark-500 mt-1 font-mono">{ext.source}</div>
)}
</div>
))}
</div>
)}
</Card>
)}
{/* Study Stats */}
<Card title="Current Progress">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-white">
{selectedStudy.progress.current}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Trials Completed</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-primary-400">
{selectedStudy.best_value?.toExponential(3) || 'N/A'}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Best Value</div>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-dark-400">Progress</span>
<span className="text-white">
{Math.round((selectedStudy.progress.current / selectedStudy.progress.total) * 100)}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{
width: `${Math.min((selectedStudy.progress.current / selectedStudy.progress.total) * 100, 100)}%`
}}
/>
</div>
</div>
{/* Status Badge */}
<div className="mt-4 flex items-center justify-center">
<span className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
selectedStudy.status === 'running' ? 'bg-green-500/10 text-green-400' :
selectedStudy.status === 'completed' ? 'bg-blue-500/10 text-blue-400' :
selectedStudy.status === 'paused' ? 'bg-orange-500/10 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{selectedStudy.status === 'completed' && <CheckCircle className="w-4 h-4" />}
{selectedStudy.status === 'running' && <Play className="w-4 h-4" />}
<span className="capitalize">{selectedStudy.status.replace('_', ' ')}</span>
</span>
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
export interface Study {
id: string;
name: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;
@@ -96,6 +96,14 @@ export interface ProgressMessage {
export interface TrialPrunedMessage extends PrunedTrial {}
// Objective type
export interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
weight?: number;
}
// Chart data types
export interface ConvergenceDataPoint {
trial_number: number;
@@ -114,7 +122,7 @@ export interface ParameterSpaceDataPoint {
// Study status types
export interface StudyStatus {
study_id: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;