feat: Enhance dashboard with charts, study report viewer, and pruning tracking

- Add ConvergencePlot component with running best, statistics, gradient fill
- Add ParameterImportanceChart with Pearson correlation analysis
- Add StudyReportViewer with KaTeX math rendering and full markdown support
- Update pruning endpoint to query Optuna database directly
- Add /report endpoint for STUDY_REPORT.md files
- Fix chart data transformation for single/multi-objective studies
- Update Protocol 13 documentation with new components
- Update generate-report skill with dashboard integration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-02 22:01:49 -05:00
parent ec5e42d733
commit 75d7036193
10 changed files with 2917 additions and 66 deletions

View File

@@ -1,7 +1,7 @@
# Generate Report Skill
**Last Updated**: November 25, 2025
**Version**: 1.0 - Optimization Results Analysis and Reporting
**Last Updated**: December 3, 2025
**Version**: 1.1 - Optimization Results Analysis and Reporting (Dashboard Integration)
You are helping the user understand and communicate optimization results.
@@ -390,6 +390,45 @@ Would you like me to:
3. Continue optimization to explore more designs?
```
## Dashboard Integration (December 2025)
When a STUDY_REPORT.md file is generated, it can be viewed directly in the Atomizer Dashboard:
1. **Save report to**: `studies/{study_name}/2_results/STUDY_REPORT.md`
2. **View in dashboard**: Click "Study Report" button on the dashboard
3. **Features**:
- Full markdown rendering with proper typography
- Math equation support via KaTeX (`$...$` inline, `$$...$$` block)
- Tables, code blocks, task lists
- Live refresh button for updates during analysis
### Report Format for Dashboard
Use standard markdown with optional LaTeX math:
```markdown
# Study Report: {study_name}
## Summary
The optimization achieved a **{improvement}%** improvement in objective.
## Results
| Trial | Objective | Improvement |
|-------|-----------|-------------|
| 0 | 100.0 | baseline |
| 10 | 85.5 | 14.5% |
## Mathematical Formulation
The RMS wavefront error is calculated as:
$$\text{RMS} = \sqrt{\frac{1}{N}\sum_{i=1}^{N}c_i^2}$$
where $c_i$ are the Zernike coefficients.
```
## Notes
- Reports should be actionable, not just data dumps
@@ -397,3 +436,4 @@ Would you like me to:
- Consider the user's stated goals when highlighting results
- Visualizations should be generated via Python scripts
- Export formats should be compatible with common tools (Excel, etc.)
- **Use STUDY_REPORT.md** for dashboard-viewable reports with math support

View File

@@ -21,6 +21,15 @@ router = APIRouter()
# Base studies directory
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
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
@router.get("/studies")
async def list_studies():
"""List all available optimization studies"""
@@ -44,8 +53,10 @@ async def list_studies():
with open(config_file) as f:
config = json.load(f)
# Check if results directory exists
# Check if results directory exists (support both 2_results and 3_results)
results_dir = study_dir / "2_results"
if not results_dir.exists():
results_dir = study_dir / "3_results"
# Check for Optuna database (Protocol 10) or JSON history (other protocols)
study_db = results_dir / "study.db"
@@ -149,8 +160,8 @@ async def get_study_status(study_id: str):
with open(config_file) as f:
config = json.load(f)
# Check for results
results_dir = study_dir / "2_results"
# Check for results (support both 2_results and 3_results)
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
history_file = results_dir / "optimization_history_incremental.json"
@@ -267,7 +278,7 @@ async def get_optimization_history(study_id: str, limit: Optional[int] = None):
"""Get optimization history (all trials)"""
try:
study_dir = STUDIES_DIR / study_id
results_dir = study_dir / "2_results"
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
history_file = results_dir / "optimization_history_incremental.json"
@@ -323,16 +334,24 @@ async def get_optimization_history(study_id: str, limit: Optional[int] = None):
except (ValueError, TypeError):
user_attrs[key] = value_json
# Extract relevant metrics for results (mass, frequency, stress, displacement, etc.)
# Extract ALL numeric metrics from user_attrs for results
# This ensures multi-objective studies show all Zernike metrics, RMS values, etc.
results = {}
if "mass" in user_attrs:
results["mass"] = user_attrs["mass"]
if "frequency" in user_attrs:
results["frequency"] = user_attrs["frequency"]
if "max_stress" in user_attrs:
results["max_stress"] = user_attrs["max_stress"]
if "max_displacement" in user_attrs:
results["max_displacement"] = user_attrs["max_displacement"]
excluded_keys = {"design_vars", "constraint_satisfied", "constraint_violations"}
for key, val in user_attrs.items():
if key in excluded_keys:
continue
# Include numeric values and lists of numbers
if isinstance(val, (int, float)):
results[key] = val
elif isinstance(val, list) and len(val) > 0 and isinstance(val[0], (int, float)):
# For lists, store as-is (e.g., Zernike coefficients)
results[key] = val
elif key == "objectives" and isinstance(val, dict):
# Extract nested objectives dict (Zernike multi-objective studies)
for obj_key, obj_val in val.items():
if isinstance(obj_val, (int, float)):
results[obj_key] = obj_val
# Fallback to first frequency from objectives if available
if not results and len(values) > 0:
results["first_frequency"] = values[0]
@@ -378,18 +397,69 @@ async def get_optimization_history(study_id: str, limit: Optional[int] = None):
@router.get("/studies/{study_id}/pruning")
async def get_pruning_history(study_id: str):
"""Get pruning diagnostics"""
"""Get pruning diagnostics from Optuna database or legacy JSON file"""
try:
study_dir = STUDIES_DIR / study_id
pruning_file = study_dir / "2_results" / "pruning_history.json"
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
pruning_file = results_dir / "pruning_history.json"
# Protocol 10+: Read from Optuna database
if study_db.exists():
conn = sqlite3.connect(str(study_db))
cursor = conn.cursor()
# Get all pruned trials from Optuna database
cursor.execute("""
SELECT t.trial_id, t.number, t.datetime_start, t.datetime_complete
FROM trials t
WHERE t.state = 'PRUNED'
ORDER BY t.number DESC
""")
pruned_rows = cursor.fetchall()
pruned_trials = []
for trial_id, trial_num, start_time, end_time in pruned_rows:
# Get parameters for this trial
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {row[0]: row[1] for row in cursor.fetchall()}
# Get user attributes (may contain pruning cause)
cursor.execute("""
SELECT key, value_json
FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
user_attrs = {}
for key, value_json in cursor.fetchall():
try:
user_attrs[key] = json.loads(value_json)
except (ValueError, TypeError):
user_attrs[key] = value_json
pruned_trials.append({
"trial_number": trial_num,
"params": params,
"pruning_cause": user_attrs.get("pruning_cause", "Unknown"),
"start_time": start_time,
"end_time": end_time
})
conn.close()
return {"pruned_trials": pruned_trials, "count": len(pruned_trials)}
# Legacy: Read from JSON history
if not pruning_file.exists():
return {"pruned_trials": []}
return {"pruned_trials": [], "count": 0}
with open(pruning_file) as f:
pruning_history = json.load(f)
return {"pruned_trials": pruning_history}
return {"pruned_trials": pruning_history, "count": len(pruning_history)}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
@@ -468,7 +538,7 @@ async def get_optimizer_state(study_id: str):
"""Read realtime optimizer state from intelligent_optimizer/ (Protocol 13)"""
try:
study_dir = STUDIES_DIR / study_id
results_dir = study_dir / "2_results"
results_dir = get_results_dir(study_dir)
state_file = results_dir / "intelligent_optimizer" / "optimizer_state.json"
if not state_file.exists():
@@ -489,7 +559,7 @@ async def get_pareto_front(study_id: str):
"""Get Pareto-optimal solutions for multi-objective studies (Protocol 13)"""
try:
study_dir = STUDIES_DIR / study_id
results_dir = study_dir / "2_results"
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
if not study_db.exists():
@@ -700,7 +770,7 @@ async def get_optuna_dashboard_url(study_id: str):
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
results_dir = study_dir / "2_results"
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
if not study_db.exists():
@@ -809,7 +879,7 @@ async def download_report(study_id: str, filename: str):
raise HTTPException(status_code=400, detail="Invalid filename")
study_dir = STUDIES_DIR / study_id
results_dir = study_dir / "2_results"
results_dir = get_results_dir(study_dir)
file_path = results_dir / filename
@@ -838,3 +908,41 @@ async def download_report(study_id: str, filename: str):
raise HTTPException(status_code=404, detail=f"Report file not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to download report: {str(e)}")
@router.get("/studies/{study_id}/report")
async def get_study_report(study_id: str):
"""
Get the STUDY_REPORT.md file content for a study
Args:
study_id: Study identifier
Returns:
JSON with the markdown content
"""
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 STUDY_REPORT.md in the study root
report_path = study_dir / "STUDY_REPORT.md"
if not report_path.exists():
raise HTTPException(status_code=404, detail="No STUDY_REPORT.md found for this study")
with open(report_path, 'r', encoding='utf-8') as f:
content = f.read()
return {
"content": content,
"path": str(report_path),
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read study report: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,13 @@
"lucide-react": "^0.554.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0",
"react-use-websocket": "^4.13.0",
"recharts": "^2.10.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.4.0",
"three": "^0.181.2"
},

View File

@@ -0,0 +1,256 @@
/**
* Convergence Plot
* Shows optimization progress over time with running best and improvement rate
*/
import { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Area,
ComposedChart,
Legend
} from 'recharts';
interface Trial {
trial_number: number;
values: number[];
state?: string;
}
interface ConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
}
export function ConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize'
}: ConvergencePlotProps) {
const convergenceData = useMemo(() => {
if (!trials || trials.length === 0) return [];
// Sort by trial number
const sortedTrials = [...trials]
.filter(t => t.values && t.values.length > objectiveIndex && t.state !== 'FAIL')
.sort((a, b) => a.trial_number - b.trial_number);
if (sortedTrials.length === 0) return [];
let runningBest = direction === 'minimize' ? Infinity : -Infinity;
const data = sortedTrials.map((trial, idx) => {
const value = trial.values[objectiveIndex];
// Update running best
if (direction === 'minimize') {
runningBest = Math.min(runningBest, value);
} else {
runningBest = Math.max(runningBest, value);
}
// Calculate improvement from first trial
const firstValue = sortedTrials[0].values[objectiveIndex];
const improvement = direction === 'minimize'
? ((firstValue - runningBest) / firstValue) * 100
: ((runningBest - firstValue) / firstValue) * 100;
return {
trial: trial.trial_number,
value,
best: runningBest,
improvement: Math.max(0, improvement),
index: idx + 1
};
});
return data;
}, [trials, objectiveIndex, direction]);
// Calculate statistics
const stats = useMemo(() => {
if (convergenceData.length === 0) return null;
const values = convergenceData.map(d => d.value);
const best = convergenceData[convergenceData.length - 1]?.best ?? 0;
const first = convergenceData[0]?.value ?? 0;
const improvement = direction === 'minimize'
? ((first - best) / first) * 100
: ((best - first) / first) * 100;
// Calculate when we found 90% of the improvement
const targetImprovement = 0.9 * improvement;
let trialsTo90 = convergenceData.length;
for (let i = 0; i < convergenceData.length; i++) {
if (convergenceData[i].improvement >= targetImprovement) {
trialsTo90 = i + 1;
break;
}
}
return {
best,
first,
improvement: Math.max(0, improvement),
totalTrials: convergenceData.length,
trialsTo90,
mean: values.reduce((a, b) => a + b, 0) / values.length,
std: Math.sqrt(values.map(v => Math.pow(v - values.reduce((a, b) => a + b, 0) / values.length, 2)).reduce((a, b) => a + b, 0) / values.length)
};
}, [convergenceData, direction]);
if (convergenceData.length === 0) {
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<h3 className="text-lg font-semibold mb-4 text-dark-100">Convergence Plot</h3>
<div className="h-64 flex items-center justify-center text-dark-400">
No trial data available
</div>
</div>
);
}
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-dark-100">Convergence Plot</h3>
<p className="text-sm text-dark-400 mt-1">
{objectiveName} over {convergenceData.length} trials
</p>
</div>
{stats && (
<div className="flex gap-6 text-sm">
<div className="text-right">
<div className="text-dark-400">Best</div>
<div className="font-semibold text-green-400">{stats.best.toFixed(4)}</div>
</div>
<div className="text-right">
<div className="text-dark-400">Improvement</div>
<div className="font-semibold text-blue-400">{stats.improvement.toFixed(1)}%</div>
</div>
<div className="text-right">
<div className="text-dark-400">90% at trial</div>
<div className="font-semibold text-purple-400">#{stats.trialsTo90}</div>
</div>
</div>
)}
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={convergenceData} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="trial"
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
label={{ value: 'Trial', position: 'bottom', offset: -5, fill: '#94a3b8', fontSize: 12 }}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
tickFormatter={(v) => v.toFixed(2)}
label={{ value: objectiveName, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: number, name: string) => {
const label = name === 'value' ? 'Trial Value' :
name === 'best' ? 'Running Best' : name;
return [value.toFixed(4), label];
}}
labelFormatter={(label) => `Trial #${label}`}
/>
<Legend
verticalAlign="top"
height={36}
wrapperStyle={{ color: '#94a3b8' }}
formatter={(value) => value === 'value' ? 'Trial Value' : 'Running Best'}
/>
{/* Area under trial values */}
<Area
type="monotone"
dataKey="value"
stroke="#60a5fa"
fill="url(#colorValue)"
strokeWidth={0}
/>
{/* Trial values as scatter points */}
<Line
type="monotone"
dataKey="value"
stroke="#60a5fa"
strokeWidth={1}
dot={{ fill: '#60a5fa', r: 3 }}
activeDot={{ fill: '#93c5fd', r: 5 }}
/>
{/* Running best line */}
<Line
type="stepAfter"
dataKey="best"
stroke="#10b981"
strokeWidth={2.5}
dot={false}
/>
{/* Reference line at best value */}
{stats && (
<ReferenceLine
y={stats.best}
stroke="#10b981"
strokeDasharray="5 5"
strokeOpacity={0.5}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Summary stats bar */}
{stats && (
<div className="grid grid-cols-4 gap-4 mt-4 pt-4 border-t border-dark-500 text-center text-sm">
<div>
<div className="text-dark-400">First Value</div>
<div className="font-medium text-dark-100">{stats.first.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Mean</div>
<div className="font-medium text-dark-100">{stats.mean.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Std Dev</div>
<div className="font-medium text-dark-100">{stats.std.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Total Trials</div>
<div className="font-medium text-dark-100">{stats.totalTrials}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Parameter Importance Chart
* Shows which design parameters have the most impact on objectives
* Uses correlation analysis between parameters and objective values
*/
import { useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface DesignVariable {
name: string;
parameter?: string;
unit?: string;
}
interface ParameterImportanceChartProps {
trials: Trial[];
designVariables: DesignVariable[];
objectiveIndex?: number;
objectiveName?: string;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
if (x.length !== y.length || x.length < 2) return 0;
const n = x.length;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
if (denominator === 0) return 0;
return numerator / denominator;
}
export function ParameterImportanceChart({
trials,
designVariables,
objectiveIndex = 0,
objectiveName = 'Objective'
}: ParameterImportanceChartProps) {
const importanceData = useMemo(() => {
if (!trials || trials.length < 3 || !designVariables || designVariables.length === 0) {
return [];
}
// Extract objective values
const objectiveValues = trials
.filter(t => t.values && t.values.length > objectiveIndex)
.map(t => t.values[objectiveIndex]);
if (objectiveValues.length < 3) return [];
// Calculate correlation for each parameter
const importances = designVariables.map(dv => {
const paramName = dv.parameter || dv.name;
const paramValues = trials
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
.map(t => t.params[paramName]);
const corrObjectiveValues = trials
.filter(t => t.params && paramName in t.params && t.values && t.values.length > objectiveIndex)
.map(t => t.values[objectiveIndex]);
if (paramValues.length < 3) return { name: dv.name, importance: 0, correlation: 0 };
const correlation = pearsonCorrelation(paramValues, corrObjectiveValues);
// Use absolute correlation as importance (sign indicates direction)
const importance = Math.abs(correlation);
return {
name: dv.name,
importance: importance * 100, // Convert to percentage
correlation,
direction: correlation > 0 ? 'positive' : 'negative'
};
});
// Sort by importance (descending)
return importances.sort((a, b) => b.importance - a.importance);
}, [trials, designVariables, objectiveIndex]);
if (importanceData.length === 0) {
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<h3 className="text-lg font-semibold mb-4 text-dark-100">Parameter Importance</h3>
<div className="h-64 flex items-center justify-center text-dark-400">
Need at least 3 trials for correlation analysis
</div>
</div>
);
}
// Color based on correlation direction
const getBarColor = (entry: typeof importanceData[0]) => {
if (entry.correlation > 0) {
// Positive correlation (increasing param increases objective) - red for minimization
return '#f87171';
} else {
// Negative correlation (increasing param decreases objective) - green for minimization
return '#34d399';
}
};
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<div className="mb-4">
<h3 className="text-lg font-semibold text-dark-100">Parameter Importance</h3>
<p className="text-sm text-dark-400 mt-1">
Correlation with {objectiveName} ({trials.length} trials)
</p>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={importanceData}
layout="vertical"
margin={{ top: 5, right: 30, left: 120, bottom: 5 }}
>
<XAxis
type="number"
domain={[0, 100]}
tickFormatter={(v) => `${v.toFixed(0)}%`}
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
/>
<YAxis
type="category"
dataKey="name"
width={110}
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
/>
<Tooltip
formatter={(value: number, _name: string, props: any) => {
const corr = props.payload.correlation;
return [
`${value.toFixed(1)}% (r=${corr.toFixed(3)})`,
'Importance'
];
}}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}
labelStyle={{ color: '#e2e8f0' }}
/>
<Bar dataKey="importance" radius={[0, 4, 4, 0]}>
{importanceData.map((entry, index) => (
<Cell key={index} fill={getBarColor(entry)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex gap-6 justify-center mt-4 text-sm border-t border-dark-500 pt-4">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#34d399' }} />
<span className="text-dark-300">Negative correlation (helps minimize)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: '#f87171' }} />
<span className="text-dark-300">Positive correlation (hurts minimize)</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,278 @@
/**
* Study Report Viewer
* Displays the STUDY_REPORT.md file with proper markdown rendering
* Includes math equation support via KaTeX and syntax highlighting
*/
import { useState, useEffect } 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 { FileText, X, ExternalLink, RefreshCw } from 'lucide-react';
interface StudyReportViewerProps {
studyId: string;
studyPath?: string;
}
export function StudyReportViewer({ studyId, studyPath }: StudyReportViewerProps) {
const [isOpen, setIsOpen] = useState(false);
const [markdown, setMarkdown] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchReport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/optimization/studies/${studyId}/report`);
if (!response.ok) {
if (response.status === 404) {
setError('No STUDY_REPORT.md found for this study');
} else {
throw new Error(`HTTP ${response.status}`);
}
return;
}
const data = await response.json();
setMarkdown(data.content);
} catch (err) {
setError(`Failed to load report: ${err}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen && !markdown) {
fetchReport();
}
}, [isOpen, studyId]);
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<FileText size={16} />
Study Report
</button>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-dark-800 rounded-xl shadow-2xl w-[90vw] max-w-5xl h-[85vh] flex flex-col border border-dark-600">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600">
<div className="flex items-center gap-3">
<FileText className="text-primary-400" size={24} />
<div>
<h2 className="text-lg font-semibold text-dark-100">Study Report</h2>
<p className="text-sm text-dark-400">{studyId}/STUDY_REPORT.md</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchReport}
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw size={18} className={`text-dark-300 ${loading ? 'animate-spin' : ''}`} />
</button>
{studyPath && (
<a
href={`file:///${studyPath.replace(/\\/g, '/')}/STUDY_REPORT.md`}
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
title="Open in editor"
>
<ExternalLink size={18} className="text-dark-300" />
</a>
)}
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-dark-600 rounded-lg transition-colors"
>
<X size={20} className="text-dark-300" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-8 bg-dark-700">
{loading && (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-400"></div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full text-dark-400">
<FileText size={48} className="mb-4 opacity-50" />
<p>{error}</p>
<button
onClick={fetchReport}
className="mt-4 px-4 py-2 text-sm bg-dark-600 hover:bg-dark-500 text-dark-200 rounded-lg transition-colors"
>
Try Again
</button>
</div>
)}
{markdown && !loading && (
<article className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// Custom heading styles
h1: ({children}) => (
<h1 className="text-3xl font-bold text-dark-50 mb-6 pb-3 border-b border-dark-500">
{children}
</h1>
),
h2: ({children}) => (
<h2 className="text-2xl font-semibold text-dark-100 mt-8 mb-4 pb-2 border-b border-dark-600">
{children}
</h2>
),
h3: ({children}) => (
<h3 className="text-xl font-semibold text-dark-100 mt-6 mb-3">
{children}
</h3>
),
h4: ({children}) => (
<h4 className="text-lg font-medium text-dark-200 mt-4 mb-2">
{children}
</h4>
),
// Paragraph styling
p: ({children}) => (
<p className="text-dark-200 leading-relaxed mb-4">
{children}
</p>
),
// List styling
ul: ({children}) => (
<ul className="list-disc list-inside text-dark-200 mb-4 space-y-1 ml-2">
{children}
</ul>
),
ol: ({children}) => (
<ol className="list-decimal list-inside text-dark-200 mb-4 space-y-1 ml-2">
{children}
</ol>
),
li: ({children}) => (
<li className="text-dark-200 leading-relaxed">
{children}
</li>
),
// Strong/bold text
strong: ({children}) => (
<strong className="font-semibold text-dark-100">
{children}
</strong>
),
// Emphasis/italic
em: ({children}) => (
<em className="italic text-dark-200">
{children}
</em>
),
// Links
a: ({href, children}) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline decoration-primary-400/30 hover:decoration-primary-300"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Inline code
code: ({className, children, ...props}) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-dark-800 text-primary-300 px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
// Code block
return (
<code className={`${className} text-sm`} {...props}>
{children}
</code>
);
},
// Code blocks with pre wrapper
pre: ({children}) => (
<pre className="bg-dark-900 text-dark-100 p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono border border-dark-600">
{children}
</pre>
),
// Blockquotes
blockquote: ({children}) => (
<blockquote className="border-l-4 border-primary-500 pl-4 py-1 my-4 bg-dark-800/50 rounded-r italic text-dark-300">
{children}
</blockquote>
),
// Tables
table: ({children}) => (
<div className="overflow-x-auto mb-6">
<table className="min-w-full border-collapse border border-dark-500 text-sm">
{children}
</table>
</div>
),
thead: ({children}) => (
<thead className="bg-dark-600">
{children}
</thead>
),
th: ({children}) => (
<th className="border border-dark-500 px-4 py-2 text-left font-semibold text-dark-100">
{children}
</th>
),
td: ({children}) => (
<td className="border border-dark-500 px-4 py-2 text-dark-200">
{children}
</td>
),
tr: ({children}) => (
<tr className="hover:bg-dark-600/50 transition-colors">
{children}
</tr>
),
// Horizontal rule
hr: () => (
<hr className="border-dark-500 my-8" />
),
// Images
img: ({src, alt}) => (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded-lg border border-dark-500 my-4"
/>
),
}}
>
{markdown}
</ReactMarkdown>
</article>
)}
</div>
</div>
</div>
);
}

