diff --git a/atomizer-dashboard/backend/api/main.py b/atomizer-dashboard/backend/api/main.py index a7e9369a..97041965 100644 --- a/atomizer-dashboard/backend/api/main.py +++ b/atomizer-dashboard/backend/api/main.py @@ -12,7 +12,7 @@ import sys # Add parent directory to path to import optimization_engine sys.path.append(str(Path(__file__).parent.parent.parent.parent)) -from api.routes import optimization, claude, terminal +from api.routes import optimization, claude, terminal, insights from api.websocket import optimization_stream # Create FastAPI app @@ -36,6 +36,7 @@ app.include_router(optimization.router, prefix="/api/optimization", tags=["optim app.include_router(optimization_stream.router, prefix="/api/ws", tags=["websocket"]) app.include_router(claude.router, prefix="/api/claude", tags=["claude"]) app.include_router(terminal.router, prefix="/api/terminal", tags=["terminal"]) +app.include_router(insights.router, prefix="/api/insights", tags=["insights"]) @app.get("/") async def root(): diff --git a/atomizer-dashboard/backend/api/routes/insights.py b/atomizer-dashboard/backend/api/routes/insights.py new file mode 100644 index 00000000..802090e4 --- /dev/null +++ b/atomizer-dashboard/backend/api/routes/insights.py @@ -0,0 +1,221 @@ +""" +Study Insights API endpoints +Provides physics-focused visualizations for completed optimization trials +""" + +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse, HTMLResponse +from pathlib import Path +from typing import List, Dict, Optional +import json +import sys + +# Add project root to path +sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent)) + +router = APIRouter() + +# Base studies directory +STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies" + + +def resolve_study_path(study_id: str) -> Path: + """Find study folder by scanning all topic directories.""" + # First check direct path (backwards compatibility) + direct_path = STUDIES_DIR / study_id + if direct_path.exists() and direct_path.is_dir(): + if (direct_path / "1_setup").exists() or (direct_path / "optimization_config.json").exists(): + return direct_path + + # Scan topic folders for nested structure + for topic_dir in STUDIES_DIR.iterdir(): + if topic_dir.is_dir() and not topic_dir.name.startswith('.'): + study_dir = topic_dir / study_id + if study_dir.exists() and study_dir.is_dir(): + if (study_dir / "1_setup").exists() or (study_dir / "optimization_config.json").exists(): + return study_dir + + raise HTTPException(status_code=404, detail=f"Study not found: {study_id}") + + +@router.get("/studies/{study_id}/insights/available") +async def list_available_insights(study_id: str): + """List all insight types that can be generated for this study.""" + try: + study_path = resolve_study_path(study_id) + + # Import insights module + from optimization_engine.insights import list_available_insights as get_available + + available = get_available(study_path) + + return { + "study_id": study_id, + "insights": available + } + except HTTPException: + raise + except ImportError as e: + return { + "study_id": study_id, + "insights": [], + "error": f"Insights module not available: {str(e)}" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/studies/{study_id}/insights/all") +async def list_all_insights(): + """List all registered insight types (regardless of availability for any study).""" + try: + from optimization_engine.insights import list_insights + + return { + "insights": list_insights() + } + except ImportError as e: + return { + "insights": [], + "error": f"Insights module not available: {str(e)}" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/studies/{study_id}/insights/generate/{insight_type}") +async def generate_insight(study_id: str, insight_type: str, trial_id: Optional[int] = None): + """Generate a specific insight visualization. + + Args: + study_id: Study identifier + insight_type: Type of insight (e.g., 'zernike_wfe', 'stress_field', 'design_space') + trial_id: Optional specific trial to analyze (defaults to best trial) + + Returns: + JSON with plotly_figure data and summary statistics + """ + try: + study_path = resolve_study_path(study_id) + + from optimization_engine.insights import get_insight, InsightConfig + + insight = get_insight(insight_type, study_path) + if insight is None: + raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}") + + if not insight.can_generate(): + raise HTTPException( + status_code=400, + detail=f"Cannot generate {insight_type}: required data not found" + ) + + # Configure insight + config = InsightConfig(trial_id=trial_id) + + # Generate + result = insight.generate(config) + + if not result.success: + raise HTTPException(status_code=500, detail=result.error or "Generation failed") + + return { + "success": True, + "insight_type": insight_type, + "study_id": study_id, + "html_path": str(result.html_path) if result.html_path else None, + "plotly_figure": result.plotly_figure, + "summary": result.summary + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/studies/{study_id}/insights/view/{insight_type}") +async def view_insight_html(study_id: str, insight_type: str): + """Get the HTML content for an insight (for iframe embedding). + + Returns the most recent generated HTML file for this insight type, + or generates one if none exists. + """ + try: + study_path = resolve_study_path(study_id) + insights_dir = study_path / "3_insights" + + # Look for existing HTML files + if insights_dir.exists(): + pattern = f"{insight_type}_*.html" + existing = list(insights_dir.glob(pattern)) + if existing: + # Return most recent + newest = max(existing, key=lambda p: p.stat().st_mtime) + return HTMLResponse(content=newest.read_text(encoding='utf-8')) + + # No existing file - generate one + from optimization_engine.insights import get_insight, InsightConfig + + insight = get_insight(insight_type, study_path) + if insight is None: + raise HTTPException(status_code=404, detail=f"Unknown insight type: {insight_type}") + + if not insight.can_generate(): + raise HTTPException( + status_code=400, + detail=f"Cannot generate {insight_type}: required data not found" + ) + + result = insight.generate(InsightConfig()) + + if result.success and result.html_path: + return HTMLResponse(content=result.html_path.read_text(encoding='utf-8')) + else: + raise HTTPException(status_code=500, detail=result.error or "Generation failed") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/studies/{study_id}/insights/generated") +async def list_generated_insights(study_id: str): + """List all previously generated insight HTML files for a study.""" + try: + study_path = resolve_study_path(study_id) + insights_dir = study_path / "3_insights" + + if not insights_dir.exists(): + return {"study_id": study_id, "files": []} + + files = [] + for html_file in insights_dir.glob("*.html"): + # Parse insight type from filename (e.g., "zernike_wfe_20251220_143022.html") + name = html_file.stem + parts = name.rsplit('_', 2) # Split from right to get type and timestamp + + insight_type = parts[0] if len(parts) >= 3 else name + timestamp = f"{parts[-2]}_{parts[-1]}" if len(parts) >= 3 else None + + files.append({ + "filename": html_file.name, + "insight_type": insight_type, + "timestamp": timestamp, + "size_kb": round(html_file.stat().st_size / 1024, 1), + "modified": html_file.stat().st_mtime + }) + + # Sort by modification time (newest first) + files.sort(key=lambda x: x['modified'], reverse=True) + + return { + "study_id": study_id, + "files": files + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/atomizer-dashboard/frontend/src/App.tsx b/atomizer-dashboard/frontend/src/App.tsx index cc48e0b3..0bdc2a22 100644 --- a/atomizer-dashboard/frontend/src/App.tsx +++ b/atomizer-dashboard/frontend/src/App.tsx @@ -8,6 +8,7 @@ import Home from './pages/Home'; import Setup from './pages/Setup'; import Dashboard from './pages/Dashboard'; import Analysis from './pages/Analysis'; +import Insights from './pages/Insights'; import Results from './pages/Results'; const queryClient = new QueryClient({ @@ -34,6 +35,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/atomizer-dashboard/frontend/src/components/layout/Sidebar.tsx b/atomizer-dashboard/frontend/src/components/layout/Sidebar.tsx index 5288631c..5ba4309d 100644 --- a/atomizer-dashboard/frontend/src/components/layout/Sidebar.tsx +++ b/atomizer-dashboard/frontend/src/components/layout/Sidebar.tsx @@ -11,7 +11,8 @@ import { CheckCircle, Clock, Zap, - Terminal + Terminal, + Eye } from 'lucide-react'; import clsx from 'clsx'; import { useStudy } from '../../context/StudyContext'; @@ -63,6 +64,7 @@ export const Sidebar = () => { { to: '/setup', icon: Settings, label: 'Setup' }, { to: '/dashboard', icon: Activity, label: 'Live Tracker' }, { to: '/analysis', icon: TrendingUp, label: 'Analysis' }, + { to: '/insights', icon: Eye, label: 'Insights' }, { to: '/results', icon: FileText, label: 'Results' }, ] : [ diff --git a/atomizer-dashboard/frontend/src/pages/Insights.tsx b/atomizer-dashboard/frontend/src/pages/Insights.tsx new file mode 100644 index 00000000..bcac607b --- /dev/null +++ b/atomizer-dashboard/frontend/src/pages/Insights.tsx @@ -0,0 +1,452 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Eye, + RefreshCw, + Download, + Maximize2, + X, + Activity, + Thermometer, + Waves, + Grid3X3, + Box, + AlertCircle, + CheckCircle, + Clock, + FileText +} from 'lucide-react'; +import { useStudy } from '../context/StudyContext'; +import { Card } from '../components/common/Card'; +import Plot from 'react-plotly.js'; + +interface InsightInfo { + type: string; + name: string; + description: string; + applicable_to: string[]; +} + +interface GeneratedFile { + filename: string; + insight_type: string; + timestamp: string | null; + size_kb: number; + modified: number; +} + +interface InsightResult { + success: boolean; + insight_type: string; + plotly_figure: any; + summary: Record; + html_path: string | null; +} + +const INSIGHT_ICONS: Record = { + zernike_wfe: Waves, + stress_field: Activity, + modal: Box, + thermal: Thermometer, + design_space: Grid3X3 +}; + +const INSIGHT_COLORS: Record = { + zernike_wfe: 'text-blue-400 bg-blue-500/10 border-blue-500/30', + stress_field: 'text-red-400 bg-red-500/10 border-red-500/30', + modal: 'text-purple-400 bg-purple-500/10 border-purple-500/30', + thermal: 'text-orange-400 bg-orange-500/10 border-orange-500/30', + design_space: 'text-green-400 bg-green-500/10 border-green-500/30' +}; + +export default function Insights() { + const navigate = useNavigate(); + const { selectedStudy, isInitialized } = useStudy(); + + const [availableInsights, setAvailableInsights] = useState([]); + const [generatedFiles, setGeneratedFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(null); + const [error, setError] = useState(null); + + // Active insight for display + const [activeInsight, setActiveInsight] = useState(null); + const [fullscreen, setFullscreen] = useState(false); + + // Redirect if no study + useEffect(() => { + if (isInitialized && !selectedStudy) { + navigate('/'); + } + }, [selectedStudy, navigate, isInitialized]); + + // Load available insights and generated files + const loadInsights = useCallback(async () => { + if (!selectedStudy) return; + + setLoading(true); + setError(null); + + try { + // Load available insights + const availRes = await fetch(`/api/insights/studies/${selectedStudy.id}/insights/available`); + const availData = await availRes.json(); + setAvailableInsights(availData.insights || []); + + // Load previously generated files + const genRes = await fetch(`/api/insights/studies/${selectedStudy.id}/insights/generated`); + const genData = await genRes.json(); + setGeneratedFiles(genData.files || []); + + } catch (err) { + console.error('Failed to load insights:', err); + setError('Failed to load insights data'); + } finally { + setLoading(false); + } + }, [selectedStudy]); + + useEffect(() => { + loadInsights(); + }, [loadInsights]); + + // Generate an insight + const handleGenerate = async (insightType: string) => { + if (!selectedStudy || generating) return; + + setGenerating(insightType); + setError(null); + + try { + const res = await fetch( + `/api/insights/studies/${selectedStudy.id}/insights/generate/${insightType}`, + { method: 'POST' } + ); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || 'Generation failed'); + } + + const result: InsightResult = await res.json(); + setActiveInsight(result); + + // Refresh file list + loadInsights(); + + } catch (err: any) { + setError(err.message || 'Failed to generate insight'); + } finally { + setGenerating(null); + } + }; + + // View existing insight + const handleViewExisting = async (file: GeneratedFile) => { + if (!selectedStudy) return; + + setGenerating(file.insight_type); + + try { + const res = await fetch( + `/api/insights/studies/${selectedStudy.id}/insights/generate/${file.insight_type}`, + { method: 'POST' } + ); + + if (!res.ok) { + throw new Error('Failed to load insight'); + } + + const result: InsightResult = await res.json(); + setActiveInsight(result); + + } catch (err: any) { + setError(err.message || 'Failed to load insight'); + } finally { + setGenerating(null); + } + }; + + // Open HTML in new tab + const handleOpenHtml = (file: GeneratedFile) => { + if (!selectedStudy) return; + window.open(`/api/insights/studies/${selectedStudy.id}/insights/view/${file.insight_type}`, '_blank'); + }; + + const getIcon = (type: string) => { + const Icon = INSIGHT_ICONS[type] || Eye; + return Icon; + }; + + const getColorClass = (type: string) => { + return INSIGHT_COLORS[type] || 'text-gray-400 bg-gray-500/10 border-gray-500/30'; + }; + + if (!isInitialized || !selectedStudy) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Insights

