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-mcp-*.json
|
||||||
.claude-prompt-*.md
|
.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)
|
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
|
||||||
docs/generated/
|
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]
|
## [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)
|
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
|
||||||
|
|
||||||
#### Added
|
#### 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
|
- If no study context: Offer to create one or list available studies
|
||||||
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
|
- 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
|
## 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 { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
|
||||||
|
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
@@ -37,23 +38,34 @@ import {
|
|||||||
useSelectedEdgeId,
|
useSelectedEdgeId,
|
||||||
} from '../../hooks/useSpecStore';
|
} from '../../hooks/useSpecStore';
|
||||||
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
|
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
|
||||||
|
import { usePanelStore } from '../../hooks/usePanelStore';
|
||||||
|
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
|
||||||
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||||
|
import { ProgressRing } from './visualization/ConvergenceSparkline';
|
||||||
import { CanvasNodeData } from '../../lib/canvas/schema';
|
import { CanvasNodeData } from '../../lib/canvas/schema';
|
||||||
|
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Drag-Drop Helpers
|
// Drag-Drop Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Addable node types via drag-drop */
|
import { SINGLETON_TYPES } from './palette/NodePalette';
|
||||||
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
|
|
||||||
|
/** 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];
|
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
|
||||||
|
|
||||||
function isAddableNodeType(type: string): type is AddableNodeType {
|
function isAddableNodeType(type: string): type is AddableNodeType {
|
||||||
return ADDABLE_NODE_TYPES.includes(type as 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 */
|
/** 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;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +74,22 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
switch (type) {
|
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':
|
case 'designVar':
|
||||||
return {
|
return {
|
||||||
name: `variable_${timestamp}`,
|
name: `variable_${timestamp}`,
|
||||||
@@ -125,6 +153,23 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
canvas_position: position,
|
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 wsStudyId = enableWebSocket ? storeStudyId : null;
|
||||||
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
|
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
|
// Load spec on mount if studyId provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
@@ -214,8 +414,58 @@ function SpecRendererInner({
|
|||||||
|
|
||||||
// Convert spec to ReactFlow nodes
|
// Convert spec to ReactFlow nodes
|
||||||
const nodes = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
return specToNodes(spec);
|
const baseNodes = specToNodes(spec);
|
||||||
}, [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
|
// Convert spec to ReactFlow edges with selection styling
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
@@ -392,6 +642,18 @@ function SpecRendererInner({
|
|||||||
return;
|
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
|
// Convert screen position to flow position
|
||||||
const position = reactFlowInstance.current.screenToFlowPosition({
|
const position = reactFlowInstance.current.screenToFlowPosition({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
@@ -402,8 +664,19 @@ function SpecRendererInner({
|
|||||||
const nodeData = getDefaultNodeData(type, position);
|
const nodeData = getDefaultNodeData(type, position);
|
||||||
const specType = mapNodeTypeToSpecType(type);
|
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 {
|
try {
|
||||||
const nodeId = await addNode(specType, nodeData);
|
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
|
||||||
// Select the newly created node
|
// Select the newly created node
|
||||||
selectNode(nodeId);
|
selectNode(nodeId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -411,7 +684,7 @@ function SpecRendererInner({
|
|||||||
setError(err instanceof Error ? err.message : 'Failed to add node');
|
setError(err instanceof Error ? err.message : 'Failed to add node');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editable, addNode, selectNode, setError]
|
[editable, addNode, selectNode, setError, localNodes]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
@@ -527,10 +800,113 @@ function SpecRendererInner({
|
|||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</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 */}
|
{/* 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">
|
<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>
|
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { ShieldAlert } from 'lucide-react';
|
import { ShieldAlert } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
|
<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 !== undefined
|
||||||
? `${data.name} ${data.operator} ${data.value}`
|
? `${data.name} ${data.operator} ${data.value}`
|
||||||
: 'Set constraint'}
|
: 'Set constraint'}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { SlidersHorizontal } from 'lucide-react';
|
import { SlidersHorizontal } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||||
|
<ResultBadge value={data.resultValue} unit={data.unit} />
|
||||||
{data.expressionName ? (
|
{data.expressionName ? (
|
||||||
<span className="font-mono">{data.expressionName}</span>
|
<span className="font-mono">{data.expressionName}</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { FlaskConical } from 'lucide-react';
|
import { FlaskConical } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
||||||
|
<ResultBadge value={data.resultValue} />
|
||||||
{data.extractorName || 'Select extractor'}
|
{data.extractorName || 'Select extractor'}
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,38 @@ import { memo } from 'react';
|
|||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { Target } from 'lucide-react';
|
import { Target } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
import { BaseNode } from './BaseNode';
|
||||||
|
import { ResultBadge } from './ResultBadge';
|
||||||
|
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
|
||||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||||
|
|
||||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||||
const { data } = props;
|
const { data } = props;
|
||||||
|
const hasHistory = data.history && data.history.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
<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>
|
</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 { memo } from 'react';
|
||||||
import { NodeProps } from 'reactflow';
|
import { NodeProps } from 'reactflow';
|
||||||
import { Cpu } from 'lucide-react';
|
import { Cpu, Terminal } from 'lucide-react';
|
||||||
import { BaseNode } from './BaseNode';
|
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>) {
|
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||||
const { data } = props;
|
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 (
|
return (
|
||||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
|
||||||
{data.solverType || 'Select solution'}
|
{displayText}
|
||||||
</BaseNode>
|
</BaseNode>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export interface NodePaletteProps {
|
|||||||
// Constants
|
// 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[] = [
|
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
{
|
{
|
||||||
type: 'model',
|
type: 'model',
|
||||||
@@ -61,15 +64,15 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
icon: Box,
|
icon: Box,
|
||||||
description: 'NX model file (.prt, .sim)',
|
description: 'NX model file (.prt, .sim)',
|
||||||
color: 'text-blue-400',
|
color: 'text-blue-400',
|
||||||
canAdd: false, // Synthetic - derived from spec
|
canAdd: true, // Singleton - only one allowed
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'solver',
|
type: 'solver',
|
||||||
label: 'Solver',
|
label: 'Solver',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
description: 'Nastran solution type',
|
description: 'Analysis solver config',
|
||||||
color: 'text-violet-400',
|
color: 'text-violet-400',
|
||||||
canAdd: false, // Synthetic - derived from model
|
canAdd: true, // Singleton - only one allowed
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'designVar',
|
type: 'designVar',
|
||||||
@@ -109,7 +112,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
icon: BrainCircuit,
|
icon: BrainCircuit,
|
||||||
description: 'Optimization method',
|
description: 'Optimization method',
|
||||||
color: 'text-indigo-400',
|
color: 'text-indigo-400',
|
||||||
canAdd: false, // Synthetic - derived from spec.optimization
|
canAdd: true, // Singleton - only one allowed
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'surrogate',
|
type: 'surrogate',
|
||||||
@@ -117,7 +120,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
|||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
description: 'Neural acceleration',
|
description: 'Neural acceleration',
|
||||||
color: 'text-pink-400',
|
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,
|
Link,
|
||||||
Box,
|
Box,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
GitBranch,
|
||||||
|
File,
|
||||||
|
Database,
|
||||||
|
CheckCircle2,
|
||||||
|
Layers,
|
||||||
|
Grid3x3,
|
||||||
|
Target,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
||||||
|
|
||||||
@@ -53,6 +61,23 @@ interface DependentFile {
|
|||||||
name: string;
|
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
|
// The API returns expressions in a nested structure
|
||||||
interface ExpressionsResult {
|
interface ExpressionsResult {
|
||||||
user: Expression[];
|
user: Expression[];
|
||||||
@@ -61,6 +86,45 @@ interface ExpressionsResult {
|
|||||||
user_count: number;
|
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 {
|
interface IntrospectionResult {
|
||||||
part_file?: string;
|
part_file?: string;
|
||||||
part_path?: string;
|
part_path?: string;
|
||||||
@@ -74,13 +138,42 @@ interface IntrospectionResult {
|
|||||||
dependent_files?: DependentFile[];
|
dependent_files?: DependentFile[];
|
||||||
extractors_available?: Extractor[];
|
extractors_available?: Extractor[];
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
// Additional fields from NX introspection
|
// Additional fields from NX introspection (PRT files)
|
||||||
mass_properties?: Record<string, unknown>;
|
mass_properties?: Record<string, unknown>;
|
||||||
materials?: Record<string, unknown>;
|
materials?: Record<string, unknown>;
|
||||||
bodies?: Record<string, unknown>;
|
bodies?: Record<string, unknown>;
|
||||||
attributes?: Array<{ title: string; value: string }>;
|
attributes?: Array<{ title: string; value: string }>;
|
||||||
units?: Record<string, unknown>;
|
units?: Record<string, unknown>;
|
||||||
linked_parts?: 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
|
// Baseline run result interface
|
||||||
@@ -100,23 +193,68 @@ interface BaselineRunResult {
|
|||||||
message: string;
|
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) {
|
export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
|
||||||
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
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('');
|
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
|
// Baseline run state
|
||||||
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
||||||
const [baselineResult, setBaselineResult] = useState<BaselineRunResult | null>(null);
|
const [baselineResult, setBaselineResult] = useState<BaselineRunResult | null>(null);
|
||||||
|
|
||||||
const { addNode, nodes } = useCanvasStore();
|
const { addNode, nodes } = useCanvasStore();
|
||||||
|
|
||||||
const runIntrospection = useCallback(async () => {
|
// Fetch available files when studyId changes
|
||||||
if (!filePath) return;
|
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);
|
setIsLoading(true);
|
||||||
setError(null);
|
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 we have a studyId, use the study-aware introspection endpoint
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
// Don't encode studyId - it may contain slashes for nested paths (e.g., M1_Mirror/study_name)
|
// Use specific file endpoint if a file is selected
|
||||||
res = await fetch(`/api/optimization/studies/${studyId}/nx/introspect`);
|
const endpoint = fileName
|
||||||
|
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||||
|
: `/api/optimization/studies/${studyId}/nx/introspect`;
|
||||||
|
res = await fetch(endpoint);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to direct path introspection
|
// Fallback to direct path introspection
|
||||||
res = await fetch('/api/nx/introspect', {
|
res = await fetch('/api/nx/introspect', {
|
||||||
@@ -153,9 +294,21 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
}
|
}
|
||||||
}, [filePath, studyId]);
|
}, [filePath, studyId]);
|
||||||
|
|
||||||
|
// Fetch files list on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runIntrospection();
|
fetchAvailableFiles();
|
||||||
}, [runIntrospection]);
|
}, [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
|
// Run baseline FEA simulation
|
||||||
const runBaseline = useCallback(async () => {
|
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 justify-between px-4 py-3 border-b border-dark-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Search size={16} className="text-primary-400" />
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={runIntrospection}
|
onClick={() => runIntrospection(selectedFile || undefined)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@@ -283,8 +439,84 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* File Selector + Search */}
|
||||||
<div className="px-4 py-2 border-b border-dark-700">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter expressions..."
|
placeholder="Filter expressions..."
|
||||||
@@ -627,6 +859,426 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
</div>
|
</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 */}
|
{/* Extractors Section - only show if available */}
|
||||||
{(result.extractors_available?.length ?? 0) > 0 && (
|
{(result.extractors_available?.length ?? 0) > 0 && (
|
||||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
useSelectedNodeId,
|
useSelectedNodeId,
|
||||||
useSelectedNode,
|
useSelectedNode,
|
||||||
} from '../../../hooks/useSpecStore';
|
} from '../../../hooks/useSpecStore';
|
||||||
|
import { usePanelStore } from '../../../hooks/usePanelStore';
|
||||||
import { FileBrowser } from './FileBrowser';
|
import { FileBrowser } from './FileBrowser';
|
||||||
import { IntrospectionPanel } from './IntrospectionPanel';
|
|
||||||
import {
|
import {
|
||||||
DesignVariable,
|
DesignVariable,
|
||||||
Extractor,
|
Extractor,
|
||||||
@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
|||||||
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
||||||
|
|
||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -249,16 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
|||||||
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Introspection Panel */}
|
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,7 +262,16 @@ interface SpecConfigProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ModelNodeConfig({ spec }: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -300,7 +299,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
|||||||
|
|
||||||
{spec.model.sim?.path && (
|
{spec.model.sim?.path && (
|
||||||
<button
|
<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
|
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
|
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
|
||||||
text-primary-400 text-sm font-medium transition-colors"
|
text-primary-400 text-sm font-medium transition-colors"
|
||||||
@@ -309,33 +308,113 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
|||||||
Introspect Model
|
Introspect Model
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showIntrospection && spec.model.sim?.path && (
|
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
|
||||||
<div className="fixed top-20 right-96 z-40">
|
|
||||||
<IntrospectionPanel
|
|
||||||
filePath={spec.model.sim.path}
|
|
||||||
studyId={useSpecStore.getState().studyId || undefined}
|
|
||||||
onClose={() => setShowIntrospection(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
<label className={labelClass}>Solution Type</label>
|
{isUpdating && (
|
||||||
<input
|
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
|
||||||
type="text"
|
)}
|
||||||
value={spec.model.sim?.solution_type || 'Not configured'}
|
|
||||||
readOnly
|
<div>
|
||||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
<label className={labelClass}>Solver Engine</label>
|
||||||
title="Solver type is determined by the model file."
|
<select
|
||||||
/>
|
value={engine}
|
||||||
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
|
onChange={(e) => handleEngineChange(e.target.value)}
|
||||||
</div>
|
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';
|
import { ValidationResult } from '../../../lib/canvas/validation';
|
||||||
|
|
||||||
interface ValidationPanelProps {
|
// ============================================================================
|
||||||
|
// Legacy Props Interface (for backward compatibility)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface LegacyValidationPanelProps {
|
||||||
validation: ValidationResult;
|
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 (
|
return (
|
||||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
|
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
|
||||||
{validation.errors.length > 0 && (
|
{validation.errors.length > 0 && (
|
||||||
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
</div>
|
</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;
|
label: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
|
resultValue?: number | string | null; // For Results Overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelNodeData extends BaseNodeData {
|
export interface ModelNodeData extends BaseNodeData {
|
||||||
@@ -24,9 +25,17 @@ export interface ModelNodeData extends BaseNodeData {
|
|||||||
fileType?: 'prt' | 'fem' | 'sim';
|
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 {
|
export interface SolverNodeData extends BaseNodeData {
|
||||||
type: 'solver';
|
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 {
|
export interface DesignVarNodeData extends BaseNodeData {
|
||||||
@@ -98,6 +107,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
|
|||||||
extractorRef?: string; // Reference to extractor ID
|
extractorRef?: string; // Reference to extractor ID
|
||||||
outputName?: string; // Which output from the extractor
|
outputName?: string; // Which output from the extractor
|
||||||
penaltyWeight?: number; // For hard constraints (penalty method)
|
penaltyWeight?: number; // For hard constraints (penalty method)
|
||||||
|
history?: number[]; // Recent values for sparkline visualization
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConstraintNodeData extends BaseNodeData {
|
export interface ConstraintNodeData extends BaseNodeData {
|
||||||
@@ -105,6 +115,7 @@ export interface ConstraintNodeData extends BaseNodeData {
|
|||||||
name?: string;
|
name?: string;
|
||||||
operator?: '<' | '<=' | '>' | '>=' | '==';
|
operator?: '<' | '<=' | '>' | '>=' | '==';
|
||||||
value?: number;
|
value?: number;
|
||||||
|
isFeasible?: boolean; // For Results Overlay
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlgorithmNodeData extends BaseNodeData {
|
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 { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
|
||||||
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
||||||
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
|
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 { useCanvasStore } from '../hooks/useCanvasStore';
|
||||||
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
|
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 { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
||||||
import { useStudy } from '../context/StudyContext';
|
import { useStudy } from '../context/StudyContext';
|
||||||
import { useChat } from '../hooks/useChat';
|
import { useChat } from '../hooks/useChat';
|
||||||
@@ -29,6 +33,23 @@ export function CanvasView() {
|
|||||||
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
|
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
|
||||||
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
|
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
|
||||||
const navigate = useNavigate();
|
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();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Spec mode is the default (AtomizerSpec v2.0)
|
// Spec mode is the default (AtomizerSpec v2.0)
|
||||||
@@ -421,7 +442,10 @@ export function CanvasView() {
|
|||||||
<main className="flex-1 overflow-hidden flex">
|
<main className="flex-1 overflow-hidden flex">
|
||||||
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
|
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
|
||||||
{useSpecMode && (
|
{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) */}
|
{/* Tab buttons (only show when expanded) */}
|
||||||
{!paletteCollapsed && (
|
{!paletteCollapsed && (
|
||||||
<div className="flex border-b border-dark-700">
|
<div className="flex border-b border-dark-700">
|
||||||
@@ -467,6 +491,16 @@ export function CanvasView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resize handle (only when not collapsed) */}
|
||||||
|
{!paletteCollapsed && (
|
||||||
|
<ResizeHandle
|
||||||
|
onMouseDown={leftPanel.startDrag}
|
||||||
|
onDoubleClick={leftPanel.resetWidth}
|
||||||
|
isDragging={leftPanel.isDragging}
|
||||||
|
position="right"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -492,14 +526,35 @@ export function CanvasView() {
|
|||||||
{/* Shows INSTEAD of chat when a node is selected */}
|
{/* Shows INSTEAD of chat when a node is selected */}
|
||||||
{selectedNodeId ? (
|
{selectedNodeId ? (
|
||||||
useSpecMode ? (
|
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">
|
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
|
||||||
<NodeConfigPanel nodeId={selectedNodeId} />
|
<NodeConfigPanel nodeId={selectedNodeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : showChat ? (
|
) : 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 */}
|
{/* Chat Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -556,6 +611,9 @@ export function CanvasView() {
|
|||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
||||||
|
{useSpecMode && <PanelContainer />}
|
||||||
|
|
||||||
{/* Notification Toast */}
|
{/* Notification Toast */}
|
||||||
{notification && (
|
{notification && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export interface SpecMeta {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Real-world engineering context */
|
/** Real-world engineering context */
|
||||||
engineering_context?: string;
|
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';
|
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 type SubcaseType = 'static' | 'modal' | 'thermal' | 'buckling';
|
||||||
|
|
||||||
export interface Subcase {
|
export interface Subcase {
|
||||||
@@ -75,10 +102,14 @@ export interface Subcase {
|
|||||||
export interface SimConfig {
|
export interface SimConfig {
|
||||||
/** Path to .sim file */
|
/** Path to .sim file */
|
||||||
path: string;
|
path: string;
|
||||||
/** Solver type */
|
/** Solver type (legacy, use engine instead) */
|
||||||
solver: SolverType;
|
solver: SolverType;
|
||||||
|
/** Solver engine software */
|
||||||
|
engine?: SolverEngine;
|
||||||
/** Solution type (e.g., SOL101) */
|
/** Solution type (e.g., SOL101) */
|
||||||
solution_type?: string;
|
solution_type?: NastranSolutionType | string;
|
||||||
|
/** Python script path (for python engine) */
|
||||||
|
script_path?: string;
|
||||||
/** Defined subcases */
|
/** Defined subcases */
|
||||||
subcases?: Subcase[];
|
subcases?: Subcase[];
|
||||||
}
|
}
|
||||||
@@ -89,11 +120,40 @@ export interface NxSettings {
|
|||||||
auto_start_nx?: boolean;
|
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 {
|
export interface ModelConfig {
|
||||||
nx_part?: NxPartConfig;
|
nx_part?: NxPartConfig;
|
||||||
|
prt?: NxPartConfig;
|
||||||
fem?: FemConfig;
|
fem?: FemConfig;
|
||||||
sim: SimConfig;
|
sim?: SimConfig;
|
||||||
nx_settings?: NxSettings;
|
nx_settings?: NxSettings;
|
||||||
|
introspection?: IntrospectionData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Atomizer Documentation Index
|
# Atomizer Documentation Index
|
||||||
|
|
||||||
**Last Updated**: 2026-01-20
|
**Last Updated**: 2026-01-24
|
||||||
**Project Version**: 1.0.0 (AtomizerSpec v2.0 - Full LLM Integration)
|
**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/historical/` - Legacy documents, old protocols
|
||||||
- `archive/marketing/` - Briefings, presentations
|
- `archive/marketing/` - Briefings, presentations
|
||||||
- `archive/session_summaries/` - Past development sessions
|
- `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
|
**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