View File

@@ -11,6 +11,9 @@ import { StudyCard } from '../components/dashboard/StudyCard';
import { OptimizerPanel } from '../components/OptimizerPanel';
import { ParetoPlot } from '../components/ParetoPlot';
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import type { Study, Trial, ConvergenceDataPoint, ParameterSpaceDataPoint } from '../types';
export default function Dashboard() {
@@ -25,6 +28,10 @@ export default function Dashboard() {
const [expandedTrials, setExpandedTrials] = useState<Set<number>>(new Set());
const [sortBy, setSortBy] = useState<'performance' | 'chronological'>('performance');
// Parameter Space axis selection
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);
const [paretoFront, setParetoFront] = useState<any[]>([]);
@@ -102,7 +109,8 @@ export default function Dashboard() {
apiClient.getStudyPruning(selectedStudyId)
.then(data => {
setPrunedCount(data.pruned_trials?.length || 0);
// Use count if available (new API), fallback to array length (legacy)
setPrunedCount(data.count ?? data.pruned_trials?.length ?? 0);
})
.catch(console.error);
@@ -129,18 +137,29 @@ export default function Dashboard() {
})
.catch(err => console.error('Failed to load Pareto front:', err));
// Fetch ALL trials (not just Pareto) for parallel coordinates
// Fetch ALL trials (not just Pareto) for parallel coordinates and charts
fetch(`/api/optimization/studies/${selectedStudyId}/history`)
.then(res => res.json())
.then(data => {
// Transform to match the format expected by ParallelCoordinatesPlot
const trialsData = data.trials.map((t: any) => ({
trial_number: t.trial_number,
values: t.values || [],
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false
}));
// Transform to match the format expected by charts
// API returns 'objectives' (array) for multi-objective, 'objective' (number) for single
const trialsData = data.trials.map((t: any) => {
// Build values array: use objectives if available, otherwise wrap single objective
let values: number[] = [];
if (t.objectives && Array.isArray(t.objectives)) {
values = t.objectives;
} else if (t.objective !== null && t.objective !== undefined) {
values = [t.objective];
}
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false
};
});
setAllTrialsRaw(trialsData);
})
.catch(err => console.error('Failed to load all trials:', err));
@@ -204,8 +223,8 @@ export default function Dashboard() {
const params = Object.values(trial.design_variables);
return {
trial_number: trial.trial_number,
x: params[0] || 0,
y: params[1] || 0,
x: params[paramXIndex] || 0,
y: params[paramYIndex] || 0,
objective: trial.objective,
isBest: trial.objective === bestValue,
};
@@ -293,6 +312,9 @@ export default function Dashboard() {
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
</div>
<div className="flex gap-2">
{selectedStudyId && (
<StudyReportViewer studyId={selectedStudyId} />
)}
<button
onClick={() => {
// Open Optuna dashboard on port 8081
@@ -332,6 +354,18 @@ export default function Dashboard() {
{/* Main Content */}
<main className="col-span-9">
{/* Study Name Header */}
{selectedStudyId && (
<div className="mb-4 pb-3 border-b border-dark-600">
<h2 className="text-xl font-semibold text-primary-300">
{selectedStudyId}
</h2>
{studyMetadata?.description && (
<p className="text-sm text-dark-400 mt-1">{studyMetadata.description}</p>
)}
</div>
)}
{/* Metrics Grid */}
<div className="grid grid-cols-4 gap-4 mb-6">
<MetricCard label="Total Trials" value={allTrials.length} />
@@ -391,6 +425,31 @@ export default function Dashboard() {
</div>
)}
{/* New Enhanced Charts: Convergence + Parameter Importance */}
{allTrialsRaw.length > 0 && (
<div className="grid grid-cols-2 gap-6 mb-6">
<ConvergencePlot
trials={allTrialsRaw}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
/>
{/* Parameter Importance needs design_variables from metadata or inferred from trials */}
{(studyMetadata?.design_variables?.length > 0 || (allTrialsRaw[0]?.params && Object.keys(allTrialsRaw[0].params).length > 0)) && (
<ParameterImportanceChart
trials={allTrialsRaw}
designVariables={
studyMetadata?.design_variables?.length > 0
? studyMetadata.design_variables
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
/>
)}
</div>
)}
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Convergence Chart */}
@@ -437,8 +496,36 @@ export default function Dashboard() {
)}
</Card>
{/* Parameter Space Chart */}
<Card title={`Parameter Space (${paramNames[0] || 'X'} vs ${paramNames[1] || 'Y'})`}>
{/* Parameter Space Chart with Selectable Axes */}
<Card title={
<div className="flex items-center justify-between w-full">
<span>Parameter Space</span>
{paramNames.length > 2 && (
<div className="flex items-center gap-2 text-sm">
<span className="text-dark-400">X:</span>
<select
value={paramXIndex}
onChange={(e) => setParamXIndex(Number(e.target.value))}
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
>
{paramNames.map((name, idx) => (
<option key={idx} value={idx}>{name}</option>
))}
</select>
<span className="text-dark-400">Y:</span>
<select
value={paramYIndex}
onChange={(e) => setParamYIndex(Number(e.target.value))}
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
>
{paramNames.map((name, idx) => (
<option key={idx} value={idx}>{name}</option>
))}
</select>
</div>
)}
</div>
}>
{parameterSpaceData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
@@ -447,15 +534,15 @@ export default function Dashboard() {
type="number"
dataKey="x"
stroke="#94a3b8"
name={paramNames[0] || 'X'}
label={{ value: paramNames[0] || 'Parameter 1', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
name={paramNames[paramXIndex] || 'X'}
label={{ value: paramNames[paramXIndex] || 'Parameter X', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
/>
<YAxis
type="number"
dataKey="y"
stroke="#94a3b8"
name={paramNames[1] || 'Y'}
label={{ value: paramNames[1] || 'Parameter 2', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
name={paramNames[paramYIndex] || 'Y'}
label={{ value: paramNames[paramYIndex] || 'Parameter Y', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
/>
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
@@ -552,14 +639,38 @@ export default function Dashboard() {
</div>
</div>
{/* Quick Preview */}
{/* Quick Preview - Show ALL metrics */}
{!isExpanded && trial.results && Object.keys(trial.results).length > 0 && (
<div className="text-xs text-primary-300 flex flex-wrap gap-3 mt-2">
{trial.results.mass && (
<span>Mass: {trial.results.mass.toFixed(2)}g</span>
)}
{trial.results.frequency && (
<span>Freq: {trial.results.frequency.toFixed(2)}Hz</span>
{Object.entries(trial.results).slice(0, 6).map(([key, val]) => {
// Format value based on type
const formatValue = (v: unknown): string => {
if (typeof v === 'number') {
// Use fewer decimals for quick preview
return Math.abs(v) < 0.01 ? v.toExponential(2) : v.toFixed(2);
}
if (Array.isArray(v)) return `[${v.length}]`;
return String(v);
};
// Format key: snake_case to Title Case, abbreviate long names
const formatKey = (k: string): string => {
const short = k.replace(/_/g, ' ')
.replace(/rel /g, 'Δ')
.replace(/filtered rms/g, 'fRMS')
.replace(/global rms/g, 'gRMS')
.replace(/ vs /g, '/')
.replace(/mfg /g, '')
.replace(/optician workload/g, 'work');
return short.length > 15 ? short.slice(0, 12) + '...' : short;
};
return (
<span key={key} title={`${key}: ${val}`}>
{formatKey(key)}: {formatValue(val)}
</span>
);
})}
{Object.keys(trial.results).length > 6 && (
<span className="text-dark-400">+{Object.keys(trial.results).length - 6} more</span>
)}
</div>
)}

View File

@@ -1,6 +1,6 @@
# Atomizer Dashboard
**Last Updated**: November 23, 2025
**Last Updated**: December 3, 2025
---
@@ -83,23 +83,50 @@ Displays algorithm information:
- **Objectives Count**: Number of optimization objectives
- **Design Variables Count**: Number of design parameters
### 3. Convergence Monitoring
- **Convergence Plot**: Best value vs. trial number
- **Real-time Updates**: WebSocket-driven live updates
- **Pruned Trials**: Visual indication of pruned trials
### 3. Convergence Plot (Enhanced)
**File**: `atomizer-dashboard/frontend/src/components/ConvergencePlot.tsx`
### 4. Parameter Space Exploration
- **2D Scatter Plot**: Design variable relationships
- **Color Mapping**: Objective values mapped to color intensity
- **Interactive Tooltips**: Trial details on hover
Advanced convergence visualization:
- **Dual-line plot**: Individual trial values + running best trajectory
- **Area fill**: Gradient under trial values curve
- **Statistics panel**: Best value, improvement %, 90% convergence trial
- **Summary footer**: First value, mean, std dev, total trials
- **Step-after interpolation**: Running best shown as step function
### 5. Trial History Table
### 4. Parameter Importance Chart
**File**: `atomizer-dashboard/frontend/src/components/ParameterImportanceChart.tsx`
Correlation-based parameter analysis:
- **Pearson correlation**: Calculates correlation between each parameter and objective
- **Horizontal bar chart**: Parameters ranked by absolute importance
- **Color coding**: Green (negative correlation - helps minimize), Red (positive - hurts minimize)
- **Tooltip**: Shows percentage importance and raw correlation coefficient (r)
- **Minimum 3 trials**: Required for statistical significance
### 5. Study Report Viewer
**File**: `atomizer-dashboard/frontend/src/components/StudyReportViewer.tsx`
Full-featured markdown report viewer:
- **Modal overlay**: Full-screen report viewing
- **Math equations**: KaTeX support for LaTeX math (`$...$` inline, `$$...$$` block)
- **GitHub-flavored markdown**: Tables, code blocks, task lists
- **Custom styling**: Dark theme with proper typography
- **Syntax highlighting**: Code blocks with language detection
- **Refresh button**: Re-fetch report for live updates
- **External link**: Open in system editor
### 6. Trial History Table
- Comprehensive list of all trials
- Sortable columns
- Status indicators (COMPLETE, PRUNED, FAIL)
- Parameter values and objective values
- User attributes (constraints)
### 7. Pruned Trials Tracking
- **Real-time count**: Fetched directly from Optuna database
- **Pruning diagnostics**: Tracks pruned trial params and causes
- **Database query**: Uses SQLite `state = 'PRUNED'` filter
---
## API Endpoints
@@ -286,6 +313,9 @@ atomizer-dashboard/
│ │ │ ├── ParallelCoordinatesPlot.tsx # Multi-objective visualization
│ │ │ ├── ParetoPlot.tsx # Pareto front scatter plot
│ │ │ ├── OptimizerPanel.tsx # Strategy information
│ │ │ ├── ConvergencePlot.tsx # Enhanced convergence chart
│ │ │ ├── ParameterImportanceChart.tsx # Correlation-based importance
│ │ │ ├── StudyReportViewer.tsx # Markdown report viewer
│ │ │ ├── common/
│ │ │ │ └── Card.tsx # Reusable card component
│ │ │ └── dashboard/
@@ -307,6 +337,15 @@ atomizer-dashboard/
└── optimization.py # Optimization endpoints
```
## NPM Dependencies
The frontend uses these key packages:
- `react-markdown` - Markdown rendering
- `remark-gfm` - GitHub-flavored markdown support
- `remark-math` - Math equation parsing
- `rehype-katex` - KaTeX math rendering
- `recharts` - Interactive charts
---
## Data Flow
@@ -368,7 +407,16 @@ if (!objectives || !designVariables) return <EmptyState />;
---
## Future Enhancements
## Recent Updates (December 2025)
### Completed
- [x] **Convergence Plot**: Enhanced with running best, statistics, and gradient fill
- [x] **Parameter Importance Chart**: Correlation analysis with color-coded bars
- [x] **Study Report Viewer**: Full markdown rendering with KaTeX math support
- [x] **Pruned Trials**: Real-time count from Optuna database (not JSON file)
- [x] **Chart Data Transformation**: Fixed `values` array mapping for single/multi-objective
### Future Enhancements
- [ ] 3D Pareto front visualization for 3+ objectives
- [ ] Advanced filtering and search in trial history

View File

@@ -1,7 +1,7 @@
# Protocol 13: Real-Time Dashboard Tracking
**Status**: ✅ COMPLETED
**Date**: November 21, 2025
**Status**: ✅ COMPLETED (Enhanced December 2025)
**Date**: November 21, 2025 (Last Updated: December 3, 2025)
**Priority**: P1 (Critical)
## Overview
@@ -130,7 +130,39 @@ Stiffness Mass support_angle tip_thickness
| ╲ |
```
#### 4. Dashboard Integration
#### 4. ConvergencePlot Component (NEW - December 2025)
**File**: `atomizer-dashboard/frontend/src/components/ConvergencePlot.tsx`
**Features**:
- Dual-line visualization: trial values + running best
- Area fill gradient under trial curve
- Statistics header: Best value, Improvement %, 90% convergence trial
- Summary footer: First value, Mean, Std Dev, Total trials
- Step-after interpolation for running best line
- Reference line at best value
#### 5. ParameterImportanceChart Component (NEW - December 2025)
**File**: `atomizer-dashboard/frontend/src/components/ParameterImportanceChart.tsx`
**Features**:
- Pearson correlation between parameters and objectives
- Horizontal bar chart sorted by absolute importance
- Color coding: Green (negative correlation), Red (positive correlation)
- Tooltip with percentage and raw correlation coefficient
- Requires minimum 3 trials for statistical analysis
#### 6. StudyReportViewer Component (NEW - December 2025)
**File**: `atomizer-dashboard/frontend/src/components/StudyReportViewer.tsx`
**Features**:
- Full-screen modal for viewing STUDY_REPORT.md
- KaTeX math equation rendering (`$...$` inline, `$$...$$` block)
- GitHub-flavored markdown (tables, code blocks, task lists)
- Custom dark theme styling for all markdown elements
- Refresh button for live updates
- External link to open in system editor
#### 7. Dashboard Integration
**File**: `atomizer-dashboard/frontend/src/pages/Dashboard.tsx`
**Layout Structure**:
@@ -292,6 +324,28 @@ npm run dev # Runs on port 3001
- **Real-time writes**: <5ms per trial (JSON serialization)
- **Dashboard load time**: <500ms initial render
## December 2025 Enhancements
### Completed
- [x] **ConvergencePlot**: Enhanced with running best, statistics panel, gradient fill
- [x] **ParameterImportanceChart**: Pearson correlation analysis with color-coded bars
- [x] **StudyReportViewer**: Full markdown rendering with KaTeX math equation support
- [x] **Pruning endpoint**: Now queries Optuna SQLite directly instead of JSON file
- [x] **Report endpoint**: New `/studies/{id}/report` endpoint for STUDY_REPORT.md
- [x] **Chart data fix**: Proper `values` array transformation for single/multi-objective
### API Endpoint Additions (December 2025)
4. **GET `/api/optimization/studies/{study_id}/pruning`** (Enhanced)
- Now queries Optuna database directly for PRUNED trials
- Returns params, timing, and pruning cause for each trial
- Fallback to legacy JSON file if database unavailable
5. **GET `/api/optimization/studies/{study_id}/report`** (NEW)
- Returns STUDY_REPORT.md content as JSON
- Searches in 2_results/, 3_results/, and study root
- Returns 404 if no report found
## Future Enhancements (P3)
- [ ] WebSocket support for instant updates (currently polling)
@@ -300,7 +354,6 @@ npm run dev # Runs on port 3001
- [ ] Strategy performance comparison charts
- [ ] Historical phase duration analysis
- [ ] Mobile-responsive design
- [ ] Dark/light theme toggle
## Troubleshooting