Compare commits
13 Commits
5c419e2358
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f18dc377 | |||
| 2cb8dccc3a | |||
| c224b16ac3 | |||
| e1c59a51c1 | |||
| f725e75164 | |||
| e954b130f5 | |||
| 5b22439357 | |||
| 0c252e3a65 | |||
| 4749944a48 | |||
| 3229c31349 | |||
| 14354a2606 | |||
| abbc7b1b50 | |||
| 1cdcc17ffd |
@@ -1 +0,0 @@
|
||||
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}
|
||||
@@ -1,45 +0,0 @@
|
||||
# Atomizer Assistant
|
||||
|
||||
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
|
||||
|
||||
**Current Mode**: USER
|
||||
|
||||
Your role:
|
||||
- Help engineers with FEA optimization workflows
|
||||
- Create, configure, and run optimization studies
|
||||
- Analyze results and provide insights
|
||||
- Explain FEA concepts and methodology
|
||||
|
||||
Important guidelines:
|
||||
- Be concise and professional
|
||||
- Use technical language appropriate for engineers
|
||||
- You are "Atomizer Assistant", not a generic AI
|
||||
- Use the available MCP tools to perform actions
|
||||
- When asked about studies, use the appropriate tools to get real data
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Current Study: m1_mirror_flatback_lateral
|
||||
|
||||
**Status**: Study directory not found.
|
||||
|
||||
---
|
||||
|
||||
# User Mode Instructions
|
||||
|
||||
You can help with optimization workflows:
|
||||
- Create and configure studies
|
||||
- Run optimizations
|
||||
- Analyze results
|
||||
- Generate reports
|
||||
- Explain FEA concepts
|
||||
|
||||
**For code modifications**, suggest switching to Power Mode.
|
||||
|
||||
Available tools:
|
||||
- `list_studies`, `get_study_status`, `create_study`
|
||||
- `run_optimization`, `stop_optimization`, `get_optimization_status`
|
||||
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
|
||||
- `generate_report`, `export_data`
|
||||
- `explain_physics`, `recommend_method`, `query_extractors`
|
||||
@@ -1,45 +0,0 @@
|
||||
# Atomizer Assistant
|
||||
|
||||
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
|
||||
|
||||
**Current Mode**: USER
|
||||
|
||||
Your role:
|
||||
- Help engineers with FEA optimization workflows
|
||||
- Create, configure, and run optimization studies
|
||||
- Analyze results and provide insights
|
||||
- Explain FEA concepts and methodology
|
||||
|
||||
Important guidelines:
|
||||
- Be concise and professional
|
||||
- Use technical language appropriate for engineers
|
||||
- You are "Atomizer Assistant", not a generic AI
|
||||
- Use the available MCP tools to perform actions
|
||||
- When asked about studies, use the appropriate tools to get real data
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Current Study: m1_mirror_flatback_lateral
|
||||
|
||||
**Status**: Study directory not found.
|
||||
|
||||
---
|
||||
|
||||
# User Mode Instructions
|
||||
|
||||
You can help with optimization workflows:
|
||||
- Create and configure studies
|
||||
- Run optimizations
|
||||
- Analyze results
|
||||
- Generate reports
|
||||
- Explain FEA concepts
|
||||
|
||||
**For code modifications**, suggest switching to Power Mode.
|
||||
|
||||
Available tools:
|
||||
- `list_studies`, `get_study_status`, `create_study`
|
||||
- `run_optimization`, `stop_optimization`, `get_optimization_status`
|
||||
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
|
||||
- `generate_report`, `export_data`
|
||||
- `explain_physics`, `recommend_method`, `query_extractors`
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -110,5 +110,17 @@ _dat_run*.dat
|
||||
.claude-mcp-*.json
|
||||
.claude-prompt-*.md
|
||||
|
||||
# Backend logs
|
||||
backend_stdout.log
|
||||
backend_stderr.log
|
||||
*.log.bak
|
||||
|
||||
# Linter/formatter caches
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
|
||||
docs/generated/
|
||||
|
||||
# Malformed filenames (Windows path used as filename)
|
||||
C:*
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2025-01-24
|
||||
|
||||
### Project Cleanup & Organization
|
||||
- Deleted 102+ orphaned MCP session temp files
|
||||
- Removed build artifacts (htmlcov, dist, __pycache__)
|
||||
- Archived superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
|
||||
- Moved debug/analysis scripts from tests/ to tools/analysis/
|
||||
- Updated .gitignore with missing patterns
|
||||
- Cleaned empty directories
|
||||
|
||||
## [0.4.0] - 2025-01-22
|
||||
|
||||
### Canvas UX Improvements (Phases 7-9)
|
||||
- **Resizable Panels**: Left sidebar (200-400px) and right panel (280-600px) with localStorage persistence
|
||||
- **All Palette Items Enabled**: All 8 node types now draggable (model, solver, designVar, extractor, objective, constraint, algorithm, surrogate)
|
||||
- **Solver Configuration**: Engine selection (NX Nastran, MSC Nastran, Python Script) with solution type dropdowns (SOL101-SOL200)
|
||||
|
||||
### AtomizerSpec v2.0
|
||||
- Unified JSON configuration schema for all studies
|
||||
- Added SolverEngine and NastranSolutionType types
|
||||
- Canvas position persistence for all nodes
|
||||
- Migration support from legacy optimization_config.json
|
||||
|
||||
## [0.3.0] - 2025-01-18
|
||||
|
||||
### Dashboard V3.1 - Canvas Builder
|
||||
- Visual workflow builder with 9 node types
|
||||
- Spec ↔ ReactFlow bidirectional converter
|
||||
- WebSocket real-time synchronization
|
||||
- Claude chat integration
|
||||
- Custom extractors with in-canvas code editor
|
||||
- Model introspection panel
|
||||
|
||||
### Learning Atomizer Core (LAC)
|
||||
- Persistent memory system for accumulated knowledge
|
||||
- Session insights recording (failures, workarounds, patterns)
|
||||
- Optimization outcome tracking
|
||||
|
||||
## [0.2.5] - 2025-01-16
|
||||
|
||||
### GNN Surrogate for Zernike Optimization
|
||||
- PolarMirrorGraph with fixed 3000-node polar grid
|
||||
- ZernikeGNN model with design-conditioned convolutions
|
||||
- Differentiable GPU-accelerated Zernike fitting
|
||||
- Training pipeline with multi-task loss
|
||||
|
||||
### DevLoop Automation
|
||||
- Closed-loop development system with AI agents
|
||||
- Gemini planning, Claude implementation
|
||||
- Playwright browser testing for dashboard UI
|
||||
|
||||
## [0.2.1] - 2025-01-07
|
||||
|
||||
### Optimization Engine v2.0 Restructure
|
||||
- Reorganized into modular subpackages (core/, nx/, study/, config/)
|
||||
- SpecManager for AtomizerSpec handling
|
||||
- Deprecation warnings for old import paths
|
||||
|
||||
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
|
||||
|
||||
#### Added
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
|
||||
- If no study context: Offer to create one or list available studies
|
||||
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
|
||||
|
||||
### Step 5: Use DevLoop for Multi-Step Development Tasks
|
||||
|
||||
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
|
||||
|
||||
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
|
||||
|
||||
```bash
|
||||
# Plan a task with Gemini
|
||||
python tools/devloop_cli.py plan "fix extractor exports"
|
||||
|
||||
# Implement with Claude
|
||||
python tools/devloop_cli.py implement
|
||||
|
||||
# Test filesystem/API
|
||||
python tools/devloop_cli.py test --study support_arm
|
||||
|
||||
# Test dashboard UI with Playwright
|
||||
python tools/devloop_cli.py browser --level full
|
||||
|
||||
# Analyze failures
|
||||
python tools/devloop_cli.py analyze
|
||||
|
||||
# Full autonomous cycle
|
||||
python tools/devloop_cli.py start "add new stress extractor"
|
||||
```
|
||||
|
||||
**When to use DevLoop:**
|
||||
- Fixing bugs that require multiple file changes
|
||||
- Adding new features or extractors
|
||||
- Debugging optimization failures
|
||||
- Testing dashboard UI changes
|
||||
- Any task that would take 3+ manual steps
|
||||
|
||||
**Browser test levels:**
|
||||
- `quick` - Smoke test (1 test)
|
||||
- `home` - Home page verification (2 tests)
|
||||
- `full` - All UI tests (5+ tests)
|
||||
- `study` - Canvas/dashboard for specific study
|
||||
|
||||
**DO NOT default to manual debugging** - use the automation we built!
|
||||
|
||||
**Full documentation**: `docs/guides/DEVLOOP.md`
|
||||
|
||||
---
|
||||
|
||||
## Quick Start - Protocol Operating System
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ResizeHandle - Visual drag handle for resizable panels
|
||||
*
|
||||
* A thin vertical bar that can be dragged to resize panels.
|
||||
* Shows visual feedback on hover and during drag.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
/** Mouse down handler to start dragging */
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
/** Double click handler to reset size */
|
||||
onDoubleClick?: () => void;
|
||||
/** Whether panel is currently being dragged */
|
||||
isDragging?: boolean;
|
||||
/** Position of the handle ('left' or 'right' edge of the panel) */
|
||||
position?: 'left' | 'right';
|
||||
}
|
||||
|
||||
function ResizeHandleComponent({
|
||||
onMouseDown,
|
||||
onDoubleClick,
|
||||
isDragging = false,
|
||||
position = 'right',
|
||||
}: ResizeHandleProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 bottom-0 w-1 z-30
|
||||
cursor-col-resize
|
||||
transition-colors duration-150
|
||||
${position === 'right' ? 'right-0' : 'left-0'}
|
||||
${isDragging
|
||||
? 'bg-primary-500'
|
||||
: 'bg-transparent hover:bg-primary-500/50'
|
||||
}
|
||||
`}
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
title="Drag to resize, double-click to reset"
|
||||
>
|
||||
{/* Wider hit area for easier grabbing */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 bottom-0 w-3
|
||||
${position === 'right' ? '-left-1' : '-right-1'}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Visual indicator dots (shown on hover via CSS) */}
|
||||
<div className={`
|
||||
absolute top-1/2 -translate-y-1/2
|
||||
${position === 'right' ? '-left-0.5' : '-right-0.5'}
|
||||
flex flex-col gap-1 opacity-0 hover:opacity-100 transition-opacity
|
||||
${isDragging ? 'opacity-100' : ''}
|
||||
`}>
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ResizeHandle = memo(ResizeHandleComponent);
|
||||
export default ResizeHandle;
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
|
||||
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
@@ -37,23 +38,34 @@ import {
|
||||
useSelectedEdgeId,
|
||||
} from '../../hooks/useSpecStore';
|
||||
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
|
||||
import { usePanelStore } from '../../hooks/usePanelStore';
|
||||
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
|
||||
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
import { ProgressRing } from './visualization/ConvergenceSparkline';
|
||||
import { CanvasNodeData } from '../../lib/canvas/schema';
|
||||
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
|
||||
|
||||
// ============================================================================
|
||||
// Drag-Drop Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Addable node types via drag-drop */
|
||||
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
|
||||
import { SINGLETON_TYPES } from './palette/NodePalette';
|
||||
|
||||
/** All node types that can be added via drag-drop */
|
||||
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
|
||||
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
|
||||
|
||||
function isAddableNodeType(type: string): type is AddableNodeType {
|
||||
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
|
||||
}
|
||||
|
||||
/** Check if a node type is a singleton (only one allowed) */
|
||||
function isSingletonType(type: string): boolean {
|
||||
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
|
||||
}
|
||||
|
||||
/** Maps canvas NodeType to spec API type */
|
||||
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' {
|
||||
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
|
||||
return type;
|
||||
}
|
||||
|
||||
@@ -62,6 +74,22 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
||||
const timestamp = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'model':
|
||||
return {
|
||||
name: 'Model',
|
||||
sim: {
|
||||
path: '',
|
||||
solver: 'nastran',
|
||||
},
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'solver':
|
||||
return {
|
||||
name: 'Solver',
|
||||
engine: 'nxnastran',
|
||||
solution_type: 'SOL101',
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'designVar':
|
||||
return {
|
||||
name: `variable_${timestamp}`,
|
||||
@@ -125,6 +153,23 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
||||
enabled: true,
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'algorithm':
|
||||
return {
|
||||
name: 'Algorithm',
|
||||
type: 'TPE',
|
||||
budget: {
|
||||
max_trials: 100,
|
||||
},
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'surrogate':
|
||||
return {
|
||||
name: 'Surrogate',
|
||||
enabled: false,
|
||||
model_type: 'MLP',
|
||||
min_trials: 20,
|
||||
canvas_position: position,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +246,161 @@ function SpecRendererInner({
|
||||
const wsStudyId = enableWebSocket ? storeStudyId : null;
|
||||
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
|
||||
|
||||
// Panel store for validation and error panels
|
||||
const { setValidationData, addError, openPanel } = usePanelStore();
|
||||
|
||||
// Optimization WebSocket stream for real-time updates
|
||||
const {
|
||||
status: optimizationStatus,
|
||||
progress: wsProgress,
|
||||
bestTrial: wsBestTrial,
|
||||
recentTrials,
|
||||
} = useOptimizationStream(studyId, {
|
||||
autoReportErrors: true,
|
||||
onTrialComplete: (trial) => {
|
||||
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
|
||||
},
|
||||
onNewBest: (best) => {
|
||||
console.log('[SpecRenderer] New best found:', best.value);
|
||||
setShowResults(true); // Auto-show results when new best found
|
||||
},
|
||||
});
|
||||
|
||||
// Optimization execution state
|
||||
const isRunning = optimizationStatus === 'running';
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
|
||||
|
||||
// Build trial history for sparklines (extract objective values from recent trials)
|
||||
const trialHistory = useMemo(() => {
|
||||
const history: Record<string, number[]> = {};
|
||||
for (const trial of recentTrials) {
|
||||
// Map objective values - assumes single objective for now
|
||||
if (trial.objective !== null) {
|
||||
const key = 'primary';
|
||||
if (!history[key]) history[key] = [];
|
||||
history[key].push(trial.objective);
|
||||
}
|
||||
// Could also extract individual params/results for multi-objective
|
||||
}
|
||||
// Reverse so oldest is first (for sparkline)
|
||||
for (const key of Object.keys(history)) {
|
||||
history[key].reverse();
|
||||
}
|
||||
return history;
|
||||
}, [recentTrials]);
|
||||
|
||||
// Build best trial data for node display
|
||||
const bestTrial = useMemo((): {
|
||||
trial_number: number;
|
||||
objective: number;
|
||||
design_variables: Record<string, number>;
|
||||
results: Record<string, number>;
|
||||
} | null => {
|
||||
if (!wsBestTrial) return null;
|
||||
return {
|
||||
trial_number: wsBestTrial.trial_number,
|
||||
objective: wsBestTrial.value,
|
||||
design_variables: wsBestTrial.params,
|
||||
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
|
||||
};
|
||||
}, [wsBestTrial]);
|
||||
|
||||
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
|
||||
// The hook handles: status updates, best trial updates, error reporting
|
||||
|
||||
// Validate the spec and show results in panel
|
||||
const handleValidate = useCallback(() => {
|
||||
if (!spec) return;
|
||||
|
||||
const result = validateSpec(spec);
|
||||
setValidationData(result);
|
||||
setValidationStatus(result.valid ? 'valid' : 'invalid');
|
||||
|
||||
// Auto-open validation panel if there are issues
|
||||
if (!result.valid || result.warnings.length > 0) {
|
||||
openPanel('validation');
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [spec, setValidationData, openPanel]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!studyId || !spec) return;
|
||||
|
||||
// Validate before running
|
||||
const validation = handleValidate();
|
||||
if (!validation || !validation.valid) {
|
||||
// Show validation panel with errors
|
||||
return;
|
||||
}
|
||||
|
||||
// Also do a quick sanity check
|
||||
const { canRun, reason } = canRunOptimization(spec);
|
||||
if (!canRun) {
|
||||
addError({
|
||||
type: 'config_error',
|
||||
message: reason || 'Cannot run optimization',
|
||||
recoverable: false,
|
||||
suggestions: ['Check the validation panel for details'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Failed to start');
|
||||
}
|
||||
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
|
||||
setValidationStatus('unchecked'); // Clear validation status when running
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
|
||||
setError(errorMessage);
|
||||
|
||||
// Also add to error panel for persistence
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorMessage,
|
||||
recoverable: true,
|
||||
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to stop');
|
||||
}
|
||||
// isRunning will update via WebSocket when optimization actually stops
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
|
||||
setError(errorMessage);
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorMessage,
|
||||
recoverable: false,
|
||||
suggestions: ['The optimization may still be running in the background'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load spec on mount if studyId provided
|
||||
useEffect(() => {
|
||||
if (studyId) {
|
||||
@@ -214,8 +414,58 @@ function SpecRendererInner({
|
||||
|
||||
// Convert spec to ReactFlow nodes
|
||||
const nodes = useMemo(() => {
|
||||
return specToNodes(spec);
|
||||
}, [spec]);
|
||||
const baseNodes = specToNodes(spec);
|
||||
|
||||
// Always map nodes to include history for sparklines (even if not showing results)
|
||||
return baseNodes.map(node => {
|
||||
// Create a mutable copy with explicit any type for dynamic property assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newData: any = { ...node.data };
|
||||
|
||||
// Add history for sparklines on objective nodes
|
||||
if (node.type === 'objective') {
|
||||
newData.history = trialHistory['primary'] || [];
|
||||
}
|
||||
|
||||
// Map results to nodes when showing results
|
||||
if (showResults && bestTrial) {
|
||||
if (node.type === 'designVar' && newData.expressionName) {
|
||||
const val = bestTrial.design_variables?.[newData.expressionName];
|
||||
if (val !== undefined) newData.resultValue = val;
|
||||
} else if (node.type === 'objective') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results?.[outputName] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[outputName];
|
||||
}
|
||||
} else if (node.type === 'constraint') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results?.[outputName] !== undefined) {
|
||||
const val = bestTrial.results[outputName];
|
||||
newData.resultValue = val;
|
||||
|
||||
// Check feasibility
|
||||
const op = newData.operator;
|
||||
const threshold = newData.value;
|
||||
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
|
||||
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
|
||||
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
|
||||
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
|
||||
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
|
||||
}
|
||||
} else if (node.type === 'extractor') {
|
||||
const outputNames = newData.outputNames;
|
||||
if (outputNames && outputNames.length > 0 && bestTrial.results) {
|
||||
const firstOut = outputNames[0];
|
||||
if (bestTrial.results[firstOut] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[firstOut];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...node, data: newData };
|
||||
});
|
||||
}, [spec, showResults, bestTrial, trialHistory]);
|
||||
|
||||
// Convert spec to ReactFlow edges with selection styling
|
||||
const edges = useMemo(() => {
|
||||
@@ -392,6 +642,18 @@ function SpecRendererInner({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a singleton type that already exists
|
||||
if (isSingletonType(type)) {
|
||||
const existingNode = localNodes.find(n => n.type === type);
|
||||
if (existingNode) {
|
||||
// Select the existing node instead of creating a duplicate
|
||||
selectNode(existingNode.id);
|
||||
// Show a toast notification would be nice here
|
||||
console.log(`${type} already exists - selected existing node`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert screen position to flow position
|
||||
const position = reactFlowInstance.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
@@ -402,8 +664,19 @@ function SpecRendererInner({
|
||||
const nodeData = getDefaultNodeData(type, position);
|
||||
const specType = mapNodeTypeToSpecType(type);
|
||||
|
||||
// For structural types (model, solver, algorithm, surrogate), these are
|
||||
// part of the spec structure rather than array items. Handle differently.
|
||||
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
if (structuralTypes.includes(type)) {
|
||||
// These nodes are derived from spec structure - they shouldn't be "added"
|
||||
// They already exist if the spec has that section configured
|
||||
console.log(`${type} is a structural node - configure via spec directly`);
|
||||
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeId = await addNode(specType, nodeData);
|
||||
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
|
||||
// Select the newly created node
|
||||
selectNode(nodeId);
|
||||
} catch (err) {
|
||||
@@ -411,7 +684,7 @@ function SpecRendererInner({
|
||||
setError(err instanceof Error ? err.message : 'Failed to add node');
|
||||
}
|
||||
},
|
||||
[editable, addNode, selectNode, setError]
|
||||
[editable, addNode, selectNode, setError, localNodes]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
@@ -527,10 +800,113 @@ function SpecRendererInner({
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
|
||||
{/* Results toggle */}
|
||||
{bestTrial && (
|
||||
<button
|
||||
onClick={() => setShowResults(!showResults)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
|
||||
showResults
|
||||
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
|
||||
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
|
||||
}`}
|
||||
title={showResults ? "Hide Results" : "Show Best Trial Results"}
|
||||
>
|
||||
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||
<span className="text-sm font-medium">Results</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Validate button - shows validation status */}
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
|
||||
validationStatus === 'valid'
|
||||
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
|
||||
: validationStatus === 'invalid'
|
||||
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
|
||||
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
|
||||
}`}
|
||||
title="Validate spec before running"
|
||||
>
|
||||
{validationStatus === 'valid' ? (
|
||||
<CheckCircle size={16} />
|
||||
) : validationStatus === 'invalid' ? (
|
||||
<AlertCircle size={16} />
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
<span className="text-sm font-medium">Validate</span>
|
||||
</button>
|
||||
|
||||
{/* Run/Stop button */}
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isStarting || validationStatus === 'invalid'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
|
||||
validationStatus === 'invalid'
|
||||
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
|
||||
>
|
||||
{isStarting ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" />
|
||||
)}
|
||||
Run
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Study name badge */}
|
||||
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
|
||||
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator when running */}
|
||||
{isRunning && wsProgress && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
|
||||
<ProgressRing
|
||||
progress={wsProgress.percentage}
|
||||
size={36}
|
||||
strokeWidth={3}
|
||||
color="#10b981"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Trial {wsProgress.current} / {wsProgress.total}
|
||||
</span>
|
||||
<span className="text-xs text-dark-400">
|
||||
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
|
||||
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
|
||||
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
|
||||
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
|
||||
</span>
|
||||
</div>
|
||||
{wsBestTrial && (
|
||||
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
|
||||
<span className="text-xs text-dark-400">Best</span>
|
||||
<span className="text-sm font-medium text-emerald-400">
|
||||
{typeof wsBestTrial.value === 'number'
|
||||
? wsBestTrial.value.toFixed(4)
|
||||
: wsBestTrial.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
|
||||
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
|
||||
{data.name && data.operator && data.value !== undefined
|
||||
? `${data.name} ${data.operator} ${data.value}`
|
||||
: 'Set constraint'}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||
<ResultBadge value={data.resultValue} unit={data.unit} />
|
||||
{data.expressionName ? (
|
||||
<span className="font-mono">{data.expressionName}</span>
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
||||
<ResultBadge value={data.resultValue} />
|
||||
{data.extractorName || 'Select extractor'}
|
||||
</BaseNode>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,38 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Target } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
|
||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||
const { data } = props;
|
||||
const hasHistory = data.history && data.history.length > 1;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
</span>
|
||||
<ResultBadge value={data.resultValue} label="Best" />
|
||||
</div>
|
||||
|
||||
{/* Convergence sparkline */}
|
||||
{hasHistory && (
|
||||
<div className="mt-1 -mb-1">
|
||||
<ConvergenceSparkline
|
||||
values={data.history!}
|
||||
width={120}
|
||||
height={20}
|
||||
direction={data.direction || 'minimize'}
|
||||
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
|
||||
showBest={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ResultBadgeProps {
|
||||
value: number | string | null | undefined;
|
||||
unit?: string;
|
||||
isFeasible?: boolean; // For constraints
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const displayValue = typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
: value;
|
||||
|
||||
// Determine color based on feasibility (if provided)
|
||||
let bgColor = 'bg-primary-500/20';
|
||||
let textColor = 'text-primary-300';
|
||||
let borderColor = 'border-primary-500/30';
|
||||
|
||||
if (isFeasible === true) {
|
||||
bgColor = 'bg-emerald-500/20';
|
||||
textColor = 'text-emerald-300';
|
||||
borderColor = 'border-emerald-500/30';
|
||||
} else if (isFeasible === false) {
|
||||
bgColor = 'bg-red-500/20';
|
||||
textColor = 'text-red-300';
|
||||
borderColor = 'border-red-500/30';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
|
||||
{label && <span className="opacity-70 mr-1">{label}:</span>}
|
||||
<span className="font-bold">{displayValue}</span>
|
||||
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,44 @@
|
||||
import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { Cpu, Terminal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { SolverNodeData } from '../../../lib/canvas/schema';
|
||||
import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
|
||||
|
||||
// Human-readable engine names
|
||||
const ENGINE_LABELS: Record<SolverEngine, string> = {
|
||||
nxnastran: 'NX Nastran',
|
||||
mscnastran: 'MSC Nastran',
|
||||
python: 'Python Script',
|
||||
abaqus: 'Abaqus',
|
||||
ansys: 'ANSYS',
|
||||
};
|
||||
|
||||
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
// Build display string: "Engine - SolutionType" or just one
|
||||
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
|
||||
const solverTypeLabel = data.solverType || null;
|
||||
|
||||
let displayText: string;
|
||||
if (engineLabel && solverTypeLabel) {
|
||||
displayText = `${engineLabel} (${solverTypeLabel})`;
|
||||
} else if (engineLabel) {
|
||||
displayText = engineLabel;
|
||||
} else if (solverTypeLabel) {
|
||||
displayText = solverTypeLabel;
|
||||
} else {
|
||||
displayText = 'Configure solver';
|
||||
}
|
||||
|
||||
// Use Terminal icon for Python, Cpu for others
|
||||
const icon = data.engine === 'python'
|
||||
? <Terminal size={16} />
|
||||
: <Cpu size={16} />;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
||||
{data.solverType || 'Select solution'}
|
||||
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
|
||||
{displayText}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface NodePaletteProps {
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Singleton node types - only one of each allowed on canvas */
|
||||
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
@@ -61,15 +64,15 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: Box,
|
||||
description: 'NX model file (.prt, .sim)',
|
||||
color: 'text-blue-400',
|
||||
canAdd: false, // Synthetic - derived from spec
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
icon: Cpu,
|
||||
description: 'Nastran solution type',
|
||||
description: 'Analysis solver config',
|
||||
color: 'text-violet-400',
|
||||
canAdd: false, // Synthetic - derived from model
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'designVar',
|
||||
@@ -109,7 +112,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: BrainCircuit,
|
||||
description: 'Optimization method',
|
||||
color: 'text-indigo-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'surrogate',
|
||||
@@ -117,7 +120,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: Rocket,
|
||||
description: 'Neural acceleration',
|
||||
color: 'text-pink-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* ErrorPanel - Displays optimization errors with recovery options
|
||||
*
|
||||
* Shows errors that occurred during optimization with:
|
||||
* - Error classification (NX crash, solver failure, etc.)
|
||||
* - Recovery suggestions
|
||||
* - Ability to dismiss individual errors
|
||||
* - Support for multiple simultaneous errors
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Trash2,
|
||||
Bug,
|
||||
Cpu,
|
||||
FileWarning,
|
||||
Settings,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ErrorPanelProps {
|
||||
onClose: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) {
|
||||
const panel = useErrorPanel();
|
||||
const { minimizePanel, dismissError, clearErrors } = usePanelStore();
|
||||
|
||||
const sortedErrors = useMemo(() => {
|
||||
return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}, [panel.errors]);
|
||||
|
||||
if (!panel.open || panel.errors.length === 0) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('error')}
|
||||
>
|
||||
<AlertOctagon size={16} className="text-red-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertOctagon size={18} className="text-red-400" />
|
||||
<span className="font-medium text-white">
|
||||
Optimization Errors ({panel.errors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{panel.errors.length > 1 && (
|
||||
<button
|
||||
onClick={clearErrors}
|
||||
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Clear all errors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => minimizePanel('error')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{sortedErrors.map((error) => (
|
||||
<ErrorItem
|
||||
key={error.timestamp}
|
||||
error={error}
|
||||
onDismiss={() => dismissError(error.timestamp)}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ErrorItemProps {
|
||||
error: OptimizationError;
|
||||
onDismiss: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) {
|
||||
const icon = getErrorIcon(error.type);
|
||||
const typeLabel = getErrorTypeLabel(error.type);
|
||||
const timeAgo = getTimeAgo(error.timestamp);
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
|
||||
{/* Error header */}
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{error.trial !== undefined && (
|
||||
<span className="text-xs text-dark-500">
|
||||
Trial #{error.trial}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-dark-600 ml-auto">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white">{error.message}</p>
|
||||
{error.details && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
|
||||
{error.details}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{error.suggestions.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
|
||||
<ul className="text-xs text-dark-300 space-y-1">
|
||||
{error.suggestions.map((suggestion, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1.5">
|
||||
<span className="text-dark-500">-</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{error.recoverable && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(error.trial)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
|
||||
text-white text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Retry{error.trial !== undefined ? ' Trial' : ''}
|
||||
</button>
|
||||
)}
|
||||
{onSkipTrial && error.trial !== undefined && (
|
||||
<button
|
||||
onClick={() => onSkipTrial(error.trial!)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
|
||||
text-dark-200 text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
Skip Trial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getErrorIcon(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return <Cpu size={16} className="text-red-400" />;
|
||||
case 'solver_fail':
|
||||
return <AlertTriangle size={16} className="text-amber-400" />;
|
||||
case 'extractor_error':
|
||||
return <FileWarning size={16} className="text-orange-400" />;
|
||||
case 'config_error':
|
||||
return <Settings size={16} className="text-blue-400" />;
|
||||
case 'system_error':
|
||||
return <Server size={16} className="text-purple-400" />;
|
||||
default:
|
||||
return <Bug size={16} className="text-red-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorTypeLabel(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return 'NX Crash';
|
||||
case 'solver_fail':
|
||||
return 'Solver Failure';
|
||||
case 'extractor_error':
|
||||
return 'Extractor Error';
|
||||
case 'config_error':
|
||||
return 'Configuration Error';
|
||||
case 'system_error':
|
||||
return 'System Error';
|
||||
default:
|
||||
return 'Unknown Error';
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default ErrorPanel;
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* FloatingIntrospectionPanel - Persistent introspection panel using store
|
||||
*
|
||||
* This is a wrapper around the existing IntrospectionPanel that:
|
||||
* 1. Gets its state from usePanelStore instead of local state
|
||||
* 2. Persists data when the panel is closed and reopened
|
||||
* 3. Can be opened from anywhere without losing state
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
SlidersHorizontal,
|
||||
Scale,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useIntrospectionPanel,
|
||||
usePanelStore,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
|
||||
interface FloatingIntrospectionPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Reuse types from original IntrospectionPanel
|
||||
interface Expression {
|
||||
name: string;
|
||||
value: number;
|
||||
rhs?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
units?: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ExpressionsResult {
|
||||
user: Expression[];
|
||||
internal: Expression[];
|
||||
total_count: number;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
solver_type?: string;
|
||||
expressions?: ExpressionsResult;
|
||||
// Allow other properties from the API response
|
||||
file_deps?: unknown[];
|
||||
fea_results?: unknown[];
|
||||
fem_mesh?: unknown;
|
||||
sim_solutions?: unknown[];
|
||||
sim_bcs?: unknown[];
|
||||
mass_properties?: {
|
||||
total_mass?: number;
|
||||
center_of_gravity?: { x: number; y: number; z: number };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelFileInfo {
|
||||
name: string;
|
||||
stem: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
size_kb: number;
|
||||
has_cache: boolean;
|
||||
}
|
||||
|
||||
interface ModelFilesResponse {
|
||||
files: {
|
||||
sim: ModelFileInfo[];
|
||||
afm: ModelFileInfo[];
|
||||
fem: ModelFileInfo[];
|
||||
idealized: ModelFileInfo[];
|
||||
prt: ModelFileInfo[];
|
||||
};
|
||||
all_files: ModelFileInfo[];
|
||||
}
|
||||
|
||||
export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) {
|
||||
const panel = useIntrospectionPanel();
|
||||
const {
|
||||
minimizePanel,
|
||||
updateIntrospectionResult,
|
||||
setIntrospectionLoading,
|
||||
setIntrospectionError,
|
||||
setIntrospectionFile,
|
||||
} = usePanelStore();
|
||||
const { addNode } = useSpecStore();
|
||||
|
||||
// Local UI state
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
const data = panel.data;
|
||||
const result = data?.result as IntrospectionResult | undefined;
|
||||
const isLoading = data?.isLoading || false;
|
||||
const error = data?.error as string | null;
|
||||
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!data?.studyId) return;
|
||||
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`);
|
||||
if (res.ok) {
|
||||
const filesData = await res.json();
|
||||
setModelFiles(filesData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch model files:', e);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [data?.studyId]);
|
||||
|
||||
// Run introspection
|
||||
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||
if (!data?.filePath && !data?.studyId) return;
|
||||
|
||||
setIntrospectionLoading(true);
|
||||
setIntrospectionError(null);
|
||||
|
||||
try {
|
||||
let res;
|
||||
|
||||
if (data?.studyId) {
|
||||
const endpoint = fileName
|
||||
? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||
: `/api/optimization/studies/${data.studyId}/nx/introspect`;
|
||||
res = await fetch(endpoint);
|
||||
} else {
|
||||
res = await fetch('/api/nx/introspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: data?.filePath }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Introspection failed');
|
||||
}
|
||||
|
||||
const responseData = await res.json();
|
||||
updateIntrospectionResult(responseData.introspection || responseData);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
|
||||
setIntrospectionError(msg);
|
||||
console.error('Introspection error:', e);
|
||||
}
|
||||
}, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]);
|
||||
|
||||
// Fetch files list on mount
|
||||
useEffect(() => {
|
||||
fetchAvailableFiles();
|
||||
}, [fetchAvailableFiles]);
|
||||
|
||||
// Run introspection when panel opens or selected file changes
|
||||
useEffect(() => {
|
||||
if (panel.open && data && !result && !isLoading) {
|
||||
runIntrospection(data.selectedFile);
|
||||
}
|
||||
}, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFile = e.target.value;
|
||||
setIntrospectionFile(newFile);
|
||||
runIntrospection(newFile);
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle both array format (old) and object format (new API)
|
||||
const allExpressions: Expression[] = useMemo(() => {
|
||||
if (!result?.expressions) return [];
|
||||
|
||||
if (Array.isArray(result.expressions)) {
|
||||
return result.expressions as Expression[];
|
||||
}
|
||||
|
||||
const exprObj = result.expressions as ExpressionsResult;
|
||||
return [...(exprObj.user || []), ...(exprObj.internal || [])];
|
||||
}, [result?.expressions]);
|
||||
|
||||
const filteredExpressions = allExpressions.filter((e) =>
|
||||
e.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const addExpressionAsDesignVar = (expr: Expression) => {
|
||||
const minValue = expr.min ?? expr.value * 0.5;
|
||||
const maxValue = expr.max ?? expr.value * 1.5;
|
||||
|
||||
addNode('designVar', {
|
||||
name: expr.name,
|
||||
expression_name: expr.name,
|
||||
type: 'continuous',
|
||||
bounds: { min: minValue, max: maxValue },
|
||||
baseline: expr.value,
|
||||
units: expr.unit || expr.units,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
>
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="font-medium text-white text-sm">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => runIntrospection(data?.selectedFile)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Selector + Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
|
||||
<select
|
||||
value={data?.selectedFile || ''}
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isLoadingFiles}
|
||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Default (Assembly)</option>
|
||||
|
||||
{modelFiles.files.sim.length > 0 && (
|
||||
<optgroup label="Simulation (.sim)">
|
||||
{modelFiles.files.sim.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.afm.length > 0 && (
|
||||
<optgroup label="Assembly FEM (.afm)">
|
||||
{modelFiles.files.afm.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.fem.length > 0 && (
|
||||
<optgroup label="FEM (.fem)">
|
||||
{modelFiles.files.fem.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.prt.length > 0 && (
|
||||
<optgroup label="Geometry (.prt)">
|
||||
{modelFiles.files.prt.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.idealized.length > 0 && (
|
||||
<optgroup label="Idealized (_i.prt)">
|
||||
{modelFiles.files.idealized.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{isLoadingFiles && (
|
||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter expressions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
||||
Analyzing model...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-red-400 text-sm">{error}</div>
|
||||
) : result ? (
|
||||
<div className="p-2 space-y-2">
|
||||
{/* Solver Type */}
|
||||
{result.solver_type && (
|
||||
<div className="p-2 bg-dark-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu size={14} className="text-violet-400" />
|
||||
<span className="text-dark-300">Solver:</span>
|
||||
<span className="text-white font-medium">{result.solver_type as string}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expressions Section */}
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('expressions')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={14} className="text-emerald-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
Expressions ({filteredExpressions.length})
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('expressions') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('expressions') && (
|
||||
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{filteredExpressions.length === 0 ? (
|
||||
<p className="text-xs text-dark-500 text-center py-2">
|
||||
No expressions found
|
||||
</p>
|
||||
) : (
|
||||
filteredExpressions.map((expr) => (
|
||||
<div
|
||||
key={expr.name}
|
||||
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{expr.name}</p>
|
||||
<p className="text-xs text-dark-500">
|
||||
{expr.value} {expr.units || expr.unit || ''}
|
||||
{expr.source === 'inferred' && (
|
||||
<span className="ml-1 text-amber-500">(inferred)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addExpressionAsDesignVar(expr)}
|
||||
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
|
||||
opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Add as Design Variable"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mass Properties Section */}
|
||||
{result.mass_properties && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('mass')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale size={14} className="text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Mass Properties</span>
|
||||
</div>
|
||||
{expandedSections.has('mass') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('mass') && (
|
||||
<div className="p-2 space-y-1">
|
||||
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
|
||||
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
|
||||
<span className="text-dark-400">Mass</span>
|
||||
<span className="text-white font-mono">
|
||||
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-dark-500 text-sm">
|
||||
Click refresh to analyze the model
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingIntrospectionPanel;
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
Link,
|
||||
Box,
|
||||
Settings2,
|
||||
GitBranch,
|
||||
File,
|
||||
Database,
|
||||
CheckCircle2,
|
||||
Layers,
|
||||
Grid3x3,
|
||||
Target,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
||||
|
||||
@@ -53,6 +61,23 @@ interface DependentFile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// File dependency structure from backend
|
||||
interface FileDependencies {
|
||||
files: {
|
||||
sim: string[];
|
||||
afm: string[];
|
||||
fem: string[];
|
||||
prt: string[];
|
||||
idealized: string[];
|
||||
};
|
||||
dependencies: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
}>;
|
||||
root_sim: string | null;
|
||||
}
|
||||
|
||||
// The API returns expressions in a nested structure
|
||||
interface ExpressionsResult {
|
||||
user: Expression[];
|
||||
@@ -61,6 +86,45 @@ interface ExpressionsResult {
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
// FEM file introspection result (from PyNastran)
|
||||
interface FemIntrospection {
|
||||
node_count?: number;
|
||||
element_count?: number;
|
||||
element_types?: Record<string, number>;
|
||||
materials?: Array<{ id: number; type: string; name?: string }>;
|
||||
properties?: Array<{ id: number; type: string; material_id?: number }>;
|
||||
coordinate_systems?: Array<{ id: number; type: string }>;
|
||||
load_sets?: number[];
|
||||
spc_sets?: number[];
|
||||
}
|
||||
|
||||
// SIM file introspection result (from NX journal)
|
||||
interface SimIntrospection {
|
||||
solutions?: Array<{
|
||||
name: string;
|
||||
type?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}>;
|
||||
boundary_conditions?: {
|
||||
constraints?: Array<{ name: string; type: string }>;
|
||||
loads?: Array<{ name: string; type: string }>;
|
||||
total_count?: number;
|
||||
};
|
||||
tree_structure?: {
|
||||
simulation_objects?: Array<{ pattern: string; type: string; found: boolean }>;
|
||||
found_types?: string[];
|
||||
};
|
||||
loaded_parts?: Array<{ name: string; type: string; leaf?: string }>;
|
||||
part_info?: {
|
||||
name?: string;
|
||||
is_assembly?: boolean;
|
||||
component_count?: number;
|
||||
components?: Array<{ name: string; type: string }>;
|
||||
};
|
||||
introspection_method?: string;
|
||||
nx_error?: string;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
part_file?: string;
|
||||
part_path?: string;
|
||||
@@ -74,13 +138,42 @@ interface IntrospectionResult {
|
||||
dependent_files?: DependentFile[];
|
||||
extractors_available?: Extractor[];
|
||||
warnings?: string[];
|
||||
// Additional fields from NX introspection
|
||||
// Additional fields from NX introspection (PRT files)
|
||||
mass_properties?: Record<string, unknown>;
|
||||
materials?: Record<string, unknown>;
|
||||
bodies?: Record<string, unknown>;
|
||||
attributes?: Array<{ title: string; value: string }>;
|
||||
units?: Record<string, unknown>;
|
||||
linked_parts?: Record<string, unknown>;
|
||||
file_dependencies?: FileDependencies;
|
||||
existing_fea_results?: {
|
||||
has_results: boolean;
|
||||
sources: Array<{
|
||||
location: string;
|
||||
path: string;
|
||||
op2: string[];
|
||||
f06: string[];
|
||||
bdf: string[];
|
||||
timestamp?: number;
|
||||
}>;
|
||||
recommended?: {
|
||||
location: string;
|
||||
path: string;
|
||||
op2: string[];
|
||||
};
|
||||
};
|
||||
// FEM file introspection (from PyNastran)
|
||||
fem?: FemIntrospection;
|
||||
// SIM file introspection (from NX journal)
|
||||
sim?: SimIntrospection;
|
||||
// Additional SIM fields that may be at top level
|
||||
solutions?: SimIntrospection['solutions'];
|
||||
boundary_conditions?: SimIntrospection['boundary_conditions'];
|
||||
tree_structure?: SimIntrospection['tree_structure'];
|
||||
loaded_parts?: SimIntrospection['loaded_parts'];
|
||||
part_info?: SimIntrospection['part_info'];
|
||||
introspection_method?: string;
|
||||
nx_error?: string;
|
||||
}
|
||||
|
||||
// Baseline run result interface
|
||||
@@ -100,23 +193,68 @@ interface BaselineRunResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// File info from /nx/parts endpoint
|
||||
interface ModelFileInfo {
|
||||
name: string;
|
||||
stem: string;
|
||||
type: string; // 'sim', 'afm', 'fem', 'idealized', 'prt'
|
||||
description?: string;
|
||||
size_kb: number;
|
||||
has_cache: boolean;
|
||||
}
|
||||
|
||||
// Grouped files response
|
||||
interface ModelFilesResponse {
|
||||
files: {
|
||||
sim: ModelFileInfo[];
|
||||
afm: ModelFileInfo[];
|
||||
fem: ModelFileInfo[];
|
||||
idealized: ModelFileInfo[];
|
||||
prt: ModelFileInfo[];
|
||||
};
|
||||
all_files: ModelFileInfo[];
|
||||
}
|
||||
|
||||
export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
|
||||
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['expressions', 'extractors'])
|
||||
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// File selection state
|
||||
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string>(''); // empty = default/assembly
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
// Baseline run state
|
||||
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
||||
const [baselineResult, setBaselineResult] = useState<BaselineRunResult | null>(null);
|
||||
|
||||
const { addNode, nodes } = useCanvasStore();
|
||||
|
||||
const runIntrospection = useCallback(async () => {
|
||||
if (!filePath) return;
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!studyId) return;
|
||||
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/nx/parts`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setModelFiles(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch model files:', e);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [studyId]);
|
||||
|
||||
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||
if (!filePath && !studyId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -125,8 +263,11 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
|
||||
// If we have a studyId, use the study-aware introspection endpoint
|
||||
if (studyId) {
|
||||
// Don't encode studyId - it may contain slashes for nested paths (e.g., M1_Mirror/study_name)
|
||||
res = await fetch(`/api/optimization/studies/${studyId}/nx/introspect`);
|
||||
// Use specific file endpoint if a file is selected
|
||||
const endpoint = fileName
|
||||
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||
: `/api/optimization/studies/${studyId}/nx/introspect`;
|
||||
res = await fetch(endpoint);
|
||||
} else {
|
||||
// Fallback to direct path introspection
|
||||
res = await fetch('/api/nx/introspect', {
|
||||
@@ -153,9 +294,21 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
}
|
||||
}, [filePath, studyId]);
|
||||
|
||||
// Fetch files list on mount
|
||||
useEffect(() => {
|
||||
runIntrospection();
|
||||
}, [runIntrospection]);
|
||||
fetchAvailableFiles();
|
||||
}, [fetchAvailableFiles]);
|
||||
|
||||
// Run introspection when component mounts or selected file changes
|
||||
useEffect(() => {
|
||||
runIntrospection(selectedFile || undefined);
|
||||
}, [selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle file selection change
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFile = e.target.value;
|
||||
setSelectedFile(newFile);
|
||||
};
|
||||
|
||||
// Run baseline FEA simulation
|
||||
const runBaseline = useCallback(async () => {
|
||||
@@ -263,11 +416,14 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="font-medium text-white text-sm">Model Introspection</span>
|
||||
<span className="font-medium text-white text-sm">
|
||||
Model Introspection
|
||||
{selectedFile && <span className="text-primary-400 ml-1">({selectedFile})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={runIntrospection}
|
||||
onClick={() => runIntrospection(selectedFile || undefined)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
@@ -283,8 +439,84 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700">
|
||||
{/* File Selector + Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||
{/* File dropdown - grouped by type */}
|
||||
{studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
|
||||
<select
|
||||
value={selectedFile}
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isLoadingFiles}
|
||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Default (Assembly)</option>
|
||||
|
||||
{/* Simulation files */}
|
||||
{modelFiles.files.sim.length > 0 && (
|
||||
<optgroup label="Simulation (.sim)">
|
||||
{modelFiles.files.sim.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Assembly FEM files */}
|
||||
{modelFiles.files.afm.length > 0 && (
|
||||
<optgroup label="Assembly FEM (.afm)">
|
||||
{modelFiles.files.afm.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* FEM files */}
|
||||
{modelFiles.files.fem.length > 0 && (
|
||||
<optgroup label="FEM (.fem)">
|
||||
{modelFiles.files.fem.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Geometry parts */}
|
||||
{modelFiles.files.prt.length > 0 && (
|
||||
<optgroup label="Geometry (.prt)">
|
||||
{modelFiles.files.prt.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Idealized parts */}
|
||||
{modelFiles.files.idealized.length > 0 && (
|
||||
<optgroup label="Idealized (_i.prt)">
|
||||
{modelFiles.files.idealized.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{isLoadingFiles && (
|
||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter expressions..."
|
||||
@@ -627,6 +859,426 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FEM Mesh Info Section (for .fem/.afm files) */}
|
||||
{result.fem && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('fem_mesh')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 size={14} className="text-teal-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
FEM Mesh ({result.fem.node_count?.toLocaleString() || 0} nodes)
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('fem_mesh') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('fem_mesh') && (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
|
||||
<span className="text-dark-400">Nodes</span>
|
||||
<span className="text-white font-mono">{result.fem.node_count?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
|
||||
<span className="text-dark-400">Elements</span>
|
||||
<span className="text-white font-mono">{result.fem.element_count?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* Element types breakdown */}
|
||||
{result.fem.element_types && Object.keys(result.fem.element_types).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-dark-500 mb-1">Element Types:</p>
|
||||
{Object.entries(result.fem.element_types).map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between p-1.5 bg-dark-800 rounded text-xs mb-0.5">
|
||||
<span className="text-teal-400 font-mono">{type}</span>
|
||||
<span className="text-white">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Materials */}
|
||||
{result.fem.materials && result.fem.materials.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-dark-500 mb-1">Materials ({result.fem.materials.length}):</p>
|
||||
{result.fem.materials.slice(0, 5).map((mat) => (
|
||||
<div key={mat.id} className="flex justify-between p-1.5 bg-dark-800 rounded text-xs mb-0.5">
|
||||
<span className="text-blue-400">ID {mat.id}</span>
|
||||
<span className="text-white font-mono">{mat.type}</span>
|
||||
</div>
|
||||
))}
|
||||
{result.fem.materials.length > 5 && (
|
||||
<p className="text-xs text-dark-500 text-center">+{result.fem.materials.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
{result.fem.properties && result.fem.properties.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-dark-500 mb-1">Properties ({result.fem.properties.length}):</p>
|
||||
{result.fem.properties.slice(0, 5).map((prop) => (
|
||||
<div key={prop.id} className="flex justify-between p-1.5 bg-dark-800 rounded text-xs mb-0.5">
|
||||
<span className="text-amber-400">ID {prop.id}</span>
|
||||
<span className="text-white font-mono">{prop.type}</span>
|
||||
</div>
|
||||
))}
|
||||
{result.fem.properties.length > 5 && (
|
||||
<p className="text-xs text-dark-500 text-center">+{result.fem.properties.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load/SPC sets */}
|
||||
{(result.fem.load_sets?.length || result.fem.spc_sets?.length) && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
{result.fem.load_sets && result.fem.load_sets.length > 0 && (
|
||||
<div className="flex-1 p-1.5 bg-dark-800 rounded text-xs">
|
||||
<span className="text-dark-400">Load Sets: </span>
|
||||
<span className="text-green-400">{result.fem.load_sets.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.fem.spc_sets && result.fem.spc_sets.length > 0 && (
|
||||
<div className="flex-1 p-1.5 bg-dark-800 rounded text-xs">
|
||||
<span className="text-dark-400">SPC Sets: </span>
|
||||
<span className="text-red-400">{result.fem.spc_sets.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Solutions Section (for .sim files) */}
|
||||
{(result.solutions || result.sim?.solutions) && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('sim_solutions')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={14} className="text-violet-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
Solutions ({(result.solutions || result.sim?.solutions)?.length || 0})
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('sim_solutions') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('sim_solutions') && (
|
||||
<div className="p-2 space-y-1">
|
||||
{((result.solutions || result.sim?.solutions) || []).map((sol, idx) => (
|
||||
<div key={idx} className="p-2 bg-dark-850 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={12} className="text-violet-400" />
|
||||
<span className="text-sm text-white">{sol.name}</span>
|
||||
</div>
|
||||
{sol.type && (
|
||||
<p className="text-xs text-dark-400 mt-1">Type: {sol.type}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(result.solutions || result.sim?.solutions)?.length === 0 && (
|
||||
<p className="text-xs text-dark-500 text-center py-2">No solutions found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Boundary Conditions Section */}
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions) && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('sim_bcs')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={14} className="text-rose-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
Boundary Conditions ({(result.boundary_conditions || result.sim?.boundary_conditions)?.total_count || 0})
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('sim_bcs') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('sim_bcs') && (
|
||||
<div className="p-2 space-y-2">
|
||||
{/* Constraints */}
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions)?.constraints &&
|
||||
(result.boundary_conditions || result.sim?.boundary_conditions)!.constraints!.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-dark-500 mb-1">Constraints:</p>
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions)!.constraints!.map((bc, idx) => (
|
||||
<div key={idx} className="flex justify-between p-1.5 bg-dark-800 rounded text-xs mb-0.5">
|
||||
<span className="text-rose-400">{bc.name}</span>
|
||||
<span className="text-dark-400">{bc.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loads */}
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions)?.loads &&
|
||||
(result.boundary_conditions || result.sim?.boundary_conditions)!.loads!.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-dark-500 mb-1">Loads:</p>
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions)!.loads!.map((load, idx) => (
|
||||
<div key={idx} className="flex justify-between p-1.5 bg-dark-800 rounded text-xs mb-0.5">
|
||||
<span className="text-green-400">{load.name}</span>
|
||||
<span className="text-dark-400">{load.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result.boundary_conditions || result.sim?.boundary_conditions)?.total_count === 0 && (
|
||||
<p className="text-xs text-dark-500 text-center py-2">No boundary conditions found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Introspection Method Info */}
|
||||
{(result.introspection_method || result.nx_error) && (
|
||||
<div className="p-2 bg-dark-800 rounded-lg text-xs">
|
||||
{result.introspection_method && (
|
||||
<p className="text-dark-400">
|
||||
Method: <span className="text-white">{result.introspection_method}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.nx_error && (
|
||||
<p className="text-amber-400 mt-1">
|
||||
NX Error: {result.nx_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Dependencies Section (NX file chain) */}
|
||||
{result.file_dependencies && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('file_deps')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={14} className="text-cyan-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
File Dependencies ({result.file_dependencies.dependencies?.length || 0} links)
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('file_deps') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('file_deps') && (
|
||||
<div className="p-2 space-y-2 max-h-64 overflow-y-auto">
|
||||
{/* Show file tree structure */}
|
||||
{result.file_dependencies.root_sim && (
|
||||
<div className="text-xs">
|
||||
{/* Simulation files */}
|
||||
{result.file_dependencies.files.sim.map((sim) => (
|
||||
<div key={sim} className="ml-0">
|
||||
<div className="flex items-center gap-1 p-1 bg-purple-500/10 rounded text-purple-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{sim}</span>
|
||||
<span className="text-purple-500/60 text-[10px]">(.sim)</span>
|
||||
</div>
|
||||
|
||||
{/* AFM files connected to this SIM */}
|
||||
{result.file_dependencies!.dependencies
|
||||
.filter(d => d.source === sim && d.type === 'sim_to_afm')
|
||||
.map(d => (
|
||||
<div key={d.target} className="ml-4 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-blue-500/10 rounded text-blue-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{d.target}</span>
|
||||
<span className="text-blue-500/60 text-[10px]">(.afm)</span>
|
||||
</div>
|
||||
|
||||
{/* FEM files connected to this AFM */}
|
||||
{result.file_dependencies!.dependencies
|
||||
.filter(d2 => d2.source === d.target && d2.type === 'afm_to_fem')
|
||||
.map(d2 => (
|
||||
<div key={d2.target} className="ml-4 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-green-500/10 rounded text-green-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{d2.target}</span>
|
||||
<span className="text-green-500/60 text-[10px]">(.fem)</span>
|
||||
</div>
|
||||
|
||||
{/* Idealized parts connected to this FEM */}
|
||||
{result.file_dependencies!.dependencies
|
||||
.filter(d3 => d3.source === d2.target && d3.type === 'fem_to_idealized')
|
||||
.map(d3 => (
|
||||
<div key={d3.target} className="ml-4 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-yellow-500/10 rounded text-yellow-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{d3.target}</span>
|
||||
<span className="text-yellow-500/60 text-[10px]">(_i.prt)</span>
|
||||
</div>
|
||||
|
||||
{/* Geometry parts */}
|
||||
{result.file_dependencies!.dependencies
|
||||
.filter(d4 => d4.source === d3.target && d4.type === 'idealized_to_prt')
|
||||
.map(d4 => (
|
||||
<div key={d4.target} className="ml-4 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-orange-500/10 rounded text-orange-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{d4.target}</span>
|
||||
<span className="text-orange-500/60 text-[10px]">(.prt)</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Direct FEM connections (no AFM) */}
|
||||
{result.file_dependencies!.dependencies
|
||||
.filter(d => d.source === sim && d.type === 'sim_to_fem')
|
||||
.map(d => (
|
||||
<div key={d.target} className="ml-4 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-green-500/10 rounded text-green-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{d.target}</span>
|
||||
<span className="text-green-500/60 text-[10px]">(.fem)</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Show any orphan PRT files not in the tree */}
|
||||
{result.file_dependencies.files.prt.filter(prt =>
|
||||
!result.file_dependencies!.dependencies.some(d => d.target === prt)
|
||||
).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-dark-700">
|
||||
<span className="text-dark-500 text-[10px]">Additional geometry files:</span>
|
||||
{result.file_dependencies.files.prt
|
||||
.filter(prt => !result.file_dependencies!.dependencies.some(d => d.target === prt))
|
||||
.map(prt => (
|
||||
<div key={prt} className="ml-2 mt-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-dark-800 rounded text-dark-400">
|
||||
<File size={12} />
|
||||
<span className="font-mono">{prt}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result.file_dependencies.root_sim && (
|
||||
<p className="text-xs text-dark-500 text-center py-2">No simulation file found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing FEA Results Section */}
|
||||
{result.existing_fea_results && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('fea_results')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.existing_fea_results.has_results ? (
|
||||
<CheckCircle2 size={14} className="text-green-400" />
|
||||
) : (
|
||||
<Database size={14} className="text-dark-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-white">
|
||||
FEA Results {result.existing_fea_results.has_results ?
|
||||
`(${result.existing_fea_results.sources?.length || 0} sources)` :
|
||||
'(none)'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('fea_results') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('fea_results') && (
|
||||
<div className="p-2 space-y-2">
|
||||
{result.existing_fea_results.has_results ? (
|
||||
<>
|
||||
<p className="text-xs text-green-400 mb-2">
|
||||
Existing results found - no solve needed for extraction
|
||||
</p>
|
||||
{result.existing_fea_results.sources?.map((source, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-2 rounded text-xs ${
|
||||
source.location === result.existing_fea_results?.recommended?.location
|
||||
? 'bg-green-500/10 border border-green-500/30'
|
||||
: 'bg-dark-850'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-white font-medium">{source.location}</span>
|
||||
{source.location === result.existing_fea_results?.recommended?.location && (
|
||||
<span className="text-[10px] text-green-400 bg-green-500/20 px-1 rounded">
|
||||
recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-dark-400 space-y-0.5">
|
||||
{source.op2?.length > 0 && (
|
||||
<p>OP2: {source.op2.join(', ')}</p>
|
||||
)}
|
||||
{source.f06?.length > 0 && (
|
||||
<p>F06: {source.f06.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-dark-500 text-center py-2">
|
||||
No FEA results found. Run baseline to generate results.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extractors Section - only show if available */}
|
||||
{(result.extractors_available?.length ?? 0) > 0 && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
useSelectedNodeId,
|
||||
useSelectedNode,
|
||||
} from '../../../hooks/useSpecStore';
|
||||
import { usePanelStore } from '../../../hooks/usePanelStore';
|
||||
import { FileBrowser } from './FileBrowser';
|
||||
import { IntrospectionPanel } from './IntrospectionPanel';
|
||||
import {
|
||||
DesignVariable,
|
||||
Extractor,
|
||||
@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
||||
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -249,16 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
||||
/>
|
||||
|
||||
{/* Introspection Panel */}
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
studyId={useSpecStore.getState().studyId || undefined}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +262,16 @@ interface SpecConfigProps {
|
||||
}
|
||||
|
||||
function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const { setIntrospectionData, openPanel } = usePanelStore();
|
||||
|
||||
const handleOpenIntrospection = () => {
|
||||
// Set up introspection data and open the panel
|
||||
setIntrospectionData({
|
||||
filePath: spec.model.sim?.path || '',
|
||||
studyId: useSpecStore.getState().studyId || undefined,
|
||||
});
|
||||
openPanel('introspection');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -300,7 +299,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
|
||||
{spec.model.sim?.path && (
|
||||
<button
|
||||
onClick={() => setShowIntrospection(true)}
|
||||
onClick={handleOpenIntrospection}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
|
||||
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
|
||||
text-primary-400 text-sm font-medium transition-colors"
|
||||
@@ -310,32 +309,112 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
studyId={useSpecStore.getState().studyId || undefined}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
||||
const { patchSpec } = useSpecStore();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const engine = spec.model.sim?.engine || 'nxnastran';
|
||||
const solutionType = spec.model.sim?.solution_type || 'SOL101';
|
||||
const scriptPath = spec.model.sim?.script_path || '';
|
||||
const isPython = engine === 'python';
|
||||
|
||||
const handleEngineChange = async (newEngine: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.engine', newEngine);
|
||||
} catch (err) {
|
||||
console.error('Failed to update engine:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = async (newType: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.solution_type', newType);
|
||||
} catch (err) {
|
||||
console.error('Failed to update solution type:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScriptPathChange = async (newPath: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.script_path', newPath);
|
||||
} catch (err) {
|
||||
console.error('Failed to update script path:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.model.sim?.solution_type || 'Not configured'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
title="Solver type is determined by the model file."
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
|
||||
</div>
|
||||
<>
|
||||
{isUpdating && (
|
||||
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Solver Engine</label>
|
||||
<select
|
||||
value={engine}
|
||||
onChange={(e) => handleEngineChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="nxnastran">NX Nastran (built-in)</option>
|
||||
<option value="mscnastran">MSC Nastran (external)</option>
|
||||
<option value="python">Python Script</option>
|
||||
<option value="abaqus" disabled>Abaqus (coming soon)</option>
|
||||
<option value="ansys" disabled>ANSYS (coming soon)</option>
|
||||
</select>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isPython && (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<select
|
||||
value={solutionType}
|
||||
onChange={(e) => handleSolutionTypeChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="SOL101">SOL101 - Linear Statics</option>
|
||||
<option value="SOL103">SOL103 - Normal Modes</option>
|
||||
<option value="SOL105">SOL105 - Buckling</option>
|
||||
<option value="SOL106">SOL106 - Nonlinear Statics</option>
|
||||
<option value="SOL111">SOL111 - Modal Frequency Response</option>
|
||||
<option value="SOL112">SOL112 - Modal Transient Response</option>
|
||||
<option value="SOL200">SOL200 - Design Optimization</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPython && (
|
||||
<div>
|
||||
<label className={labelClass}>Script Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scriptPath}
|
||||
onChange={(e) => handleScriptPathChange(e.target.value)}
|
||||
placeholder="path/to/solver_script.py"
|
||||
className={`${inputClass} font-mono text-sm`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Python script must define solve(params) function
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* PanelContainer - Orchestrates all floating panels in the canvas view
|
||||
*
|
||||
* This component renders floating panels (Introspection, Validation, Error, Results)
|
||||
* in a portal, positioned absolutely within the canvas area.
|
||||
*
|
||||
* Features:
|
||||
* - Draggable panels
|
||||
* - Z-index management (click to bring to front)
|
||||
* - Keyboard shortcuts (Escape to close all)
|
||||
* - Position persistence via usePanelStore
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
usePanelStore,
|
||||
useIntrospectionPanel,
|
||||
useValidationPanel,
|
||||
useErrorPanel,
|
||||
useResultsPanel,
|
||||
PanelPosition,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
|
||||
import { FloatingValidationPanel } from './ValidationPanel';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
import { ResultsPanel } from './ResultsPanel';
|
||||
|
||||
interface PanelContainerProps {
|
||||
/** Container element to render panels into (defaults to document.body) */
|
||||
container?: HTMLElement;
|
||||
/** Callback when retry is requested from error panel */
|
||||
onRetry?: (trial?: number) => void;
|
||||
/** Callback when skip trial is requested */
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
|
||||
|
||||
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
|
||||
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
|
||||
|
||||
const introspectionPanel = useIntrospectionPanel();
|
||||
const validationPanel = useValidationPanel();
|
||||
const errorPanel = useErrorPanel();
|
||||
const resultsPanel = useResultsPanel();
|
||||
|
||||
// Track which panel is on top (for z-index)
|
||||
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
|
||||
|
||||
// Dragging state
|
||||
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
|
||||
// Escape key to close all panels
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAllPanels();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeAllPanels]);
|
||||
|
||||
// Mouse move handler for dragging
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { panel, offset } = dragRef.current;
|
||||
const newPosition: PanelPosition = {
|
||||
x: e.clientX - offset.x,
|
||||
y: e.clientY - offset.y,
|
||||
};
|
||||
|
||||
// Clamp to viewport
|
||||
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
|
||||
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
|
||||
|
||||
setPanelPosition(panel, newPosition);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragRef.current = null;
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
if (dragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, setPanelPosition]);
|
||||
|
||||
// Start dragging a panel
|
||||
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
|
||||
const offset = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
dragRef.current = { panel, offset };
|
||||
setDragging({ panel, offset });
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Click to bring panel to front
|
||||
const handlePanelClick = useCallback((panel: PanelName) => {
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Get z-index for a panel
|
||||
const getZIndex = (panel: PanelName) => {
|
||||
const baseZ = 100;
|
||||
if (panel === topPanel) return baseZ + 10;
|
||||
return baseZ;
|
||||
};
|
||||
|
||||
// Render a draggable wrapper
|
||||
const renderDraggable = (
|
||||
panel: PanelName,
|
||||
position: PanelPosition,
|
||||
isOpen: boolean,
|
||||
children: React.ReactNode
|
||||
) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={panel}
|
||||
className="fixed select-none"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: getZIndex(panel),
|
||||
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
|
||||
}}
|
||||
onClick={() => handlePanelClick(panel)}
|
||||
>
|
||||
{/* Drag handle - the header area */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={(e) => handleDragStart(panel, e, position)}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Panel content */}
|
||||
<div className="relative" style={{ zIndex: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Determine what to render
|
||||
const panels = (
|
||||
<>
|
||||
{/* Introspection Panel */}
|
||||
{renderDraggable(
|
||||
'introspection',
|
||||
introspectionPanel.position || { x: 100, y: 100 },
|
||||
introspectionPanel.open,
|
||||
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
|
||||
)}
|
||||
|
||||
{/* Validation Panel */}
|
||||
{renderDraggable(
|
||||
'validation',
|
||||
validationPanel.position || { x: 150, y: 150 },
|
||||
validationPanel.open,
|
||||
<FloatingValidationPanel onClose={() => closePanel('validation')} />
|
||||
)}
|
||||
|
||||
{/* Error Panel */}
|
||||
{renderDraggable(
|
||||
'error',
|
||||
errorPanel.position || { x: 200, y: 100 },
|
||||
errorPanel.open,
|
||||
<ErrorPanel
|
||||
onClose={() => closePanel('error')}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results Panel */}
|
||||
{renderDraggable(
|
||||
'results',
|
||||
resultsPanel.position || { x: 250, y: 150 },
|
||||
resultsPanel.open,
|
||||
<ResultsPanel onClose={() => closePanel('results')} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Use portal if container specified, otherwise render in place
|
||||
if (container) {
|
||||
return createPortal(panels, container);
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export default PanelContainer;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* ResultsPanel - Shows detailed trial results
|
||||
*
|
||||
* Displays the parameters, objectives, and constraints for a specific trial.
|
||||
* Can be opened by clicking on result badges on nodes.
|
||||
*/
|
||||
|
||||
import {
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Trophy,
|
||||
SlidersHorizontal,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ResultsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResultsPanel({ onClose }: ResultsPanelProps) {
|
||||
const panel = useResultsPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const data = panel.data;
|
||||
|
||||
if (!panel.open || !data) return null;
|
||||
|
||||
const timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('results')}
|
||||
>
|
||||
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="font-medium text-white">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
{data.isBest && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('results')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{data.isFeasible ? (
|
||||
<div className="flex items-center gap-1.5 text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
<span className="text-sm font-medium">Feasible</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-red-400">
|
||||
<XCircle size={16} />
|
||||
<span className="text-sm font-medium">Infeasible</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">{timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<SlidersHorizontal size={12} />
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.params).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-white font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Target size={12} />
|
||||
Objectives
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.objectives).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints (if any) */}
|
||||
{data.constraints && Object.keys(data.constraints).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle size={12} />
|
||||
Constraints
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.constraints).map(([name, constraint]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={`flex justify-between p-2 rounded text-sm ${
|
||||
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-300 flex items-center gap-1.5">
|
||||
{constraint.feasible ? (
|
||||
<CheckCircle size={12} className="text-green-400" />
|
||||
) : (
|
||||
<XCircle size={12} className="text-red-400" />
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
|
||||
{formatValue(constraint.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) {
|
||||
return value.toExponential(3);
|
||||
}
|
||||
return value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export default ResultsPanel;
|
||||
@@ -1,10 +1,41 @@
|
||||
/**
|
||||
* ValidationPanel - Displays spec validation errors and warnings
|
||||
*
|
||||
* Shows a list of validation issues that need to be fixed before
|
||||
* running an optimization. Supports auto-navigation to problematic nodes.
|
||||
*
|
||||
* Can be used in two modes:
|
||||
* 1. Legacy mode: Pass validation prop directly (for backward compatibility)
|
||||
* 2. Store mode: Uses usePanelStore for persistent state
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
import { ValidationResult } from '../../../lib/canvas/validation';
|
||||
|
||||
interface ValidationPanelProps {
|
||||
// ============================================================================
|
||||
// Legacy Props Interface (for backward compatibility)
|
||||
// ============================================================================
|
||||
|
||||
interface LegacyValidationPanelProps {
|
||||
validation: ValidationResult;
|
||||
}
|
||||
|
||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
/**
|
||||
* Legacy ValidationPanel - Inline display for canvas overlay
|
||||
* Kept for backward compatibility with AtomizerCanvas
|
||||
*/
|
||||
export function ValidationPanel({ validation }: LegacyValidationPanelProps) {
|
||||
return (
|
||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
|
||||
{validation.errors.length > 0 && (
|
||||
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New Floating Panel (uses store)
|
||||
// ============================================================================
|
||||
|
||||
interface FloatingValidationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
|
||||
const panel = useValidationPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const { selectNode } = useSpecStore();
|
||||
|
||||
const { errors, warnings, valid } = useMemo(() => {
|
||||
if (!panel.data) {
|
||||
return { errors: [], warnings: [], valid: true };
|
||||
}
|
||||
return {
|
||||
errors: panel.data.errors || [],
|
||||
warnings: panel.data.warnings || [],
|
||||
valid: panel.data.valid,
|
||||
};
|
||||
}, [panel.data]);
|
||||
|
||||
const handleNavigateToNode = (nodeId?: string) => {
|
||||
if (nodeId) {
|
||||
selectNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('validation')}
|
||||
>
|
||||
{valid ? (
|
||||
<CheckCircle size={16} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={16} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-sm text-white font-medium">
|
||||
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{valid ? (
|
||||
<CheckCircle size={18} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
<span className="font-medium text-white">
|
||||
{valid ? 'Validation Passed' : 'Validation Issues'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('validation')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{valid && errors.length === 0 && warnings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CheckCircle size={40} className="text-green-400 mb-3" />
|
||||
<p className="text-white font-medium">All checks passed!</p>
|
||||
<p className="text-sm text-dark-400 mt-1">
|
||||
Your spec is ready to run.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Errors ({errors.length})
|
||||
</h4>
|
||||
{errors.map((error, idx) => (
|
||||
<ValidationItem
|
||||
key={`error-${idx}`}
|
||||
item={error}
|
||||
severity="error"
|
||||
onNavigate={() => handleNavigateToNode(error.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertTriangle size={12} />
|
||||
Warnings ({warnings.length})
|
||||
</h4>
|
||||
{warnings.map((warning, idx) => (
|
||||
<ValidationItem
|
||||
key={`warning-${idx}`}
|
||||
item={warning}
|
||||
severity="warning"
|
||||
onNavigate={() => handleNavigateToNode(warning.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!valid && (
|
||||
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
|
||||
<p className="text-xs text-dark-400">
|
||||
Fix all errors before running the optimization.
|
||||
Warnings can be ignored but may cause issues.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationItemProps {
|
||||
item: StoreValidationError;
|
||||
severity: 'error' | 'warning';
|
||||
onNavigate: () => void;
|
||||
}
|
||||
|
||||
function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) {
|
||||
const isError = severity === 'error';
|
||||
const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10';
|
||||
const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30';
|
||||
const iconColor = isError ? 'text-red-400' : 'text-amber-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isError ? (
|
||||
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
) : (
|
||||
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white">{item.message}</p>
|
||||
{item.path && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
|
||||
)}
|
||||
{item.suggestion && (
|
||||
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.nodeId && (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidationPanel;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
|
||||
*
|
||||
* Displays the last N trial values as a mini line chart.
|
||||
* Used on ObjectiveNode to show convergence trend.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface ConvergenceSparklineProps {
|
||||
/** Array of values (most recent last) */
|
||||
values: number[];
|
||||
/** Width in pixels */
|
||||
width?: number;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Line color */
|
||||
color?: string;
|
||||
/** Best value line color */
|
||||
bestColor?: string;
|
||||
/** Whether to show the best value line */
|
||||
showBest?: boolean;
|
||||
/** Direction: minimize shows lower as better, maximize shows higher as better */
|
||||
direction?: 'minimize' | 'maximize';
|
||||
/** Show dots at each point */
|
||||
showDots?: boolean;
|
||||
/** Number of points to display */
|
||||
maxPoints?: number;
|
||||
}
|
||||
|
||||
export function ConvergenceSparkline({
|
||||
values,
|
||||
width = 80,
|
||||
height = 24,
|
||||
color = '#60a5fa',
|
||||
bestColor = '#34d399',
|
||||
showBest = true,
|
||||
direction = 'minimize',
|
||||
showDots = false,
|
||||
maxPoints = 20,
|
||||
}: ConvergenceSparklineProps) {
|
||||
const { path, bestY, points } = useMemo(() => {
|
||||
if (!values || values.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Take last N points
|
||||
const data = values.slice(-maxPoints);
|
||||
if (data.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Calculate bounds with padding
|
||||
const minVal = Math.min(...data);
|
||||
const maxVal = Math.max(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
const padding = range * 0.1;
|
||||
const yMin = minVal - padding;
|
||||
const yMax = maxVal + padding;
|
||||
const yRange = yMax - yMin;
|
||||
|
||||
// Calculate best value
|
||||
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
|
||||
|
||||
// Map values to SVG coordinates
|
||||
const xStep = width / Math.max(data.length - 1, 1);
|
||||
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
|
||||
|
||||
// Build path
|
||||
const points = data.map((v, i) => ({
|
||||
x: i * xStep,
|
||||
y: mapY(v),
|
||||
value: v,
|
||||
}));
|
||||
|
||||
const pathParts = points.map((p, i) =>
|
||||
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
|
||||
);
|
||||
|
||||
return {
|
||||
path: pathParts.join(' '),
|
||||
bestY: mapY(bestVal),
|
||||
points,
|
||||
minVal,
|
||||
maxVal,
|
||||
};
|
||||
}, [values, width, height, maxPoints, direction]);
|
||||
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-dark-500 text-xs"
|
||||
style={{ width, height }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="overflow-visible"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{/* Best value line */}
|
||||
{showBest && bestY !== null && (
|
||||
<line
|
||||
x1={0}
|
||||
y1={bestY}
|
||||
x2={width}
|
||||
y2={bestY}
|
||||
stroke={bestColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2,2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Gradient fill under the line */}
|
||||
<defs>
|
||||
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{points.length > 1 && (
|
||||
<path
|
||||
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
|
||||
fill="url(#sparkline-gradient)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dots at each point */}
|
||||
{showDots && points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={2}
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Last point highlight */}
|
||||
{points.length > 0 && (
|
||||
<circle
|
||||
cx={points[points.length - 1].x}
|
||||
cy={points[points.length - 1].y}
|
||||
r={3}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressRing - Circular progress indicator
|
||||
*/
|
||||
interface ProgressRingProps {
|
||||
/** Progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Size in pixels */
|
||||
size?: number;
|
||||
/** Stroke width */
|
||||
strokeWidth?: number;
|
||||
/** Progress color */
|
||||
color?: string;
|
||||
/** Background color */
|
||||
bgColor?: string;
|
||||
/** Show percentage text */
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 32,
|
||||
strokeWidth = 3,
|
||||
color = '#60a5fa',
|
||||
bgColor = '#374151',
|
||||
showText = true,
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={bgColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="absolute text-xs font-medium"
|
||||
style={{ color, fontSize: size * 0.25 }}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConvergenceSparkline;
|
||||
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
|
||||
*
|
||||
* This hook provides:
|
||||
* - Real-time trial updates (no polling needed)
|
||||
* - Best trial tracking
|
||||
* - Progress tracking
|
||||
* - Error detection and reporting
|
||||
* - Integration with panel store for error display
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const {
|
||||
* isConnected,
|
||||
* progress,
|
||||
* bestTrial,
|
||||
* recentTrials,
|
||||
* status
|
||||
* } = useOptimizationStream(studyId);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
import { usePanelStore } from './usePanelStore';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TrialData {
|
||||
trial_number: number;
|
||||
trial_num: number;
|
||||
objective: number | null;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs: Record<string, unknown>;
|
||||
source: 'FEA' | 'NN' | string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
study_name: string;
|
||||
constraint_satisfied: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressData {
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
fea_count: number;
|
||||
nn_count: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface BestTrialData {
|
||||
trial_number: number;
|
||||
value: number;
|
||||
params: Record<string, number>;
|
||||
improvement: number;
|
||||
}
|
||||
|
||||
export interface ParetoData {
|
||||
pareto_front: Array<{
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
constraint_satisfied: boolean;
|
||||
source: string;
|
||||
}>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
export interface OptimizationStreamState {
|
||||
isConnected: boolean;
|
||||
status: OptimizationStatus;
|
||||
progress: ProgressData | null;
|
||||
bestTrial: BestTrialData | null;
|
||||
recentTrials: TrialData[];
|
||||
paretoFront: ParetoData | null;
|
||||
lastUpdate: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseOptimizationStreamOptions {
|
||||
/** Maximum number of recent trials to keep */
|
||||
maxRecentTrials?: number;
|
||||
/** Callback when a new trial completes */
|
||||
onTrialComplete?: (trial: TrialData) => void;
|
||||
/** Callback when a new best is found */
|
||||
onNewBest?: (best: BestTrialData) => void;
|
||||
/** Callback on progress update */
|
||||
onProgress?: (progress: ProgressData) => void;
|
||||
/** Whether to auto-report errors to the error panel */
|
||||
autoReportErrors?: boolean;
|
||||
}
|
||||
|
||||
export function useOptimizationStream(
|
||||
studyId: string | null | undefined,
|
||||
options: UseOptimizationStreamOptions = {}
|
||||
) {
|
||||
const {
|
||||
maxRecentTrials = 20,
|
||||
onTrialComplete,
|
||||
onNewBest,
|
||||
onProgress,
|
||||
autoReportErrors = true,
|
||||
} = options;
|
||||
|
||||
// Panel store for error reporting
|
||||
const { addError } = usePanelStore();
|
||||
|
||||
// State
|
||||
const [state, setState] = useState<OptimizationStreamState>({
|
||||
isConnected: false,
|
||||
status: 'disconnected',
|
||||
progress: null,
|
||||
bestTrial: null,
|
||||
recentTrials: [],
|
||||
paretoFront: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Track last error timestamp to avoid duplicates
|
||||
const lastErrorTime = useRef<number>(0);
|
||||
|
||||
// Build WebSocket URL
|
||||
const socketUrl = studyId
|
||||
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
|
||||
import.meta.env.DEV ? 'localhost:8001' : window.location.host
|
||||
}/api/ws/optimization/${encodeURIComponent(studyId)}`
|
||||
: null;
|
||||
|
||||
// WebSocket connection
|
||||
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
|
||||
shouldReconnect: () => true,
|
||||
reconnectAttempts: 10,
|
||||
reconnectInterval: 3000,
|
||||
onOpen: () => {
|
||||
console.log('[OptStream] Connected to optimization stream');
|
||||
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('[OptStream] Disconnected from optimization stream');
|
||||
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
|
||||
},
|
||||
onError: (event) => {
|
||||
console.error('[OptStream] WebSocket error:', event);
|
||||
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
|
||||
},
|
||||
});
|
||||
|
||||
// Update connection status
|
||||
useEffect(() => {
|
||||
const statusMap: Record<ReadyState, OptimizationStatus> = {
|
||||
[ReadyState.CONNECTING]: 'connecting',
|
||||
[ReadyState.OPEN]: 'connected',
|
||||
[ReadyState.CLOSING]: 'disconnected',
|
||||
[ReadyState.CLOSED]: 'disconnected',
|
||||
[ReadyState.UNINSTANTIATED]: 'disconnected',
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: readyState === ReadyState.OPEN,
|
||||
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
|
||||
? prev.status
|
||||
: statusMap[readyState] || 'disconnected',
|
||||
}));
|
||||
}, [readyState]);
|
||||
|
||||
// Process incoming messages
|
||||
useEffect(() => {
|
||||
if (!lastMessage?.data) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(lastMessage.data);
|
||||
const { type, data } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
console.log('[OptStream] Connection confirmed:', data.message);
|
||||
break;
|
||||
|
||||
case 'trial_completed':
|
||||
handleTrialComplete(data as TrialData);
|
||||
break;
|
||||
|
||||
case 'new_best':
|
||||
handleNewBest(data as BestTrialData);
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
handleProgress(data as ProgressData);
|
||||
break;
|
||||
|
||||
case 'pareto_update':
|
||||
handleParetoUpdate(data as ParetoData);
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
case 'pong':
|
||||
// Keep-alive messages
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
handleError(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[OptStream] Unknown message type:', type, data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OptStream] Failed to parse message:', e);
|
||||
}
|
||||
}, [lastMessage]);
|
||||
|
||||
// Handler functions
|
||||
const handleTrialComplete = useCallback((trial: TrialData) => {
|
||||
setState(prev => {
|
||||
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
|
||||
return {
|
||||
...prev,
|
||||
recentTrials: newTrials,
|
||||
lastUpdate: Date.now(),
|
||||
status: 'running',
|
||||
};
|
||||
});
|
||||
|
||||
onTrialComplete?.(trial);
|
||||
}, [maxRecentTrials, onTrialComplete]);
|
||||
|
||||
const handleNewBest = useCallback((best: BestTrialData) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
bestTrial: best,
|
||||
lastUpdate: Date.now(),
|
||||
}));
|
||||
|
||||
onNewBest?.(best);
|
||||
}, [onNewBest]);
|
||||
|
||||
const handleProgress = useCallback((progress: ProgressData) => {
|
||||
setState(prev => {
|
||||
// Determine status based on progress
|
||||
let status: OptimizationStatus = prev.status;
|
||||
if (progress.current > 0 && progress.current < progress.total) {
|
||||
status = 'running';
|
||||
} else if (progress.current >= progress.total) {
|
||||
status = 'completed';
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
progress,
|
||||
status,
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
});
|
||||
|
||||
onProgress?.(progress);
|
||||
}, [onProgress]);
|
||||
|
||||
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
paretoFront: pareto,
|
||||
lastUpdate: Date.now(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Avoid duplicate errors within 5 seconds
|
||||
if (now - lastErrorTime.current < 5000) return;
|
||||
lastErrorTime.current = now;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: errorData.message,
|
||||
status: 'failed',
|
||||
}));
|
||||
|
||||
if (autoReportErrors) {
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorData.message,
|
||||
details: errorData.details,
|
||||
trial: errorData.trial,
|
||||
recoverable: true,
|
||||
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
}, [autoReportErrors, addError]);
|
||||
|
||||
// Send ping to keep connection alive
|
||||
useEffect(() => {
|
||||
if (readyState !== ReadyState.OPEN) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
sendMessage(JSON.stringify({ type: 'ping' }));
|
||||
}, 25000); // Ping every 25 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [readyState, sendMessage]);
|
||||
|
||||
// Reset state when study changes
|
||||
useEffect(() => {
|
||||
setState({
|
||||
isConnected: false,
|
||||
status: 'disconnected',
|
||||
progress: null,
|
||||
bestTrial: null,
|
||||
recentTrials: [],
|
||||
paretoFront: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
}, [studyId]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
|
||||
};
|
||||
}
|
||||
|
||||
export default useOptimizationStream;
|
||||
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal file
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* usePanelStore - Centralized state management for canvas panels
|
||||
*
|
||||
* This store manages the visibility and state of all panels in the canvas view.
|
||||
* Panels persist their state even when the user clicks elsewhere on the canvas.
|
||||
*
|
||||
* Panel Types:
|
||||
* - introspection: Model introspection results (floating, draggable)
|
||||
* - validation: Spec validation errors/warnings (floating)
|
||||
* - results: Trial results details (floating)
|
||||
* - error: Error display with recovery options (floating)
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IntrospectionData {
|
||||
filePath: string;
|
||||
studyId?: string;
|
||||
selectedFile?: string;
|
||||
result?: Record<string, unknown>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string;
|
||||
severity: 'error' | 'warning';
|
||||
path: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationData {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface OptimizationError {
|
||||
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown';
|
||||
trial?: number;
|
||||
message: string;
|
||||
details?: string;
|
||||
recoverable: boolean;
|
||||
suggestions: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface TrialResultData {
|
||||
trialNumber: number;
|
||||
params: Record<string, number>;
|
||||
objectives: Record<string, number>;
|
||||
constraints?: Record<string, { value: number; feasible: boolean }>;
|
||||
isFeasible: boolean;
|
||||
isBest: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PanelPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PanelState {
|
||||
open: boolean;
|
||||
position?: PanelPosition;
|
||||
minimized?: boolean;
|
||||
}
|
||||
|
||||
export interface IntrospectionPanelState extends PanelState {
|
||||
data?: IntrospectionData;
|
||||
}
|
||||
|
||||
export interface ValidationPanelState extends PanelState {
|
||||
data?: ValidationData;
|
||||
}
|
||||
|
||||
export interface ErrorPanelState extends PanelState {
|
||||
errors: OptimizationError[];
|
||||
}
|
||||
|
||||
export interface ResultsPanelState extends PanelState {
|
||||
data?: TrialResultData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Store Interface
|
||||
// ============================================================================
|
||||
|
||||
interface PanelStore {
|
||||
// Panel states
|
||||
introspection: IntrospectionPanelState;
|
||||
validation: ValidationPanelState;
|
||||
error: ErrorPanelState;
|
||||
results: ResultsPanelState;
|
||||
|
||||
// Generic panel actions
|
||||
openPanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
closePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
togglePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
minimizePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
setPanelPosition: (panel: 'introspection' | 'validation' | 'error' | 'results', position: PanelPosition) => void;
|
||||
|
||||
// Introspection-specific actions
|
||||
setIntrospectionData: (data: IntrospectionData) => void;
|
||||
updateIntrospectionResult: (result: Record<string, unknown>) => void;
|
||||
setIntrospectionLoading: (loading: boolean) => void;
|
||||
setIntrospectionError: (error: string | null) => void;
|
||||
setIntrospectionFile: (fileName: string) => void;
|
||||
|
||||
// Validation-specific actions
|
||||
setValidationData: (data: ValidationData) => void;
|
||||
clearValidation: () => void;
|
||||
|
||||
// Error-specific actions
|
||||
addError: (error: OptimizationError) => void;
|
||||
clearErrors: () => void;
|
||||
dismissError: (timestamp: number) => void;
|
||||
|
||||
// Results-specific actions
|
||||
setTrialResult: (data: TrialResultData) => void;
|
||||
clearTrialResult: () => void;
|
||||
|
||||
// Utility
|
||||
closeAllPanels: () => void;
|
||||
hasOpenPanels: () => boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default States
|
||||
// ============================================================================
|
||||
|
||||
const defaultIntrospection: IntrospectionPanelState = {
|
||||
open: false,
|
||||
position: { x: 100, y: 100 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const defaultValidation: ValidationPanelState = {
|
||||
open: false,
|
||||
position: { x: 150, y: 150 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const defaultError: ErrorPanelState = {
|
||||
open: false,
|
||||
position: { x: 200, y: 100 },
|
||||
minimized: false,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const defaultResults: ResultsPanelState = {
|
||||
open: false,
|
||||
position: { x: 250, y: 150 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Store Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const usePanelStore = create<PanelStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial states
|
||||
introspection: defaultIntrospection,
|
||||
validation: defaultValidation,
|
||||
error: defaultError,
|
||||
results: defaultResults,
|
||||
|
||||
// Generic panel actions
|
||||
openPanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: true, minimized: false }
|
||||
})),
|
||||
|
||||
closePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: false }
|
||||
})),
|
||||
|
||||
togglePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: !state[panel].open, minimized: false }
|
||||
})),
|
||||
|
||||
minimizePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], minimized: !state[panel].minimized }
|
||||
})),
|
||||
|
||||
setPanelPosition: (panel, position) => set((state) => ({
|
||||
[panel]: { ...state[panel], position }
|
||||
})),
|
||||
|
||||
// Introspection actions
|
||||
setIntrospectionData: (data) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
updateIntrospectionResult: (result) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, result, isLoading: false, error: null }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionLoading: (loading) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, isLoading: loading }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionError: (error) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, error, isLoading: false }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionFile: (fileName) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, selectedFile: fileName }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
// Validation actions
|
||||
setValidationData: (data) => set((state) => ({
|
||||
validation: {
|
||||
...state.validation,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
clearValidation: () => set((state) => ({
|
||||
validation: {
|
||||
...state.validation,
|
||||
data: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
// Error actions
|
||||
addError: (error) => set((state) => ({
|
||||
error: {
|
||||
...state.error,
|
||||
open: true,
|
||||
errors: [...state.error.errors, error]
|
||||
}
|
||||
})),
|
||||
|
||||
clearErrors: () => set((state) => ({
|
||||
error: {
|
||||
...state.error,
|
||||
errors: [],
|
||||
open: false
|
||||
}
|
||||
})),
|
||||
|
||||
dismissError: (timestamp) => set((state) => {
|
||||
const newErrors = state.error.errors.filter(e => e.timestamp !== timestamp);
|
||||
return {
|
||||
error: {
|
||||
...state.error,
|
||||
errors: newErrors,
|
||||
open: newErrors.length > 0
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// Results actions
|
||||
setTrialResult: (data) => set((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
clearTrialResult: () => set((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
data: undefined,
|
||||
open: false
|
||||
}
|
||||
})),
|
||||
|
||||
// Utility
|
||||
closeAllPanels: () => set({
|
||||
introspection: { ...get().introspection, open: false },
|
||||
validation: { ...get().validation, open: false },
|
||||
error: { ...get().error, open: false },
|
||||
results: { ...get().results, open: false },
|
||||
}),
|
||||
|
||||
hasOpenPanels: () => {
|
||||
const state = get();
|
||||
return state.introspection.open ||
|
||||
state.validation.open ||
|
||||
state.error.open ||
|
||||
state.results.open;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'atomizer-panel-store',
|
||||
// Only persist certain fields (not loading states or errors)
|
||||
partialize: (state) => ({
|
||||
introspection: {
|
||||
position: state.introspection.position,
|
||||
// Don't persist open state - start fresh each session
|
||||
},
|
||||
validation: {
|
||||
position: state.validation.position,
|
||||
},
|
||||
error: {
|
||||
position: state.error.position,
|
||||
},
|
||||
results: {
|
||||
position: state.results.position,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Selector Hooks (for convenience)
|
||||
// ============================================================================
|
||||
|
||||
export const useIntrospectionPanel = () => usePanelStore((state) => state.introspection);
|
||||
export const useValidationPanel = () => usePanelStore((state) => state.validation);
|
||||
export const useErrorPanel = () => usePanelStore((state) => state.error);
|
||||
export const useResultsPanel = () => usePanelStore((state) => state.results);
|
||||
|
||||
// Actions
|
||||
export const usePanelActions = () => usePanelStore((state) => ({
|
||||
openPanel: state.openPanel,
|
||||
closePanel: state.closePanel,
|
||||
togglePanel: state.togglePanel,
|
||||
minimizePanel: state.minimizePanel,
|
||||
setPanelPosition: state.setPanelPosition,
|
||||
setIntrospectionData: state.setIntrospectionData,
|
||||
updateIntrospectionResult: state.updateIntrospectionResult,
|
||||
setIntrospectionLoading: state.setIntrospectionLoading,
|
||||
setIntrospectionError: state.setIntrospectionError,
|
||||
setIntrospectionFile: state.setIntrospectionFile,
|
||||
setValidationData: state.setValidationData,
|
||||
clearValidation: state.clearValidation,
|
||||
addError: state.addError,
|
||||
clearErrors: state.clearErrors,
|
||||
dismissError: state.dismissError,
|
||||
setTrialResult: state.setTrialResult,
|
||||
clearTrialResult: state.clearTrialResult,
|
||||
closeAllPanels: state.closeAllPanels,
|
||||
}));
|
||||
156
atomizer-dashboard/frontend/src/hooks/useResizablePanel.ts
Normal file
156
atomizer-dashboard/frontend/src/hooks/useResizablePanel.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* useResizablePanel - Hook for creating resizable panels with persistence
|
||||
*
|
||||
* Features:
|
||||
* - Drag to resize
|
||||
* - Min/max constraints
|
||||
* - localStorage persistence
|
||||
* - Double-click to reset to default
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export interface ResizablePanelConfig {
|
||||
/** Unique key for localStorage persistence */
|
||||
storageKey: string;
|
||||
/** Default width in pixels */
|
||||
defaultWidth: number;
|
||||
/** Minimum width in pixels */
|
||||
minWidth: number;
|
||||
/** Maximum width in pixels */
|
||||
maxWidth: number;
|
||||
/** Side of the panel ('left' or 'right') - affects resize direction */
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface ResizablePanelState {
|
||||
/** Current width in pixels */
|
||||
width: number;
|
||||
/** Whether user is currently dragging */
|
||||
isDragging: boolean;
|
||||
/** Start drag handler - attach to resize handle mousedown */
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
/** Reset to default width */
|
||||
resetWidth: () => void;
|
||||
/** Set width programmatically */
|
||||
setWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'atomizer-panel-';
|
||||
|
||||
function getStoredWidth(key: string, defaultWidth: number): number {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_PREFIX + key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return defaultWidth;
|
||||
}
|
||||
|
||||
function storeWidth(key: string, width: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + key, String(width));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
export function useResizablePanel(config: ResizablePanelConfig): ResizablePanelState {
|
||||
const { storageKey, defaultWidth, minWidth, maxWidth, side } = config;
|
||||
|
||||
// Initialize from localStorage
|
||||
const [width, setWidthState] = useState(() => {
|
||||
const stored = getStoredWidth(storageKey, defaultWidth);
|
||||
return Math.max(minWidth, Math.min(maxWidth, stored));
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Track initial position for drag calculation
|
||||
const dragStartRef = useRef<{ x: number; width: number } | null>(null);
|
||||
|
||||
// Clamp width within bounds
|
||||
const clampWidth = useCallback((w: number) => {
|
||||
return Math.max(minWidth, Math.min(maxWidth, w));
|
||||
}, [minWidth, maxWidth]);
|
||||
|
||||
// Set width with clamping and persistence
|
||||
const setWidth = useCallback((newWidth: number) => {
|
||||
const clamped = clampWidth(newWidth);
|
||||
setWidthState(clamped);
|
||||
storeWidth(storageKey, clamped);
|
||||
}, [clampWidth, storageKey]);
|
||||
|
||||
// Reset to default
|
||||
const resetWidth = useCallback(() => {
|
||||
setWidth(defaultWidth);
|
||||
}, [defaultWidth, setWidth]);
|
||||
|
||||
// Start drag handler
|
||||
const startDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, width };
|
||||
}, [width]);
|
||||
|
||||
// Handle mouse move during drag
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
|
||||
// For left panels, positive delta increases width
|
||||
// For right panels, negative delta increases width
|
||||
const newWidth = side === 'left'
|
||||
? dragStartRef.current.width + delta
|
||||
: dragStartRef.current.width - delta;
|
||||
|
||||
setWidthState(clampWidth(newWidth));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragStartRef.current) {
|
||||
// Persist the final width
|
||||
storeWidth(storageKey, width);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
// Add listeners to document for smooth dragging
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Change cursor globally during drag
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, side, clampWidth, storageKey, width]);
|
||||
|
||||
return {
|
||||
width,
|
||||
isDragging,
|
||||
startDrag,
|
||||
resetWidth,
|
||||
setWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export default useResizablePanel;
|
||||
@@ -16,6 +16,7 @@ export interface BaseNodeData {
|
||||
label: string;
|
||||
configured: boolean;
|
||||
errors?: string[];
|
||||
resultValue?: number | string | null; // For Results Overlay
|
||||
}
|
||||
|
||||
export interface ModelNodeData extends BaseNodeData {
|
||||
@@ -24,9 +25,17 @@ export interface ModelNodeData extends BaseNodeData {
|
||||
fileType?: 'prt' | 'fem' | 'sim';
|
||||
}
|
||||
|
||||
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
|
||||
export type NastranSolutionType = 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112' | 'SOL200';
|
||||
|
||||
export interface SolverNodeData extends BaseNodeData {
|
||||
type: 'solver';
|
||||
solverType?: 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112';
|
||||
/** Solver engine (nxnastran, mscnastran, python, etc.) */
|
||||
engine?: SolverEngine;
|
||||
/** Solution type for Nastran solvers */
|
||||
solverType?: NastranSolutionType;
|
||||
/** Python script path (for python engine) */
|
||||
scriptPath?: string;
|
||||
}
|
||||
|
||||
export interface DesignVarNodeData extends BaseNodeData {
|
||||
@@ -98,6 +107,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
|
||||
extractorRef?: string; // Reference to extractor ID
|
||||
outputName?: string; // Which output from the extractor
|
||||
penaltyWeight?: number; // For hard constraints (penalty method)
|
||||
history?: number[]; // Recent values for sparkline visualization
|
||||
}
|
||||
|
||||
export interface ConstraintNodeData extends BaseNodeData {
|
||||
@@ -105,6 +115,7 @@ export interface ConstraintNodeData extends BaseNodeData {
|
||||
name?: string;
|
||||
operator?: '<' | '<=' | '>' | '>=' | '==';
|
||||
value?: number;
|
||||
isFeasible?: boolean; // For Results Overlay
|
||||
}
|
||||
|
||||
export interface AlgorithmNodeData extends BaseNodeData {
|
||||
|
||||
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Spec Validator - Validate AtomizerSpec v2.0 before running optimization
|
||||
*
|
||||
* This validator checks the spec for completeness and correctness,
|
||||
* returning structured errors that can be displayed in the ValidationPanel.
|
||||
*/
|
||||
|
||||
import { AtomizerSpec } from '../../types/atomizer-spec';
|
||||
import { ValidationError, ValidationData } from '../../hooks/usePanelStore';
|
||||
|
||||
// ============================================================================
|
||||
// Validation Rules
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationRule {
|
||||
code: string;
|
||||
check: (spec: AtomizerSpec) => ValidationError | null;
|
||||
}
|
||||
|
||||
const validationRules: ValidationRule[] = [
|
||||
// ---- Critical Errors (must fix) ----
|
||||
|
||||
{
|
||||
code: 'NO_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return {
|
||||
code: 'NO_DESIGN_VARS',
|
||||
severity: 'error',
|
||||
path: 'design_variables',
|
||||
message: 'No design variables defined',
|
||||
suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_OBJECTIVES',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length === 0) {
|
||||
return {
|
||||
code: 'NO_OBJECTIVES',
|
||||
severity: 'error',
|
||||
path: 'objectives',
|
||||
message: 'No objectives defined',
|
||||
suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_EXTRACTORS',
|
||||
check: (spec) => {
|
||||
if (spec.extractors.length === 0) {
|
||||
return {
|
||||
code: 'NO_EXTRACTORS',
|
||||
severity: 'error',
|
||||
path: 'extractors',
|
||||
message: 'No extractors defined',
|
||||
suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_MODEL',
|
||||
check: (spec) => {
|
||||
if (!spec.model.sim?.path) {
|
||||
return {
|
||||
code: 'NO_MODEL',
|
||||
severity: 'error',
|
||||
path: 'model.sim.path',
|
||||
message: 'No simulation file configured',
|
||||
suggestion: 'Select a .sim file in the study\'s model directory.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Design Variable Validation ----
|
||||
|
||||
{
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return {
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has invalid bounds (min >= max)`,
|
||||
suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`,
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (!dv.expression_name || dv.expression_name.trim() === '') {
|
||||
return {
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has no NX expression name`,
|
||||
suggestion: 'Set the expression_name to match an NX expression in the model.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Extractor Validation ----
|
||||
|
||||
{
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (!ext.type || ext.type.trim() === '') {
|
||||
return {
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Extractor "${ext.name}" has no type selected`,
|
||||
suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) {
|
||||
return {
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Custom extractor "${ext.name}" has no code defined`,
|
||||
suggestion: 'Open the code editor and write the extraction function.',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Objective Validation ----
|
||||
|
||||
{
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
check: (spec) => {
|
||||
for (const obj of spec.objectives) {
|
||||
// Check if objective is connected to an extractor via canvas edges
|
||||
const hasSource = spec.canvas?.edges?.some(
|
||||
edge => edge.target === obj.id && edge.source.startsWith('ext_')
|
||||
);
|
||||
|
||||
// Also check if source.extractor_id is set
|
||||
const hasDirectSource = obj.source?.extractor_id &&
|
||||
spec.extractors.some(e => e.id === obj.source.extractor_id);
|
||||
|
||||
if (!hasSource && !hasDirectSource) {
|
||||
return {
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
severity: 'error',
|
||||
path: `objectives.${obj.id}`,
|
||||
message: `Objective "${obj.name}" has no connected extractor`,
|
||||
suggestion: 'Connect an extractor to this objective or set source_extractor_id.',
|
||||
nodeId: obj.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Constraint Validation ----
|
||||
|
||||
{
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
check: (spec) => {
|
||||
for (const con of spec.constraints || []) {
|
||||
if (con.threshold === undefined || con.threshold === null) {
|
||||
return {
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
severity: 'error',
|
||||
path: `constraints.${con.id}`,
|
||||
message: `Constraint "${con.name}" has no threshold value`,
|
||||
suggestion: 'Set a threshold value for the constraint.',
|
||||
nodeId: con.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Warnings (can proceed but risky) ----
|
||||
|
||||
{
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials > 500) {
|
||||
return {
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: `High trial count (${maxTrials}) may take several hours to complete`,
|
||||
suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'SINGLE_TRIAL',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials === 1) {
|
||||
return {
|
||||
code: 'SINGLE_TRIAL',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: 'Only 1 trial configured - this will just run a single evaluation',
|
||||
suggestion: 'Increase max_trials to explore the design space.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
const range = dv.bounds.max - dv.bounds.min;
|
||||
const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2;
|
||||
const relativeRange = range / Math.abs(baseline || 1);
|
||||
|
||||
if (relativeRange < 0.01) { // Less than 1% variation
|
||||
return {
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
severity: 'warning',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`,
|
||||
suggestion: 'Consider widening the bounds for more meaningful exploration.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length > 10) {
|
||||
return {
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
severity: 'warning',
|
||||
path: 'design_variables',
|
||||
message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`,
|
||||
suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length > 1) {
|
||||
const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null);
|
||||
if (!hasWeights) {
|
||||
return {
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
severity: 'warning',
|
||||
path: 'objectives',
|
||||
message: 'Multi-objective optimization without explicit weights',
|
||||
suggestion: 'Consider setting weights to control the trade-off between objectives.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Main Validation Function
|
||||
// ============================================================================
|
||||
|
||||
export function validateSpec(spec: AtomizerSpec): ValidationData {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationError[] = [];
|
||||
|
||||
for (const rule of validationRules) {
|
||||
const result = rule.check(spec);
|
||||
if (result) {
|
||||
if (result.severity === 'error') {
|
||||
errors.push(result);
|
||||
} else {
|
||||
warnings.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Validation (just checks if can run)
|
||||
// ============================================================================
|
||||
|
||||
export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } {
|
||||
// Check critical requirements only
|
||||
if (!spec.model.sim?.path) {
|
||||
return { canRun: false, reason: 'No simulation file configured' };
|
||||
}
|
||||
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return { canRun: false, reason: 'No design variables defined' };
|
||||
}
|
||||
|
||||
if (spec.objectives.length === 0) {
|
||||
return { canRun: false, reason: 'No objectives defined' };
|
||||
}
|
||||
|
||||
if (spec.extractors.length === 0) {
|
||||
return { canRun: false, reason: 'No extractors defined' };
|
||||
}
|
||||
|
||||
// Check for invalid bounds
|
||||
for (const dv of enabledDVs) {
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return { canRun: false, reason: `Invalid bounds for "${dv.name}"` };
|
||||
}
|
||||
}
|
||||
|
||||
return { canRun: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export validation result type for backward compatibility
|
||||
// ============================================================================
|
||||
|
||||
export interface LegacyValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult {
|
||||
return {
|
||||
valid: data.valid,
|
||||
errors: data.errors.map(e => e.message),
|
||||
warnings: data.warnings.map(w => w.message),
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,12 @@ import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
|
||||
import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
|
||||
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
||||
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
|
||||
import { PanelContainer } from '../components/canvas/panels/PanelContainer';
|
||||
import { ResizeHandle } from '../components/canvas/ResizeHandle';
|
||||
import { useCanvasStore } from '../hooks/useCanvasStore';
|
||||
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
|
||||
import { useResizablePanel } from '../hooks/useResizablePanel';
|
||||
// usePanelStore is now used by child components - PanelContainer handles panels
|
||||
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
@@ -29,6 +33,23 @@ export function CanvasView() {
|
||||
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
|
||||
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Resizable panels
|
||||
const leftPanel = useResizablePanel({
|
||||
storageKey: 'left-sidebar',
|
||||
defaultWidth: 240,
|
||||
minWidth: 200,
|
||||
maxWidth: 400,
|
||||
side: 'left',
|
||||
});
|
||||
|
||||
const rightPanel = useResizablePanel({
|
||||
storageKey: 'right-panel',
|
||||
defaultWidth: 384,
|
||||
minWidth: 280,
|
||||
maxWidth: 600,
|
||||
side: 'right',
|
||||
});
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Spec mode is the default (AtomizerSpec v2.0)
|
||||
@@ -421,7 +442,10 @@ export function CanvasView() {
|
||||
<main className="flex-1 overflow-hidden flex">
|
||||
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
|
||||
{useSpecMode && (
|
||||
<div className={`${paletteCollapsed ? 'w-14' : 'w-60'} bg-dark-850 border-r border-dark-700 flex flex-col transition-all duration-200`}>
|
||||
<div
|
||||
className="relative bg-dark-850 border-r border-dark-700 flex flex-col"
|
||||
style={{ width: paletteCollapsed ? 56 : leftPanel.width }}
|
||||
>
|
||||
{/* Tab buttons (only show when expanded) */}
|
||||
{!paletteCollapsed && (
|
||||
<div className="flex border-b border-dark-700">
|
||||
@@ -467,6 +491,16 @@ export function CanvasView() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize handle (only when not collapsed) */}
|
||||
{!paletteCollapsed && (
|
||||
<ResizeHandle
|
||||
onMouseDown={leftPanel.startDrag}
|
||||
onDoubleClick={leftPanel.resetWidth}
|
||||
isDragging={leftPanel.isDragging}
|
||||
position="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -492,14 +526,35 @@ export function CanvasView() {
|
||||
{/* Shows INSTEAD of chat when a node is selected */}
|
||||
{selectedNodeId ? (
|
||||
useSpecMode ? (
|
||||
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
|
||||
<div
|
||||
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
||||
style={{ width: rightPanel.width }}
|
||||
>
|
||||
<ResizeHandle
|
||||
onMouseDown={rightPanel.startDrag}
|
||||
onDoubleClick={rightPanel.resetWidth}
|
||||
isDragging={rightPanel.isDragging}
|
||||
position="left"
|
||||
/>
|
||||
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
|
||||
<NodeConfigPanel nodeId={selectedNodeId} />
|
||||
</div>
|
||||
)
|
||||
) : showChat ? (
|
||||
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
|
||||
<div
|
||||
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
||||
style={{ width: rightPanel.width }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<ResizeHandle
|
||||
onMouseDown={rightPanel.startDrag}
|
||||
onDoubleClick={rightPanel.resetWidth}
|
||||
isDragging={rightPanel.isDragging}
|
||||
position="left"
|
||||
/>
|
||||
{/* Chat Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -556,6 +611,9 @@ export function CanvasView() {
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
||||
{useSpecMode && <PanelContainer />}
|
||||
|
||||
{/* Notification Toast */}
|
||||
{notification && (
|
||||
<div
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface SpecMeta {
|
||||
tags?: string[];
|
||||
/** Real-world engineering context */
|
||||
engineering_context?: string;
|
||||
/** Current workflow status */
|
||||
status?: 'draft' | 'introspected' | 'configured' | 'validated' | 'ready' | 'running' | 'completed' | 'failed';
|
||||
/** Topic/folder for organization */
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -64,6 +68,29 @@ export interface FemConfig {
|
||||
}
|
||||
|
||||
export type SolverType = 'nastran' | 'NX_Nastran' | 'abaqus';
|
||||
|
||||
/**
|
||||
* SolverEngine - The actual solver software used for analysis
|
||||
* - nxnastran: NX Nastran (built into Siemens NX)
|
||||
* - mscnastran: MSC Nastran (external)
|
||||
* - python: Custom Python script
|
||||
* - abaqus: Abaqus (future)
|
||||
* - ansys: ANSYS (future)
|
||||
*/
|
||||
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
|
||||
|
||||
/**
|
||||
* NastranSolutionType - Common Nastran solution types
|
||||
*/
|
||||
export type NastranSolutionType =
|
||||
| 'SOL101' // Linear Statics
|
||||
| 'SOL103' // Normal Modes
|
||||
| 'SOL105' // Buckling
|
||||
| 'SOL106' // Nonlinear Statics
|
||||
| 'SOL111' // Modal Frequency Response
|
||||
| 'SOL112' // Modal Transient Response
|
||||
| 'SOL200'; // Design Optimization
|
||||
|
||||
export type SubcaseType = 'static' | 'modal' | 'thermal' | 'buckling';
|
||||
|
||||
export interface Subcase {
|
||||
@@ -75,10 +102,14 @@ export interface Subcase {
|
||||
export interface SimConfig {
|
||||
/** Path to .sim file */
|
||||
path: string;
|
||||
/** Solver type */
|
||||
/** Solver type (legacy, use engine instead) */
|
||||
solver: SolverType;
|
||||
/** Solver engine software */
|
||||
engine?: SolverEngine;
|
||||
/** Solution type (e.g., SOL101) */
|
||||
solution_type?: string;
|
||||
solution_type?: NastranSolutionType | string;
|
||||
/** Python script path (for python engine) */
|
||||
script_path?: string;
|
||||
/** Defined subcases */
|
||||
subcases?: Subcase[];
|
||||
}
|
||||
@@ -89,11 +120,40 @@ export interface NxSettings {
|
||||
auto_start_nx?: boolean;
|
||||
}
|
||||
|
||||
export interface IntrospectionExpression {
|
||||
name: string;
|
||||
value: number | null;
|
||||
units: string | null;
|
||||
formula: string | null;
|
||||
is_candidate: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface IntrospectionData {
|
||||
timestamp: string;
|
||||
solver_type: string | null;
|
||||
mass_kg: number | null;
|
||||
volume_mm3: number | null;
|
||||
expressions: IntrospectionExpression[];
|
||||
warnings: string[];
|
||||
baseline: {
|
||||
timestamp: string;
|
||||
solve_time_seconds: number;
|
||||
mass_kg: number | null;
|
||||
max_displacement_mm: number | null;
|
||||
max_stress_mpa: number | null;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
nx_part?: NxPartConfig;
|
||||
prt?: NxPartConfig;
|
||||
fem?: FemConfig;
|
||||
sim: SimConfig;
|
||||
sim?: SimConfig;
|
||||
nx_settings?: NxSettings;
|
||||
introspection?: IntrospectionData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Atomizer Documentation Index
|
||||
|
||||
**Last Updated**: 2026-01-20
|
||||
**Project Version**: 1.0.0 (AtomizerSpec v2.0 - Full LLM Integration)
|
||||
**Last Updated**: 2026-01-24
|
||||
**Project Version**: 0.5.0 (AtomizerSpec v2.0 - Canvas Builder)
|
||||
|
||||
---
|
||||
|
||||
@@ -201,6 +201,8 @@ Historical documents are preserved in `archive/`:
|
||||
- `archive/historical/` - Legacy documents, old protocols
|
||||
- `archive/marketing/` - Briefings, presentations
|
||||
- `archive/session_summaries/` - Past development sessions
|
||||
- `archive/plans/` - Superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
|
||||
- `archive/PROTOCOL_V1_MONOLITHIC.md` - Original monolithic protocol (Nov 2025)
|
||||
|
||||
---
|
||||
|
||||
@@ -216,5 +218,5 @@ For Claude/AI integration:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-20
|
||||
**Last Updated**: 2026-01-24
|
||||
**Maintained By**: Antoine / Atomaste
|
||||
|
||||
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal file
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal file
@@ -0,0 +1,438 @@
|
||||
# Canvas Builder Robustness & Enhancement Plan
|
||||
|
||||
**Created**: January 21, 2026
|
||||
**Branch**: `feature/studio-enhancement`
|
||||
**Status**: Planning
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan addresses critical issues and enhancements to make the Canvas Builder robust and production-ready:
|
||||
|
||||
1. **Panel Management** - Panels (Introspection, Config, Chat) disappear unexpectedly
|
||||
2. **Pre-run Validation** - No validation before starting optimization
|
||||
3. **Error Handling** - Poor feedback when things go wrong
|
||||
4. **Live Updates** - Polling is inefficient; need WebSocket
|
||||
5. **Visualization** - No convergence charts or progress indicators
|
||||
6. **Testing** - No automated tests for critical flows
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Panel Management System (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- IntrospectionPanel disappears when user clicks elsewhere on canvas
|
||||
- Panel state is lost (e.g., introspection results, expanded sections)
|
||||
- No way to have multiple panels open simultaneously
|
||||
- Chat panel and Config panel are mutually exclusive
|
||||
|
||||
### Root Cause
|
||||
```typescript
|
||||
// Current: Local state in ModelNodeConfig (NodeConfigPanelV2.tsx:275)
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
|
||||
// When selectedNodeId changes, ModelNodeConfig unmounts, losing state
|
||||
```
|
||||
|
||||
### Solution: Centralized Panel Store
|
||||
|
||||
Create `usePanelStore.ts` - a Zustand store for panel management:
|
||||
|
||||
```typescript
|
||||
// atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
|
||||
|
||||
interface PanelState {
|
||||
// Panel visibility
|
||||
panels: {
|
||||
introspection: { open: boolean; filePath?: string; data?: IntrospectionResult };
|
||||
config: { open: boolean; nodeId?: string };
|
||||
chat: { open: boolean; powerMode: boolean };
|
||||
validation: { open: boolean; errors?: ValidationError[] };
|
||||
results: { open: boolean; trialId?: number };
|
||||
};
|
||||
|
||||
// Actions
|
||||
openPanel: (panel: PanelName, data?: any) => void;
|
||||
closePanel: (panel: PanelName) => void;
|
||||
togglePanel: (panel: PanelName) => void;
|
||||
|
||||
// Panel data persistence
|
||||
setIntrospectionData: (data: IntrospectionResult) => void;
|
||||
clearIntrospectionData: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 1.1 | `usePanelStore.ts` | Create Zustand store for panel state |
|
||||
| 1.2 | `PanelContainer.tsx` | Create container that renders open panels |
|
||||
| 1.3 | `IntrospectionPanel.tsx` | Refactor to use store instead of local state |
|
||||
| 1.4 | `NodeConfigPanelV2.tsx` | Remove local panel state, use store |
|
||||
| 1.5 | `CanvasView.tsx` | Integrate PanelContainer, remove chat panel logic |
|
||||
| 1.6 | `SpecRenderer.tsx` | Add panel trigger buttons (introspect, validate) |
|
||||
|
||||
### UI Changes
|
||||
|
||||
**Before:**
|
||||
```
|
||||
[Canvas] [Config Panel OR Chat Panel]
|
||||
↑ mutually exclusive
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
[Canvas] [Right Panel Area]
|
||||
├── Config Panel (pinnable)
|
||||
├── Chat Panel (collapsible)
|
||||
└── Floating Panels:
|
||||
├── Introspection (draggable, persistent)
|
||||
├── Validation Results
|
||||
└── Trial Details
|
||||
```
|
||||
|
||||
### Panel Behaviors
|
||||
|
||||
| Panel | Trigger | Persistence | Position |
|
||||
|-------|---------|-------------|----------|
|
||||
| **Config** | Node click | While node selected | Right sidebar |
|
||||
| **Chat** | Toggle button | Always available | Right sidebar (below config) |
|
||||
| **Introspection** | "Introspect" button | Until explicitly closed | Floating, draggable |
|
||||
| **Validation** | "Validate" or pre-run | Until fixed or dismissed | Floating |
|
||||
| **Results** | Click on result badge | Until dismissed | Floating |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Pre-run Validation (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- User can click "Run" with incomplete spec
|
||||
- No feedback about missing extractors, objectives, or connections
|
||||
- Optimization fails silently or with cryptic errors
|
||||
|
||||
### Solution: Validation Pipeline
|
||||
|
||||
```typescript
|
||||
// Types of validation
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[]; // Must fix before running
|
||||
warnings: ValidationWarning[]; // Can proceed but risky
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
code: string;
|
||||
severity: 'error' | 'warning';
|
||||
path: string; // e.g., "objectives[0]"
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
autoFix?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Rules
|
||||
|
||||
| Rule | Severity | Message |
|
||||
|------|----------|---------|
|
||||
| No design variables | Error | "Add at least one design variable" |
|
||||
| No objectives | Error | "Add at least one objective" |
|
||||
| Objective not connected to extractor | Error | "Objective '{name}' has no source extractor" |
|
||||
| Extractor type not set | Error | "Extractor '{name}' needs a type selected" |
|
||||
| Design var bounds invalid | Error | "Min must be less than max for '{name}'" |
|
||||
| No model file | Error | "No simulation file configured" |
|
||||
| Custom extractor no code | Warning | "Custom extractor '{name}' has no code" |
|
||||
| High trial count (>500) | Warning | "Large budget may take hours to complete" |
|
||||
| Single trial | Warning | "Only 1 trial - results won't be meaningful" |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 2.1 | `validation/specValidator.ts` | Client-side validation rules |
|
||||
| 2.2 | `ValidationPanel.tsx` | Display validation results |
|
||||
| 2.3 | `SpecRenderer.tsx` | Add "Validate" button, pre-run check |
|
||||
| 2.4 | `api/routes/spec.py` | Server-side validation endpoint |
|
||||
| 2.5 | `useSpecStore.ts` | Add `validate()` action |
|
||||
|
||||
### UI Flow
|
||||
|
||||
```
|
||||
User clicks "Run Optimization"
|
||||
↓
|
||||
[Validate Spec] ──failed──→ [Show ValidationPanel]
|
||||
↓ passed │
|
||||
[Confirm Dialog] │
|
||||
↓ confirmed │
|
||||
[Start Optimization] ←── fix ─────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Error Handling & Recovery (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- NX crashes don't show useful feedback
|
||||
- Solver failures leave user confused
|
||||
- No way to resume after errors
|
||||
|
||||
### Solution: Error Classification & Display
|
||||
|
||||
```typescript
|
||||
interface OptimizationError {
|
||||
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error';
|
||||
trial?: number;
|
||||
message: string;
|
||||
details?: string;
|
||||
recoverable: boolean;
|
||||
suggestions: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
| Error Type | Display | Recovery |
|
||||
|------------|---------|----------|
|
||||
| NX Crash | Toast + Error Panel | Retry trial, skip trial |
|
||||
| Solver Failure | Badge on trial | Mark infeasible, continue |
|
||||
| Extractor Error | Log + badge | Use NaN, continue |
|
||||
| Config Error | Block run | Show validation panel |
|
||||
| System Error | Full modal | Restart optimization |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 3.1 | `ErrorBoundary.tsx` | Wrap canvas in error boundary |
|
||||
| 3.2 | `ErrorPanel.tsx` | Detailed error display with suggestions |
|
||||
| 3.3 | `optimization.py` | Enhanced error responses with type/recovery |
|
||||
| 3.4 | `SpecRenderer.tsx` | Error state handling, retry buttons |
|
||||
| 3.5 | `useOptimizationStatus.ts` | Hook for status polling with error handling |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Live Updates via WebSocket (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- Current polling (3s) is inefficient and has latency
|
||||
- Missed updates between polls
|
||||
- No real-time progress indication
|
||||
|
||||
### Solution: WebSocket for Trial Updates
|
||||
|
||||
```typescript
|
||||
// WebSocket events
|
||||
interface TrialStartEvent {
|
||||
type: 'trial_start';
|
||||
trial_number: number;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
interface TrialCompleteEvent {
|
||||
type: 'trial_complete';
|
||||
trial_number: number;
|
||||
objectives: Record<string, number>;
|
||||
is_best: boolean;
|
||||
is_feasible: boolean;
|
||||
}
|
||||
|
||||
interface OptimizationCompleteEvent {
|
||||
type: 'optimization_complete';
|
||||
best_trial: number;
|
||||
total_trials: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 4.1 | `websocket.py` | Add optimization events to WS |
|
||||
| 4.2 | `run_optimization.py` | Emit events during optimization |
|
||||
| 4.3 | `useOptimizationWebSocket.ts` | Hook for WS subscription |
|
||||
| 4.4 | `SpecRenderer.tsx` | Use WS instead of polling |
|
||||
| 4.5 | `ResultBadge.tsx` | Animate on new results |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Convergence Visualization (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- No visual feedback on optimization progress
|
||||
- Can't tell if converging or stuck
|
||||
- No Pareto front visualization for multi-objective
|
||||
|
||||
### Solution: Embedded Charts
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `ConvergenceSparkline` | Tiny chart in ObjectiveNode showing trend |
|
||||
| `ProgressRing` | Circular progress in header (trials/total) |
|
||||
| `ConvergenceChart` | Full chart in Results panel |
|
||||
| `ParetoPlot` | 2D Pareto front for multi-objective |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 5.1 | `ConvergenceSparkline.tsx` | SVG sparkline component |
|
||||
| 5.2 | `ObjectiveNode.tsx` | Integrate sparkline |
|
||||
| 5.3 | `ProgressRing.tsx` | Circular progress indicator |
|
||||
| 5.4 | `ConvergenceChart.tsx` | Full chart with Recharts |
|
||||
| 5.5 | `ResultsPanel.tsx` | Panel showing detailed results |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: End-to-End Testing (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- No automated tests for canvas operations
|
||||
- Manual testing is time-consuming and error-prone
|
||||
- Regressions go unnoticed
|
||||
|
||||
### Solution: Playwright E2E Tests
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
| Test | Steps | Assertions |
|
||||
|------|-------|------------|
|
||||
| Load study | Navigate to /canvas/{id} | Spec loads, nodes render |
|
||||
| Add design var | Drag from palette | Node appears, spec updates |
|
||||
| Connect nodes | Drag edge | Edge renders, spec has edge |
|
||||
| Edit node | Click node, change value | Value persists, API called |
|
||||
| Run validation | Click validate | Errors shown for incomplete |
|
||||
| Start optimization | Complete spec, click run | Status shows running |
|
||||
| View results | Wait for trial | Badge shows value |
|
||||
| Stop optimization | Click stop | Status shows stopped |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 6.1 | `e2e/canvas.spec.ts` | Basic canvas operations |
|
||||
| 6.2 | `e2e/optimization.spec.ts` | Run/stop/status flow |
|
||||
| 6.3 | `e2e/panels.spec.ts` | Panel open/close/persist |
|
||||
| 6.4 | `playwright.config.ts` | Configure Playwright |
|
||||
| 6.5 | `CI workflow` | Run tests in GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Week 1:
|
||||
├── Phase 1: Panel Management (critical UX fix)
|
||||
│ ├── Day 1-2: usePanelStore + PanelContainer
|
||||
│ └── Day 3-4: Refactor existing panels
|
||||
│
|
||||
├── Phase 2: Validation (prevent user errors)
|
||||
│ └── Day 5: Validation rules + UI
|
||||
|
||||
Week 2:
|
||||
├── Phase 3: Error Handling
|
||||
│ ├── Day 1-2: Error types + ErrorPanel
|
||||
│ └── Day 3: Integration with optimization flow
|
||||
│
|
||||
├── Phase 4: WebSocket Updates
|
||||
│ └── Day 4-5: WS events + frontend hook
|
||||
|
||||
Week 3:
|
||||
├── Phase 5: Visualization
|
||||
│ ├── Day 1-2: Sparklines
|
||||
│ └── Day 3: Progress indicators
|
||||
│
|
||||
├── Phase 6: Testing
|
||||
│ └── Day 4-5: Playwright setup + core tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Can Do Now)
|
||||
|
||||
These can be implemented immediately with minimal changes:
|
||||
|
||||
1. **Persist introspection data in localStorage**
|
||||
- Cache introspection results
|
||||
- Restore on panel reopen
|
||||
|
||||
2. **Add loading states to all buttons**
|
||||
- Disable during operations
|
||||
- Show spinners
|
||||
|
||||
3. **Add confirmation dialogs**
|
||||
- Before stopping optimization
|
||||
- Before clearing canvas
|
||||
|
||||
4. **Improve error messages**
|
||||
- Parse NX error logs
|
||||
- Show actionable suggestions
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
```
|
||||
atomizer-dashboard/frontend/src/
|
||||
├── hooks/
|
||||
│ ├── usePanelStore.ts
|
||||
│ └── useOptimizationWebSocket.ts
|
||||
├── components/canvas/
|
||||
│ ├── PanelContainer.tsx
|
||||
│ ├── panels/
|
||||
│ │ ├── ValidationPanel.tsx
|
||||
│ │ ├── ErrorPanel.tsx
|
||||
│ │ └── ResultsPanel.tsx
|
||||
│ └── visualization/
|
||||
│ ├── ConvergenceSparkline.tsx
|
||||
│ ├── ProgressRing.tsx
|
||||
│ └── ConvergenceChart.tsx
|
||||
└── lib/
|
||||
└── validation/
|
||||
└── specValidator.ts
|
||||
|
||||
e2e/
|
||||
├── canvas.spec.ts
|
||||
├── optimization.spec.ts
|
||||
└── panels.spec.ts
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
atomizer-dashboard/frontend/src/
|
||||
├── pages/CanvasView.tsx
|
||||
├── components/canvas/SpecRenderer.tsx
|
||||
├── components/canvas/panels/IntrospectionPanel.tsx
|
||||
├── components/canvas/panels/NodeConfigPanelV2.tsx
|
||||
├── components/canvas/nodes/ObjectiveNode.tsx
|
||||
└── hooks/useSpecStore.ts
|
||||
|
||||
atomizer-dashboard/backend/api/
|
||||
├── routes/optimization.py
|
||||
├── routes/spec.py
|
||||
└── websocket.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| Phase | Success Metric |
|
||||
|-------|----------------|
|
||||
| 1 | Introspection panel persists across node selections |
|
||||
| 2 | Invalid spec shows clear error before run |
|
||||
| 3 | NX errors display with recovery options |
|
||||
| 4 | Results update within 500ms of trial completion |
|
||||
| 5 | Convergence trend visible on objective nodes |
|
||||
| 6 | All E2E tests pass in CI |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review this plan
|
||||
2. Start with Phase 1 (Panel Management) - fixes your immediate issue
|
||||
3. Implement incrementally, commit after each phase
|
||||
445
docs/plans/CANVAS_UX_IMPROVEMENTS.md
Normal file
445
docs/plans/CANVAS_UX_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Canvas UX Improvements - Master Plan
|
||||
|
||||
**Created:** January 2026
|
||||
**Status:** Planning
|
||||
**Branch:** `feature/studio-enhancement`
|
||||
|
||||
## Overview
|
||||
|
||||
This plan addresses three major UX issues in the Canvas Builder:
|
||||
|
||||
1. **Resizable Panels** - Right pane (chat/config) is fixed at 384px, cannot be adjusted
|
||||
2. **Disabled Palette Items** - Model, Solver, Algorithm, Surrogate are grayed out and not draggable
|
||||
3. **Solver Type Selection** - Solver node should allow selection of solver type (NX Nastran, Python, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Resizable Panels
|
||||
|
||||
### Current State
|
||||
- Left sidebar: Fixed 240px (expanded) or 56px (collapsed)
|
||||
- Right panel (Chat/Config): Fixed 384px
|
||||
- Canvas: Takes remaining space
|
||||
|
||||
### Requirements
|
||||
- Users should be able to drag panel edges to resize
|
||||
- Minimum/maximum constraints for usability
|
||||
- Persist panel sizes in localStorage
|
||||
- Smooth resize with proper cursor feedback
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 7.1 Create Resizable Panel Hook
|
||||
```typescript
|
||||
// hooks/useResizablePanel.ts
|
||||
interface ResizablePanelState {
|
||||
width: number;
|
||||
isDragging: boolean;
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function useResizablePanel(
|
||||
key: string,
|
||||
defaultWidth: number,
|
||||
minWidth: number,
|
||||
maxWidth: number
|
||||
): ResizablePanelState
|
||||
```
|
||||
|
||||
#### 7.2 Update CanvasView Layout
|
||||
- Wrap left sidebar with resizer
|
||||
- Wrap right panel with resizer
|
||||
- Add visual drag handles (thin border that highlights on hover)
|
||||
- Add cursor: col-resize on hover
|
||||
|
||||
#### 7.3 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `hooks/useResizablePanel.ts` | NEW - Resize hook with localStorage persistence |
|
||||
| `pages/CanvasView.tsx` | Add resizers to left/right panels |
|
||||
| `components/canvas/ResizeHandle.tsx` | NEW - Visual resize handle component |
|
||||
|
||||
#### 7.4 Constraints
|
||||
| Panel | Min | Default | Max |
|
||||
|-------|-----|---------|-----|
|
||||
| Left (Palette/Files) | 200px | 240px | 400px |
|
||||
| Right (Chat/Config) | 280px | 384px | 600px |
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Enable All Palette Items
|
||||
|
||||
### Current State
|
||||
- Model, Solver, Algorithm, Surrogate are marked `canAdd: false`
|
||||
- They appear grayed out with "Auto-created" text
|
||||
- Users cannot drag them to canvas
|
||||
|
||||
### Problem Analysis
|
||||
These nodes were marked as "synthetic" because they're derived from:
|
||||
- **Model**: From `spec.model.sim.path`
|
||||
- **Solver**: From model's solution type
|
||||
- **Algorithm**: From `spec.optimization.algorithm`
|
||||
- **Surrogate**: From `spec.optimization.surrogate`
|
||||
|
||||
However, users need to:
|
||||
1. Add a Model node when creating a new study from scratch
|
||||
2. Configure the Solver type
|
||||
3. Choose an Algorithm
|
||||
4. Enable/configure Surrogate
|
||||
|
||||
### Solution: Make All Items Draggable
|
||||
|
||||
#### 8.1 Update NodePalette
|
||||
```typescript
|
||||
// All items should be draggable
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
label: 'Model',
|
||||
canAdd: true, // Changed from false
|
||||
description: 'NX/FEM model file',
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
canAdd: true, // Changed from false
|
||||
description: 'Analysis solver',
|
||||
},
|
||||
// ... etc
|
||||
];
|
||||
```
|
||||
|
||||
#### 8.2 Handle "Singleton" Nodes
|
||||
Some nodes should only exist once on the canvas:
|
||||
- Model (only one model per study)
|
||||
- Solver (one solver)
|
||||
- Algorithm (one algorithm config)
|
||||
- Surrogate (optional, one)
|
||||
|
||||
When user drags a singleton that already exists:
|
||||
- Option A: Show warning toast "Model already exists"
|
||||
- Option B: Select the existing node instead of creating new
|
||||
- **Recommended**: Option B (select existing)
|
||||
|
||||
#### 8.3 Update SpecRenderer Drop Handler
|
||||
```typescript
|
||||
const onDrop = useCallback(async (event: DragEvent) => {
|
||||
const type = event.dataTransfer.getData('application/reactflow');
|
||||
|
||||
// Check if singleton already exists
|
||||
const SINGLETON_TYPES = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
if (SINGLETON_TYPES.includes(type)) {
|
||||
const existingNode = nodes.find(n => n.type === type);
|
||||
if (existingNode) {
|
||||
selectNode(existingNode.id);
|
||||
showNotification(`${type} already exists - selected it`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new node...
|
||||
}, [...]);
|
||||
```
|
||||
|
||||
#### 8.4 Default Data for New Node Types
|
||||
```typescript
|
||||
function getDefaultNodeData(type: NodeType, position) {
|
||||
switch (type) {
|
||||
case 'model':
|
||||
return {
|
||||
name: 'Model',
|
||||
sim: { path: '', solver: 'nastran' },
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'solver':
|
||||
return {
|
||||
name: 'Solver',
|
||||
type: 'nxnastran', // Default solver
|
||||
solution_type: 'SOL101',
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'algorithm':
|
||||
return {
|
||||
name: 'Algorithm',
|
||||
type: 'TPE',
|
||||
budget: { max_trials: 100 },
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'surrogate':
|
||||
return {
|
||||
name: 'Surrogate',
|
||||
enabled: false,
|
||||
model_type: 'MLP',
|
||||
min_trials: 20,
|
||||
canvas_position: position,
|
||||
};
|
||||
// ... existing cases
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.5 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `components/canvas/palette/NodePalette.tsx` | Set `canAdd: true` for all items |
|
||||
| `components/canvas/SpecRenderer.tsx` | Handle singleton logic in onDrop |
|
||||
| `lib/spec/converter.ts` | Ensure synthetic nodes have proper IDs |
|
||||
| `hooks/useSpecStore.ts` | Add model/solver/algorithm to addNode support |
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Solver Type Selection
|
||||
|
||||
### Current State
|
||||
- Solver node shows auto-detected solution type (SOL101, etc.)
|
||||
- No ability to change solver engine or configure it
|
||||
|
||||
### Requirements
|
||||
1. Allow selection of solver engine type
|
||||
2. Configure solution type
|
||||
3. Support future solver types
|
||||
|
||||
### Solver Types to Support
|
||||
|
||||
| Solver | Description | Status |
|
||||
|--------|-------------|--------|
|
||||
| `nxnastran` | NX Nastran (built-in) | Current |
|
||||
| `mscnastran` | MSC Nastran (external) | Future |
|
||||
| `python` | Python-based solver | Future |
|
||||
| `abaqus` | Abaqus (via Python API) | Future |
|
||||
| `ansys` | ANSYS (via Python API) | Future |
|
||||
|
||||
### Solution Types per Solver
|
||||
|
||||
**NX Nastran / MSC Nastran:**
|
||||
- SOL101 - Linear Static
|
||||
- SOL103 - Normal Modes
|
||||
- SOL105 - Buckling
|
||||
- SOL106 - Nonlinear Static
|
||||
- SOL111 - Frequency Response
|
||||
- SOL112 - Transient Response
|
||||
- SOL200 - Design Optimization
|
||||
|
||||
**Python Solver:**
|
||||
- Custom (user-defined)
|
||||
|
||||
### Schema Updates
|
||||
|
||||
#### 9.1 Update AtomizerSpec Types
|
||||
```typescript
|
||||
// types/atomizer-spec.ts
|
||||
|
||||
export type SolverEngine =
|
||||
| 'nxnastran'
|
||||
| 'mscnastran'
|
||||
| 'python'
|
||||
| 'abaqus'
|
||||
| 'ansys';
|
||||
|
||||
export type NastranSolutionType =
|
||||
| 'SOL101'
|
||||
| 'SOL103'
|
||||
| 'SOL105'
|
||||
| 'SOL106'
|
||||
| 'SOL111'
|
||||
| 'SOL112'
|
||||
| 'SOL200';
|
||||
|
||||
export interface SolverConfig {
|
||||
/** Solver engine type */
|
||||
engine: SolverEngine;
|
||||
|
||||
/** Solution type (for Nastran) */
|
||||
solution_type?: NastranSolutionType;
|
||||
|
||||
/** Custom solver script path (for Python solver) */
|
||||
script_path?: string;
|
||||
|
||||
/** Additional solver options */
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
sim?: {
|
||||
path: string;
|
||||
solver: SolverConfig; // Changed from just 'nastran' string
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.2 Update SolverNode Component
|
||||
```typescript
|
||||
// components/canvas/nodes/SolverNode.tsx
|
||||
|
||||
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{data.engine || 'nxnastran'}</span>
|
||||
<span className="text-xs text-dark-400">
|
||||
{data.solution_type || 'Auto-detect'}
|
||||
</span>
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.3 Solver Configuration Panel
|
||||
Add to `NodeConfigPanelV2.tsx`:
|
||||
|
||||
```typescript
|
||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
||||
const { patchSpec } = useSpecStore();
|
||||
const solver = spec.model?.sim?.solver || { engine: 'nxnastran' };
|
||||
|
||||
const handleEngineChange = (engine: SolverEngine) => {
|
||||
patchSpec('model.sim.solver.engine', engine);
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = (type: NastranSolutionType) => {
|
||||
patchSpec('model.sim.solver.solution_type', type);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Solver Engine</label>
|
||||
<select
|
||||
value={solver.engine}
|
||||
onChange={(e) => handleEngineChange(e.target.value as SolverEngine)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="nxnastran">NX Nastran</option>
|
||||
<option value="mscnastran">MSC Nastran</option>
|
||||
<option value="python">Python Script</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(solver.engine === 'nxnastran' || solver.engine === 'mscnastran') && (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<select
|
||||
value={solver.solution_type || ''}
|
||||
onChange={(e) => handleSolutionTypeChange(e.target.value as NastranSolutionType)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Auto-detect from model</option>
|
||||
<option value="SOL101">SOL101 - Linear Static</option>
|
||||
<option value="SOL103">SOL103 - Normal Modes</option>
|
||||
<option value="SOL105">SOL105 - Buckling</option>
|
||||
<option value="SOL106">SOL106 - Nonlinear Static</option>
|
||||
<option value="SOL111">SOL111 - Frequency Response</option>
|
||||
<option value="SOL112">SOL112 - Transient Response</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{solver.engine === 'python' && (
|
||||
<div>
|
||||
<label className={labelClass}>Solver Script</label>
|
||||
<input
|
||||
type="text"
|
||||
value={solver.script_path || ''}
|
||||
onChange={(e) => patchSpec('model.sim.solver.script_path', e.target.value)}
|
||||
placeholder="/path/to/solver.py"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Python script that runs the analysis
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.4 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `types/atomizer-spec.ts` | Add SolverEngine, SolverConfig types |
|
||||
| `components/canvas/nodes/SolverNode.tsx` | Show engine and solution type |
|
||||
| `components/canvas/panels/NodeConfigPanelV2.tsx` | Add SolverNodeConfig |
|
||||
| `lib/canvas/schema.ts` | Update SolverNodeData |
|
||||
| Backend: `config/spec_models.py` | Add SolverConfig Pydantic model |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| Phase | Effort | Priority | Dependencies |
|
||||
|-------|--------|----------|--------------|
|
||||
| **7.1** Resizable Panel Hook | 2h | High | None |
|
||||
| **7.2** CanvasView Resizers | 2h | High | 7.1 |
|
||||
| **8.1** Enable Palette Items | 1h | High | None |
|
||||
| **8.2** Singleton Logic | 2h | High | 8.1 |
|
||||
| **8.3** Default Node Data | 1h | High | 8.2 |
|
||||
| **9.1** Schema Updates | 2h | Medium | None |
|
||||
| **9.2** SolverNode UI | 1h | Medium | 9.1 |
|
||||
| **9.3** Solver Config Panel | 2h | Medium | 9.1, 9.2 |
|
||||
|
||||
**Total Estimated Effort:** ~13 hours
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 7 (Resizable Panels)
|
||||
- [ ] Left panel can be resized between 200-400px
|
||||
- [ ] Right panel can be resized between 280-600px
|
||||
- [ ] Resize handles show cursor feedback
|
||||
- [ ] Panel sizes persist across page reload
|
||||
- [ ] Double-click on handle resets to default
|
||||
|
||||
### Phase 8 (Enable Palette Items)
|
||||
- [ ] All 8 node types are draggable from palette
|
||||
- [ ] Dragging singleton to canvas with existing node selects existing
|
||||
- [ ] Toast notification explains the behavior
|
||||
- [ ] New studies can start with empty canvas and add Model first
|
||||
|
||||
### Phase 9 (Solver Selection)
|
||||
- [ ] Solver node shows engine type (nxnastran, python, etc.)
|
||||
- [ ] Clicking solver node opens config panel
|
||||
- [ ] Can select solver engine from dropdown
|
||||
- [ ] Nastran solvers show solution type dropdown
|
||||
- [ ] Python solver shows script path input
|
||||
- [ ] Changes persist to atomizer_spec.json
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Additional Solver Support
|
||||
- ANSYS integration via pyANSYS
|
||||
- Abaqus integration via abaqus-python
|
||||
- OpenFOAM for CFD
|
||||
- Custom Python solvers with standardized interface
|
||||
|
||||
### Multi-Solver Workflows
|
||||
- Support for chained solvers (thermal → structural)
|
||||
- Co-simulation workflows
|
||||
- Parallel solver execution
|
||||
|
||||
### Algorithm Node Enhancement
|
||||
- Similar to Solver, allow algorithm selection
|
||||
- Show algorithm-specific parameters
|
||||
- Support custom algorithms
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
```bash
|
||||
# Phase 7
|
||||
git commit -m "feat: Add resizable panels to canvas view"
|
||||
|
||||
# Phase 8
|
||||
git commit -m "feat: Enable all palette items with singleton handling"
|
||||
|
||||
# Phase 9
|
||||
git commit -m "feat: Add solver type selection and configuration"
|
||||
```
|
||||
49
examples/README.md
Normal file
49
examples/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Atomizer Examples
|
||||
|
||||
This directory contains example configurations and scripts demonstrating Atomizer capabilities.
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `optimization_config_neural.json` | Neural surrogate-accelerated optimization |
|
||||
| `optimization_config_protocol10.json` | IMSO (Intelligent Multi-Stage Optimization) example |
|
||||
| `optimization_config_protocol12.json` | Custom extractor with Zernike analysis |
|
||||
| `optimization_config_zernike_mirror.json` | Telescope mirror WFE optimization |
|
||||
|
||||
## Scripts
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `llm_mode_simple_example.py` | Basic LLM-driven optimization setup |
|
||||
| `interactive_research_session.py` | Interactive research mode with visualization |
|
||||
|
||||
## Models
|
||||
|
||||
The `Models/` directory contains sample FEA models for testing:
|
||||
- Bracket geometries
|
||||
- Beam structures
|
||||
- Mirror assemblies
|
||||
|
||||
## Zernike Reference
|
||||
|
||||
The `Zernike_old_reference/` directory contains legacy Zernike extraction code for reference purposes.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Copy a configuration file to your study directory
|
||||
2. Modify paths and parameters for your model
|
||||
3. Run optimization with:
|
||||
|
||||
```bash
|
||||
cd studies/your_study
|
||||
python run_optimization.py
|
||||
```
|
||||
|
||||
Or use the Canvas Builder in the dashboard (http://localhost:3003).
|
||||
|
||||
## See Also
|
||||
|
||||
- [Study Creation Guide](../docs/protocols/operations/OP_01_CREATE_STUDY.md)
|
||||
- [Extractor Library](../docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md)
|
||||
- [Canvas Builder](../docs/guides/CANVAS.md)
|
||||
380
nx_journals/introspect_sim.py
Normal file
380
nx_journals/introspect_sim.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
NX Journal: SIM File Introspection Tool
|
||||
=========================================
|
||||
|
||||
This journal performs deep introspection of an NX .sim file and extracts:
|
||||
- Solutions (name, type, solver)
|
||||
- Boundary conditions (SPCs, loads, etc.)
|
||||
- Subcases
|
||||
- Linked FEM files
|
||||
- Solution properties
|
||||
|
||||
Usage:
|
||||
run_journal.exe introspect_sim.py <sim_file_path> [output_dir]
|
||||
|
||||
Output:
|
||||
_introspection_sim.json - JSON with all extracted data
|
||||
|
||||
Author: Atomizer
|
||||
Created: 2026-01-20
|
||||
Version: 1.0
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import NXOpen
|
||||
import NXOpen.CAE
|
||||
|
||||
|
||||
def get_solutions(simSimulation):
|
||||
"""Extract all solutions from the simulation."""
|
||||
solutions = []
|
||||
|
||||
try:
|
||||
# Iterate through all solutions in the simulation
|
||||
# Solutions are accessed via FindObject with pattern "Solution[name]"
|
||||
# But we can also iterate if the simulation has a solutions collection
|
||||
|
||||
# Try to get solution info by iterating through known solution names
|
||||
# Common patterns: "Solution 1", "Solution 2", etc.
|
||||
for i in range(1, 20): # Check up to 20 solutions
|
||||
sol_name = f"Solution {i}"
|
||||
try:
|
||||
sol = simSimulation.FindObject(f"Solution[{sol_name}]")
|
||||
if sol:
|
||||
sol_info = {"name": sol_name, "type": str(type(sol).__name__), "properties": {}}
|
||||
|
||||
# Try to get common properties
|
||||
try:
|
||||
sol_info["properties"]["solver_type"] = (
|
||||
str(sol.SolverType) if hasattr(sol, "SolverType") else None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
sol_info["properties"]["analysis_type"] = (
|
||||
str(sol.AnalysisType) if hasattr(sol, "AnalysisType") else None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
solutions.append(sol_info)
|
||||
except:
|
||||
# Solution not found, stop looking
|
||||
if i > 5: # Give a few tries in case there are gaps
|
||||
break
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
solutions.append({"error": str(e)})
|
||||
|
||||
return solutions
|
||||
|
||||
|
||||
def get_boundary_conditions(simSimulation, workPart):
|
||||
"""Extract boundary conditions from the simulation."""
|
||||
bcs = {"constraints": [], "loads": [], "total_count": 0}
|
||||
|
||||
try:
|
||||
# Try to access BC collections through the simulation object
|
||||
# BCs are typically stored in the simulation's children
|
||||
|
||||
# Look for constraint groups
|
||||
constraint_names = [
|
||||
"Constraint Group[1]",
|
||||
"Constraint Group[2]",
|
||||
"Constraint Group[3]",
|
||||
"SPC[1]",
|
||||
"SPC[2]",
|
||||
"SPC[3]",
|
||||
"Fixed Constraint[1]",
|
||||
"Fixed Constraint[2]",
|
||||
]
|
||||
|
||||
for name in constraint_names:
|
||||
try:
|
||||
obj = simSimulation.FindObject(name)
|
||||
if obj:
|
||||
bc_info = {
|
||||
"name": name,
|
||||
"type": str(type(obj).__name__),
|
||||
}
|
||||
bcs["constraints"].append(bc_info)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Look for load groups
|
||||
load_names = [
|
||||
"Load Group[1]",
|
||||
"Load Group[2]",
|
||||
"Load Group[3]",
|
||||
"Force[1]",
|
||||
"Force[2]",
|
||||
"Pressure[1]",
|
||||
"Pressure[2]",
|
||||
"Enforced Displacement[1]",
|
||||
"Enforced Displacement[2]",
|
||||
]
|
||||
|
||||
for name in load_names:
|
||||
try:
|
||||
obj = simSimulation.FindObject(name)
|
||||
if obj:
|
||||
load_info = {
|
||||
"name": name,
|
||||
"type": str(type(obj).__name__),
|
||||
}
|
||||
bcs["loads"].append(load_info)
|
||||
except:
|
||||
pass
|
||||
|
||||
bcs["total_count"] = len(bcs["constraints"]) + len(bcs["loads"])
|
||||
|
||||
except Exception as e:
|
||||
bcs["error"] = str(e)
|
||||
|
||||
return bcs
|
||||
|
||||
|
||||
def get_sim_part_info(workPart):
|
||||
"""Extract SIM part-level information."""
|
||||
info = {"name": None, "full_path": None, "type": None, "fem_parts": [], "component_count": 0}
|
||||
|
||||
try:
|
||||
info["name"] = workPart.Name
|
||||
info["full_path"] = workPart.FullPath if hasattr(workPart, "FullPath") else None
|
||||
info["type"] = str(type(workPart).__name__)
|
||||
|
||||
# Check for component assembly (assembly FEM)
|
||||
try:
|
||||
root = workPart.ComponentAssembly.RootComponent
|
||||
if root:
|
||||
info["is_assembly"] = True
|
||||
# Count components
|
||||
try:
|
||||
children = root.GetChildren()
|
||||
info["component_count"] = len(children) if children else 0
|
||||
|
||||
# Get component names
|
||||
components = []
|
||||
for child in children[:10]: # Limit to first 10
|
||||
try:
|
||||
comp_info = {
|
||||
"name": child.Name if hasattr(child, "Name") else str(child),
|
||||
"type": str(type(child).__name__),
|
||||
}
|
||||
components.append(comp_info)
|
||||
except:
|
||||
pass
|
||||
info["components"] = components
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
info["is_assembly"] = False
|
||||
|
||||
except Exception as e:
|
||||
info["error"] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def get_cae_session_info(theSession):
|
||||
"""Get CAE session information."""
|
||||
cae_info = {"active_sim_part": None, "active_fem_part": None, "solver_types": []}
|
||||
|
||||
try:
|
||||
# Get CAE session
|
||||
caeSession = theSession.GetExportedObject("NXOpen.CAE.CaeSession")
|
||||
if caeSession:
|
||||
cae_info["cae_session_exists"] = True
|
||||
except:
|
||||
cae_info["cae_session_exists"] = False
|
||||
|
||||
return cae_info
|
||||
|
||||
|
||||
def explore_simulation_tree(simSimulation, workPart):
|
||||
"""Explore the simulation tree structure."""
|
||||
tree_info = {"simulation_objects": [], "found_types": set()}
|
||||
|
||||
# Try to enumerate objects in the simulation
|
||||
# This is exploratory - we don't know the exact API
|
||||
|
||||
try:
|
||||
# Try common child object patterns
|
||||
patterns = [
|
||||
# Solutions
|
||||
"Solution[Solution 1]",
|
||||
"Solution[Solution 2]",
|
||||
"Solution[SOLUTION 1]",
|
||||
# Subcases
|
||||
"Subcase[Subcase 1]",
|
||||
"Subcase[Subcase - Static 1]",
|
||||
# Loads/BCs
|
||||
"LoadSet[LoadSet 1]",
|
||||
"ConstraintSet[ConstraintSet 1]",
|
||||
"BoundaryCondition[1]",
|
||||
# FEM reference
|
||||
"FemPart",
|
||||
"AssyFemPart",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
try:
|
||||
obj = simSimulation.FindObject(pattern)
|
||||
if obj:
|
||||
obj_info = {"pattern": pattern, "type": str(type(obj).__name__), "found": True}
|
||||
tree_info["simulation_objects"].append(obj_info)
|
||||
tree_info["found_types"].add(str(type(obj).__name__))
|
||||
except:
|
||||
pass
|
||||
|
||||
tree_info["found_types"] = list(tree_info["found_types"])
|
||||
|
||||
except Exception as e:
|
||||
tree_info["error"] = str(e)
|
||||
|
||||
return tree_info
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Main entry point for NX journal."""
|
||||
|
||||
if len(args) < 1:
|
||||
print("ERROR: No .sim file path provided")
|
||||
print("Usage: run_journal.exe introspect_sim.py <sim_file_path> [output_dir]")
|
||||
return False
|
||||
|
||||
sim_file_path = args[0]
|
||||
output_dir = args[1] if len(args) > 1 else os.path.dirname(sim_file_path)
|
||||
sim_filename = os.path.basename(sim_file_path)
|
||||
|
||||
print(f"[INTROSPECT-SIM] " + "=" * 60)
|
||||
print(f"[INTROSPECT-SIM] NX SIMULATION INTROSPECTION")
|
||||
print(f"[INTROSPECT-SIM] " + "=" * 60)
|
||||
print(f"[INTROSPECT-SIM] SIM File: {sim_filename}")
|
||||
print(f"[INTROSPECT-SIM] Output: {output_dir}")
|
||||
|
||||
results = {
|
||||
"sim_file": sim_filename,
|
||||
"sim_path": sim_file_path,
|
||||
"success": False,
|
||||
"error": None,
|
||||
"part_info": {},
|
||||
"solutions": [],
|
||||
"boundary_conditions": {},
|
||||
"tree_structure": {},
|
||||
"cae_info": {},
|
||||
}
|
||||
|
||||
try:
|
||||
theSession = NXOpen.Session.GetSession()
|
||||
|
||||
# Set load options
|
||||
working_dir = os.path.dirname(sim_file_path)
|
||||
theSession.Parts.LoadOptions.ComponentLoadMethod = (
|
||||
NXOpen.LoadOptions.LoadMethod.FromDirectory
|
||||
)
|
||||
theSession.Parts.LoadOptions.SetSearchDirectories([working_dir], [True])
|
||||
theSession.Parts.LoadOptions.ComponentsToLoad = NXOpen.LoadOptions.LoadComponents.All
|
||||
theSession.Parts.LoadOptions.PartLoadOption = NXOpen.LoadOptions.LoadOption.FullyLoad
|
||||
|
||||
# Open the SIM file
|
||||
print(f"[INTROSPECT-SIM] Opening SIM file...")
|
||||
basePart, partLoadStatus = theSession.Parts.OpenActiveDisplay(
|
||||
sim_file_path, NXOpen.DisplayPartOption.AllowAdditional
|
||||
)
|
||||
partLoadStatus.Dispose()
|
||||
|
||||
workPart = theSession.Parts.Work
|
||||
print(f"[INTROSPECT-SIM] Loaded: {workPart.Name}")
|
||||
|
||||
# Switch to SFEM application
|
||||
try:
|
||||
theSession.ApplicationSwitchImmediate("UG_APP_SFEM")
|
||||
print(f"[INTROSPECT-SIM] Switched to SFEM application")
|
||||
except Exception as e:
|
||||
print(f"[INTROSPECT-SIM] Note: Could not switch to SFEM: {e}")
|
||||
|
||||
# Get part info
|
||||
print(f"[INTROSPECT-SIM] Extracting part info...")
|
||||
results["part_info"] = get_sim_part_info(workPart)
|
||||
print(f"[INTROSPECT-SIM] Part: {results['part_info'].get('name')}")
|
||||
print(f"[INTROSPECT-SIM] Is Assembly: {results['part_info'].get('is_assembly', False)}")
|
||||
|
||||
# Get simulation object
|
||||
print(f"[INTROSPECT-SIM] Finding Simulation object...")
|
||||
try:
|
||||
simSimulation = workPart.FindObject("Simulation")
|
||||
print(f"[INTROSPECT-SIM] Found Simulation object: {type(simSimulation).__name__}")
|
||||
|
||||
# Get solutions
|
||||
print(f"[INTROSPECT-SIM] Extracting solutions...")
|
||||
results["solutions"] = get_solutions(simSimulation)
|
||||
print(f"[INTROSPECT-SIM] Found {len(results['solutions'])} solutions")
|
||||
|
||||
# Get boundary conditions
|
||||
print(f"[INTROSPECT-SIM] Extracting boundary conditions...")
|
||||
results["boundary_conditions"] = get_boundary_conditions(simSimulation, workPart)
|
||||
print(
|
||||
f"[INTROSPECT-SIM] Found {results['boundary_conditions'].get('total_count', 0)} BCs"
|
||||
)
|
||||
|
||||
# Explore tree structure
|
||||
print(f"[INTROSPECT-SIM] Exploring simulation tree...")
|
||||
results["tree_structure"] = explore_simulation_tree(simSimulation, workPart)
|
||||
print(
|
||||
f"[INTROSPECT-SIM] Found types: {results['tree_structure'].get('found_types', [])}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[INTROSPECT-SIM] WARNING: Could not find Simulation object: {e}")
|
||||
results["simulation_object_error"] = str(e)
|
||||
|
||||
# Get CAE session info
|
||||
print(f"[INTROSPECT-SIM] Getting CAE session info...")
|
||||
results["cae_info"] = get_cae_session_info(theSession)
|
||||
|
||||
# List all loaded parts
|
||||
print(f"[INTROSPECT-SIM] Listing loaded parts...")
|
||||
loaded_parts = []
|
||||
for part in theSession.Parts:
|
||||
try:
|
||||
loaded_parts.append(
|
||||
{
|
||||
"name": part.Name,
|
||||
"type": str(type(part).__name__),
|
||||
"leaf": part.Leaf if hasattr(part, "Leaf") else None,
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
results["loaded_parts"] = loaded_parts
|
||||
print(f"[INTROSPECT-SIM] {len(loaded_parts)} parts loaded")
|
||||
|
||||
results["success"] = True
|
||||
print(f"[INTROSPECT-SIM] ")
|
||||
print(f"[INTROSPECT-SIM] INTROSPECTION COMPLETE!")
|
||||
print(f"[INTROSPECT-SIM] " + "=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
results["error"] = str(e)
|
||||
results["success"] = False
|
||||
print(f"[INTROSPECT-SIM] FATAL ERROR: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Write results
|
||||
output_file = os.path.join(output_dir, "_introspection_sim.json")
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"[INTROSPECT-SIM] Results written to: {output_file}")
|
||||
|
||||
return results["success"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
"""
|
||||
Bracket Displacement Maximization Study
|
||||
========================================
|
||||
|
||||
Complete optimization workflow using Phase 3.3 Wizard:
|
||||
1. Setup wizard validates the complete pipeline
|
||||
2. Auto-detects element types from OP2
|
||||
3. Runs 20-trial optimization
|
||||
4. Generates comprehensive report
|
||||
|
||||
Objective: Maximize displacement
|
||||
Constraint: Safety factor >= 4.0
|
||||
Material: Aluminum 6061-T6 (Yield = 276 MPa)
|
||||
Design Variables: tip_thickness (15-25mm), support_angle (20-40deg)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directories to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from optimization_engine.config.setup_wizard import OptimizationSetupWizard
|
||||
from optimization_engine.future.llm_optimization_runner import LLMOptimizationRunner
|
||||
from optimization_engine.nx.solver import NXSolver
|
||||
from optimization_engine.nx.updater import NXParameterUpdater
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def print_section(title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
print_section("BRACKET DISPLACEMENT MAXIMIZATION STUDY")
|
||||
|
||||
print("Study Configuration:")
|
||||
print(" - Objective: Maximize displacement")
|
||||
print(" - Constraint: Safety factor >= 4.0")
|
||||
print(" - Material: Aluminum 6061-T6 (Yield = 276 MPa)")
|
||||
print(" - Design Variables:")
|
||||
print(" * tip_thickness: 15-25 mm")
|
||||
print(" * support_angle: 20-40 degrees")
|
||||
print(" - Optimization trials: 20")
|
||||
print()
|
||||
|
||||
# File paths
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
prt_file = base_dir / "tests" / "Bracket.prt"
|
||||
sim_file = base_dir / "tests" / "Bracket_sim1.sim"
|
||||
|
||||
if not prt_file.exists():
|
||||
print(f"ERROR: Part file not found: {prt_file}")
|
||||
sys.exit(1)
|
||||
|
||||
if not sim_file.exists():
|
||||
print(f"ERROR: Simulation file not found: {sim_file}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Part file: {prt_file}")
|
||||
print(f"Simulation file: {sim_file}")
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# PHASE 3.3: OPTIMIZATION SETUP WIZARD
|
||||
# =========================================================================
|
||||
|
||||
print_section("STEP 1: INITIALIZATION")
|
||||
|
||||
print("Initializing Optimization Setup Wizard...")
|
||||
wizard = OptimizationSetupWizard(prt_file, sim_file)
|
||||
print(" [OK] Wizard initialized")
|
||||
print()
|
||||
|
||||
print_section("STEP 2: MODEL INTROSPECTION")
|
||||
|
||||
print("Reading NX model expressions...")
|
||||
model_info = wizard.introspect_model()
|
||||
|
||||
print(f"Found {len(model_info.expressions)} expressions:")
|
||||
for name, info in model_info.expressions.items():
|
||||
print(f" - {name}: {info['value']} {info['units']}")
|
||||
print()
|
||||
|
||||
print_section("STEP 3: BASELINE SIMULATION")
|
||||
|
||||
print("Running baseline simulation to generate reference OP2...")
|
||||
print("(This validates that NX simulation works before optimization)")
|
||||
baseline_op2 = wizard.run_baseline_simulation()
|
||||
print(f" [OK] Baseline OP2: {baseline_op2.name}")
|
||||
print()
|
||||
|
||||
print_section("STEP 4: OP2 INTROSPECTION")
|
||||
|
||||
print("Analyzing OP2 file to auto-detect element types...")
|
||||
op2_info = wizard.introspect_op2()
|
||||
|
||||
print("OP2 Contents:")
|
||||
print(f" - Element types with stress: {', '.join(op2_info.element_types)}")
|
||||
print(f" - Available result types: {', '.join(op2_info.result_types)}")
|
||||
print(f" - Subcases: {op2_info.subcases}")
|
||||
print(f" - Nodes: {op2_info.node_count}")
|
||||
print(f" - Elements: {op2_info.element_count}")
|
||||
print()
|
||||
|
||||
print_section("STEP 5: WORKFLOW CONFIGURATION")
|
||||
|
||||
print("Building LLM workflow with auto-detected element types...")
|
||||
|
||||
# Use the FIRST detected element type (could be CHEXA, CPENTA, CTETRA, etc.)
|
||||
detected_element_type = op2_info.element_types[0].lower() if op2_info.element_types else 'ctetra'
|
||||
|
||||
print(f" Using detected element type: {detected_element_type.upper()}")
|
||||
print()
|
||||
|
||||
llm_workflow = {
|
||||
'engineering_features': [
|
||||
{
|
||||
'action': 'extract_displacement',
|
||||
'domain': 'result_extraction',
|
||||
'description': 'Extract displacement results from OP2 file',
|
||||
'params': {'result_type': 'displacement'}
|
||||
},
|
||||
{
|
||||
'action': 'extract_solid_stress',
|
||||
'domain': 'result_extraction',
|
||||
'description': f'Extract von Mises stress from {detected_element_type.upper()} elements',
|
||||
'params': {
|
||||
'result_type': 'stress',
|
||||
'element_type': detected_element_type # AUTO-DETECTED!
|
||||
}
|
||||
}
|
||||
],
|
||||
'inline_calculations': [
|
||||
{
|
||||
'action': 'calculate_safety_factor',
|
||||
'params': {
|
||||
'input': 'max_von_mises',
|
||||
'yield_strength': 276.0, # MPa for Aluminum 6061-T6
|
||||
'operation': 'divide'
|
||||
},
|
||||
'code_hint': 'safety_factor = 276.0 / max_von_mises'
|
||||
},
|
||||
{
|
||||
'action': 'negate_displacement',
|
||||
'params': {
|
||||
'input': 'max_displacement',
|
||||
'operation': 'negate'
|
||||
},
|
||||
'code_hint': 'neg_displacement = -max_displacement'
|
||||
}
|
||||
],
|
||||
'post_processing_hooks': [], # Using manual safety_factor_constraint hook
|
||||
'optimization': {
|
||||
'algorithm': 'TPE',
|
||||
'direction': 'minimize', # Minimize neg_displacement = maximize displacement
|
||||
'design_variables': [
|
||||
{
|
||||
'parameter': 'tip_thickness',
|
||||
'min': 15.0,
|
||||
'max': 25.0,
|
||||
'units': 'mm'
|
||||
},
|
||||
{
|
||||
'parameter': 'support_angle',
|
||||
'min': 20.0,
|
||||
'max': 40.0,
|
||||
'units': 'degrees'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
print_section("STEP 6: PIPELINE VALIDATION")
|
||||
|
||||
print("Validating complete pipeline with baseline OP2...")
|
||||
print("(Dry-run test of extractors, calculations, hooks, objective)")
|
||||
print()
|
||||
|
||||
validation_results = wizard.validate_pipeline(llm_workflow)
|
||||
|
||||
all_passed = all(r.success for r in validation_results)
|
||||
|
||||
print("Validation Results:")
|
||||
for result in validation_results:
|
||||
status = "[OK]" if result.success else "[FAIL]"
|
||||
print(f" {status} {result.component}: {result.message.split(':')[-1].strip()}")
|
||||
print()
|
||||
|
||||
if not all_passed:
|
||||
print("[FAILED] Pipeline validation failed!")
|
||||
print("Fix the issues above before running optimization.")
|
||||
sys.exit(1)
|
||||
|
||||
print("[SUCCESS] All pipeline components validated!")
|
||||
print()
|
||||
|
||||
print_section("STEP 7: OPTIMIZATION SETUP")
|
||||
|
||||
print("Creating model updater and simulation runner...")
|
||||
|
||||
# Model updater
|
||||
updater = NXParameterUpdater(prt_file_path=prt_file)
|
||||
def model_updater(design_vars: dict):
|
||||
updater.update_expressions(design_vars)
|
||||
updater.save()
|
||||
|
||||
# Simulation runner
|
||||
solver = NXSolver(nastran_version='2412', use_journal=True)
|
||||
def simulation_runner() -> Path:
|
||||
result = solver.run_simulation(sim_file)
|
||||
return result['op2_file']
|
||||
|
||||
print(" [OK] Model updater ready")
|
||||
print(" [OK] Simulation runner ready")
|
||||
print()
|
||||
|
||||
print("Initializing LLM optimization runner...")
|
||||
runner = LLMOptimizationRunner(
|
||||
llm_workflow=llm_workflow,
|
||||
model_updater=model_updater,
|
||||
simulation_runner=simulation_runner,
|
||||
study_name='bracket_displacement_maximizing'
|
||||
)
|
||||
|
||||
print(f" [OK] Output directory: {runner.output_dir}")
|
||||
print(f" [OK] Extractors generated: {len(runner.extractors)}")
|
||||
print(f" [OK] Inline calculations: {len(runner.inline_code)}")
|
||||
hook_summary = runner.hook_manager.get_summary()
|
||||
print(f" [OK] Hooks loaded: {hook_summary['enabled_hooks']}")
|
||||
print()
|
||||
|
||||
print_section("STEP 8: RUNNING OPTIMIZATION")
|
||||
|
||||
print("Starting 20-trial optimization...")
|
||||
print("(This will take several minutes)")
|
||||
print()
|
||||
|
||||
start_time = datetime.now()
|
||||
results = runner.run_optimization(n_trials=20)
|
||||
end_time = datetime.now()
|
||||
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
print()
|
||||
print_section("OPTIMIZATION COMPLETE!")
|
||||
|
||||
print(f"Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)")
|
||||
print()
|
||||
print("Best Design Found:")
|
||||
print(f" - tip_thickness: {results['best_params']['tip_thickness']:.3f} mm")
|
||||
print(f" - support_angle: {results['best_params']['support_angle']:.3f} degrees")
|
||||
print(f" - Objective value: {results['best_value']:.6f}")
|
||||
print()
|
||||
|
||||
# Show best trial details
|
||||
best_trial = results['history'][results['best_trial_number']]
|
||||
best_results = best_trial['results']
|
||||
best_calcs = best_trial['calculations']
|
||||
|
||||
print("Best Design Performance:")
|
||||
print(f" - Max displacement: {best_results.get('max_displacement', 0):.6f} mm")
|
||||
print(f" - Max stress: {best_results.get('max_von_mises', 0):.3f} MPa")
|
||||
print(f" - Safety factor: {best_calcs.get('safety_factor', 0):.3f}")
|
||||
print(f" - Constraint: {'SATISFIED' if best_calcs.get('safety_factor', 0) >= 4.0 else 'VIOLATED'}")
|
||||
print()
|
||||
|
||||
print(f"Results saved to: {runner.output_dir}")
|
||||
print()
|
||||
|
||||
print_section("STUDY COMPLETE!")
|
||||
print("Phase 3.3 Optimization Setup Wizard successfully guided the")
|
||||
print("complete optimization from setup through execution!")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user