+

+ Physics visualizations for {selectedStudy.name || selectedStudy.id} +

+
+
+ +
+
+ + {/* Error Banner */} + {error && ( +
+ +

{error}

+ +
+ )} + +
+ {/* Left Panel: Available Insights */} +
+ + {loading ? ( +
+ + Loading... +
+ ) : availableInsights.length === 0 ? ( +
+ +

No insights available for this study.

+

Run some trials first.

+
+ ) : ( +
+ {availableInsights.map((insight) => { + const Icon = getIcon(insight.type); + const colorClass = getColorClass(insight.type); + const isGenerating = generating === insight.type; + + return ( +
+
+
+ +
+
+

{insight.name}

+

{insight.description}

+
+
+ +
+ ); + })} +
+ )} +
+ + {/* Previously Generated */} + {generatedFiles.length > 0 && ( + +
+ {generatedFiles.map((file) => { + const Icon = getIcon(file.insight_type); + + return ( +
+ +
+

{file.insight_type}

+

{file.size_kb} KB

+
+
+ + +
+
+ ); + })} +
+
+ )} +
+ + {/* Right Panel: Visualization */} +
+ {activeInsight ? ( + + {activeInsight.insight_type.replace('_', ' ').toUpperCase()} +
+ + +
+
+ } + className="h-full" + > + {/* Summary Stats */} + {activeInsight.summary && Object.keys(activeInsight.summary).length > 0 && ( +
+ {Object.entries(activeInsight.summary).slice(0, 8).map(([key, value]) => ( +
+
{key.replace(/_/g, ' ')}
+
+ {typeof value === 'number' + ? value.toExponential ? value.toExponential(3) : value + : String(value) + } +
+
+ ))} +
+ )} + + {/* Plotly Figure */} + {activeInsight.plotly_figure ? ( +
+ +
+ ) : ( +
+

No visualization data available

+
+ )} + + ) : ( + +
+ +

No Insight Selected

+

+ Select an insight type from the left panel to generate a visualization. +

+
+
+ )} +
+
+ + {/* Fullscreen Modal */} + {fullscreen && activeInsight?.plotly_figure && ( +
+
+

+ {activeInsight.insight_type.replace('_', ' ').toUpperCase()} +

+ +
+
+ +
+
+ )} + + ); +}