Compare commits
19 Commits
cf8c57fdac
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f18dc377 | |||
| 2cb8dccc3a | |||
| c224b16ac3 | |||
| e1c59a51c1 | |||
| f725e75164 | |||
| e954b130f5 | |||
| 5b22439357 | |||
| 0c252e3a65 | |||
| 4749944a48 | |||
| 3229c31349 | |||
| 14354a2606 | |||
| abbc7b1b50 | |||
| 1cdcc17ffd | |||
| 5c419e2358 | |||
| 89694088a2 | |||
| 91cf9ca1fd | |||
| ced79b8d39 | |||
| 2f0f45de86 | |||
| 47f8b50112 |
@@ -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`
|
||||
@@ -62,7 +62,23 @@
|
||||
"Bash(xargs -I{} git ls-tree -r -l HEAD {})",
|
||||
"Bash(sort:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe introspect_model.py)",
|
||||
"Bash(xargs:*)"
|
||||
"Bash(xargs:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import requests; r = requests.post\\(''http://127.0.0.1:8001/api/claude/sessions'', json={''mode'': ''user''}\\); print\\(f''Status: {r.status_code}''\\); print\\(f''Response: {r.text}''\\)\")",
|
||||
"Bash(start \"Atomizer Backend\" cmd /k C:UsersantoiAtomizerrestart_backend.bat)",
|
||||
"Bash(start \"Test Backend\" cmd /c \"cd /d C:\\\\Users\\\\antoi\\\\Atomizer\\\\atomizer-dashboard\\\\backend && C:\\\\Users\\\\antoi\\\\anaconda3\\\\Scripts\\\\activate.bat atomizer && python -m uvicorn api.main:app --port 8002\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe C:UsersantoiAtomizertest_backend.py)",
|
||||
"Bash(start \"Backend 8002\" C:UsersantoiAtomizerstart_backend_8002.bat)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from api.main import app; print\\(''Import OK''\\)\")",
|
||||
"Bash(find:*)",
|
||||
"Bash(npx tailwindcss:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from pathlib import Path; p = Path\\(''C:/Users/antoi/Atomizer/studies''\\) / ''M1_Mirror/m1_mirror_cost_reduction_lateral''; print\\(''exists:'', p.exists\\(\\), ''path:'', p\\)\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\(''Study:'', d.get\\(''meta'',{}\\).get\\(''study_name'',''N/A''\\)\\); print\\(''Design Variables:''\\); [print\\(f'' - {dv[\"\"name\"\"]} \\({dv[\"\"expression_name\"\"]}\\)''\\) for dv in d.get\\(''design_variables'',[]\\)]\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -m py_compile:*)",
|
||||
"Skill(ralph-loop:ralph-loop)",
|
||||
"Skill(ralph-loop:ralph-loop:*)",
|
||||
"mcp__Claude_in_Chrome__computer",
|
||||
"mcp__Claude_in_Chrome__navigate"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -110,5 +110,17 @@ _dat_run*.dat
|
||||
.claude-mcp-*.json
|
||||
.claude-prompt-*.md
|
||||
|
||||
# Backend logs
|
||||
backend_stdout.log
|
||||
backend_stderr.log
|
||||
*.log.bak
|
||||
|
||||
# Linter/formatter caches
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
|
||||
docs/generated/
|
||||
|
||||
# Malformed filenames (Windows path used as filename)
|
||||
C:*
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2025-01-24
|
||||
|
||||
### Project Cleanup & Organization
|
||||
- Deleted 102+ orphaned MCP session temp files
|
||||
- Removed build artifacts (htmlcov, dist, __pycache__)
|
||||
- Archived superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
|
||||
- Moved debug/analysis scripts from tests/ to tools/analysis/
|
||||
- Updated .gitignore with missing patterns
|
||||
- Cleaned empty directories
|
||||
|
||||
## [0.4.0] - 2025-01-22
|
||||
|
||||
### Canvas UX Improvements (Phases 7-9)
|
||||
- **Resizable Panels**: Left sidebar (200-400px) and right panel (280-600px) with localStorage persistence
|
||||
- **All Palette Items Enabled**: All 8 node types now draggable (model, solver, designVar, extractor, objective, constraint, algorithm, surrogate)
|
||||
- **Solver Configuration**: Engine selection (NX Nastran, MSC Nastran, Python Script) with solution type dropdowns (SOL101-SOL200)
|
||||
|
||||
### AtomizerSpec v2.0
|
||||
- Unified JSON configuration schema for all studies
|
||||
- Added SolverEngine and NastranSolutionType types
|
||||
- Canvas position persistence for all nodes
|
||||
- Migration support from legacy optimization_config.json
|
||||
|
||||
## [0.3.0] - 2025-01-18
|
||||
|
||||
### Dashboard V3.1 - Canvas Builder
|
||||
- Visual workflow builder with 9 node types
|
||||
- Spec ↔ ReactFlow bidirectional converter
|
||||
- WebSocket real-time synchronization
|
||||
- Claude chat integration
|
||||
- Custom extractors with in-canvas code editor
|
||||
- Model introspection panel
|
||||
|
||||
### Learning Atomizer Core (LAC)
|
||||
- Persistent memory system for accumulated knowledge
|
||||
- Session insights recording (failures, workarounds, patterns)
|
||||
- Optimization outcome tracking
|
||||
|
||||
## [0.2.5] - 2025-01-16
|
||||
|
||||
### GNN Surrogate for Zernike Optimization
|
||||
- PolarMirrorGraph with fixed 3000-node polar grid
|
||||
- ZernikeGNN model with design-conditioned convolutions
|
||||
- Differentiable GPU-accelerated Zernike fitting
|
||||
- Training pipeline with multi-task loss
|
||||
|
||||
### DevLoop Automation
|
||||
- Closed-loop development system with AI agents
|
||||
- Gemini planning, Claude implementation
|
||||
- Playwright browser testing for dashboard UI
|
||||
|
||||
## [0.2.1] - 2025-01-07
|
||||
|
||||
### Optimization Engine v2.0 Restructure
|
||||
- Reorganized into modular subpackages (core/, nx/, study/, config/)
|
||||
- SpecManager for AtomizerSpec handling
|
||||
- Deprecation warnings for old import paths
|
||||
|
||||
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
|
||||
|
||||
#### Added
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
|
||||
- If no study context: Offer to create one or list available studies
|
||||
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
|
||||
|
||||
### Step 5: Use DevLoop for Multi-Step Development Tasks
|
||||
|
||||
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
|
||||
|
||||
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
|
||||
|
||||
```bash
|
||||
# Plan a task with Gemini
|
||||
python tools/devloop_cli.py plan "fix extractor exports"
|
||||
|
||||
# Implement with Claude
|
||||
python tools/devloop_cli.py implement
|
||||
|
||||
# Test filesystem/API
|
||||
python tools/devloop_cli.py test --study support_arm
|
||||
|
||||
# Test dashboard UI with Playwright
|
||||
python tools/devloop_cli.py browser --level full
|
||||
|
||||
# Analyze failures
|
||||
python tools/devloop_cli.py analyze
|
||||
|
||||
# Full autonomous cycle
|
||||
python tools/devloop_cli.py start "add new stress extractor"
|
||||
```
|
||||
|
||||
**When to use DevLoop:**
|
||||
- Fixing bugs that require multiple file changes
|
||||
- Adding new features or extractors
|
||||
- Debugging optimization failures
|
||||
- Testing dashboard UI changes
|
||||
- Any task that would take 3+ manual steps
|
||||
|
||||
**Browser test levels:**
|
||||
- `quick` - Smoke test (1 test)
|
||||
- `home` - Home page verification (2 tests)
|
||||
- `full` - All UI tests (5+ tests)
|
||||
- `study` - Canvas/dashboard for specific study
|
||||
|
||||
**DO NOT default to manual debugging** - use the automation we built!
|
||||
|
||||
**Full documentation**: `docs/guides/DEVLOOP.md`
|
||||
|
||||
---
|
||||
|
||||
## Quick Start - Protocol Operating System
|
||||
|
||||
1
CUsersantoiAtomizeropenapi_dump.json
Normal file
1
CUsersantoiAtomizeropenapi_dump.json
Normal file
File diff suppressed because one or more lines are too long
@@ -83,23 +83,49 @@ async def generate_extractor_code(request: ExtractorGenerationRequest):
|
||||
# Build focused system prompt for extractor generation
|
||||
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
|
||||
|
||||
The function MUST:
|
||||
1. Have signature: def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict
|
||||
2. Return a dict with extracted values (e.g., {"max_stress": 150.5, "mass": 2.3})
|
||||
3. Use pyNastran.op2.op2.OP2 for reading OP2 results
|
||||
4. Handle missing data gracefully with try/except blocks
|
||||
IMPORTANT: Choose the appropriate function signature based on what data is needed:
|
||||
|
||||
Available imports (already available, just use them):
|
||||
- from pyNastran.op2.op2 import OP2
|
||||
- import numpy as np
|
||||
- from pathlib import Path
|
||||
## Option 1: FEA Results (OP2) - Use for stresses, displacements, frequencies, forces
|
||||
```python
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
from pyNastran.op2.op2 import OP2
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
# Access: op2.displacements[subcase_id], op2.cquad4_stress[subcase_id], etc.
|
||||
return {"max_stress": value}
|
||||
```
|
||||
|
||||
Common patterns:
|
||||
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z components)
|
||||
## Option 2: Expression/Computed Values (no FEA needed) - Use for dimensions, volumes, derived values
|
||||
```python
|
||||
def extract(trial_dir: str, config: dict, context: dict) -> dict:
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Read mass properties (if available from model introspection)
|
||||
mass_file = Path(trial_dir) / "mass_properties.json"
|
||||
if mass_file.exists():
|
||||
with open(mass_file) as f:
|
||||
props = json.load(f)
|
||||
mass = props.get("mass_kg", 0)
|
||||
|
||||
# Or use config values directly (e.g., expression values)
|
||||
length_mm = config.get("length_expression", 100)
|
||||
|
||||
# context has results from other extractors
|
||||
other_value = context.get("other_extractor_output", 0)
|
||||
|
||||
return {"computed_value": length_mm * 2}
|
||||
```
|
||||
|
||||
Available imports: pyNastran.op2.op2.OP2, numpy, pathlib.Path, json
|
||||
|
||||
Common OP2 patterns:
|
||||
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z)
|
||||
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
|
||||
- Eigenvalues: op2.eigenvalues[subcase_id]
|
||||
- Mass: op2.grid_point_weight (if available)
|
||||
|
||||
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations outside the code block."""
|
||||
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations."""
|
||||
|
||||
# Build user prompt with context
|
||||
user_prompt = f"Generate a custom extractor that: {request.prompt}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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;
|
||||
@@ -10,7 +10,8 @@
|
||||
* P2.7-P2.10: SpecRenderer component with node/edge/selection handling
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useMemo, 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, {
|
||||
Background,
|
||||
Controls,
|
||||
@@ -22,6 +23,7 @@ import ReactFlow, {
|
||||
NodeChange,
|
||||
EdgeChange,
|
||||
Connection,
|
||||
applyNodeChanges,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
@@ -36,23 +38,34 @@ import {
|
||||
useSelectedEdgeId,
|
||||
} from '../../hooks/useSpecStore';
|
||||
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
|
||||
import { usePanelStore } from '../../hooks/usePanelStore';
|
||||
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
|
||||
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
import { ProgressRing } from './visualization/ConvergenceSparkline';
|
||||
import { CanvasNodeData } from '../../lib/canvas/schema';
|
||||
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
|
||||
|
||||
// ============================================================================
|
||||
// Drag-Drop Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Addable node types via drag-drop */
|
||||
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
|
||||
import { SINGLETON_TYPES } from './palette/NodePalette';
|
||||
|
||||
/** All node types that can be added via drag-drop */
|
||||
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
|
||||
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
|
||||
|
||||
function isAddableNodeType(type: string): type is AddableNodeType {
|
||||
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
|
||||
}
|
||||
|
||||
/** Check if a node type is a singleton (only one allowed) */
|
||||
function isSingletonType(type: string): boolean {
|
||||
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
|
||||
}
|
||||
|
||||
/** Maps canvas NodeType to spec API type */
|
||||
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' {
|
||||
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
|
||||
return type;
|
||||
}
|
||||
|
||||
@@ -61,6 +74,22 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
||||
const timestamp = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'model':
|
||||
return {
|
||||
name: 'Model',
|
||||
sim: {
|
||||
path: '',
|
||||
solver: 'nastran',
|
||||
},
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'solver':
|
||||
return {
|
||||
name: 'Solver',
|
||||
engine: 'nxnastran',
|
||||
solution_type: 'SOL101',
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'designVar':
|
||||
return {
|
||||
name: `variable_${timestamp}`,
|
||||
@@ -74,8 +103,28 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
||||
case 'extractor':
|
||||
return {
|
||||
name: `extractor_${timestamp}`,
|
||||
type: 'custom',
|
||||
type: 'custom_function', // Must be valid ExtractorType
|
||||
builtin: false,
|
||||
enabled: true,
|
||||
// Custom function extractors need a function definition
|
||||
function: {
|
||||
name: 'extract',
|
||||
source_code: `def extract(op2_path: str, config: dict = None) -> dict:
|
||||
"""
|
||||
Custom extractor function.
|
||||
|
||||
Args:
|
||||
op2_path: Path to the OP2 results file
|
||||
config: Optional configuration dict
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted values
|
||||
"""
|
||||
# TODO: Implement extraction logic
|
||||
return {'value': 0.0}
|
||||
`,
|
||||
},
|
||||
outputs: [{ name: 'value', metric: 'custom' }],
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'objective':
|
||||
@@ -83,20 +132,44 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
|
||||
name: `objective_${timestamp}`,
|
||||
direction: 'minimize',
|
||||
weight: 1.0,
|
||||
source_extractor_id: null,
|
||||
source_output: null,
|
||||
// Source is required - use placeholder that user must configure
|
||||
source: {
|
||||
extractor_id: 'ext_001', // Placeholder - user needs to configure
|
||||
output_name: 'value',
|
||||
},
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'constraint':
|
||||
return {
|
||||
name: `constraint_${timestamp}`,
|
||||
type: 'upper',
|
||||
limit: 1.0,
|
||||
source_extractor_id: null,
|
||||
source_output: null,
|
||||
type: 'hard', // Must be 'hard' or 'soft' (field is 'type' not 'constraint_type')
|
||||
operator: '<=',
|
||||
threshold: 1.0, // Field is 'threshold' not 'limit'
|
||||
// Source is required
|
||||
source: {
|
||||
extractor_id: 'ext_001', // Placeholder - user needs to configure
|
||||
output_name: 'value',
|
||||
},
|
||||
enabled: true,
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'algorithm':
|
||||
return {
|
||||
name: 'Algorithm',
|
||||
type: 'TPE',
|
||||
budget: {
|
||||
max_trials: 100,
|
||||
},
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'surrogate':
|
||||
return {
|
||||
name: 'Surrogate',
|
||||
enabled: false,
|
||||
model_type: 'MLP',
|
||||
min_trials: 20,
|
||||
canvas_position: position,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +246,161 @@ function SpecRendererInner({
|
||||
const wsStudyId = enableWebSocket ? storeStudyId : null;
|
||||
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
|
||||
|
||||
// Panel store for validation and error panels
|
||||
const { setValidationData, addError, openPanel } = usePanelStore();
|
||||
|
||||
// Optimization WebSocket stream for real-time updates
|
||||
const {
|
||||
status: optimizationStatus,
|
||||
progress: wsProgress,
|
||||
bestTrial: wsBestTrial,
|
||||
recentTrials,
|
||||
} = useOptimizationStream(studyId, {
|
||||
autoReportErrors: true,
|
||||
onTrialComplete: (trial) => {
|
||||
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
|
||||
},
|
||||
onNewBest: (best) => {
|
||||
console.log('[SpecRenderer] New best found:', best.value);
|
||||
setShowResults(true); // Auto-show results when new best found
|
||||
},
|
||||
});
|
||||
|
||||
// Optimization execution state
|
||||
const isRunning = optimizationStatus === 'running';
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
|
||||
|
||||
// Build trial history for sparklines (extract objective values from recent trials)
|
||||
const trialHistory = useMemo(() => {
|
||||
const history: Record<string, number[]> = {};
|
||||
for (const trial of recentTrials) {
|
||||
// Map objective values - assumes single objective for now
|
||||
if (trial.objective !== null) {
|
||||
const key = 'primary';
|
||||
if (!history[key]) history[key] = [];
|
||||
history[key].push(trial.objective);
|
||||
}
|
||||
// Could also extract individual params/results for multi-objective
|
||||
}
|
||||
// Reverse so oldest is first (for sparkline)
|
||||
for (const key of Object.keys(history)) {
|
||||
history[key].reverse();
|
||||
}
|
||||
return history;
|
||||
}, [recentTrials]);
|
||||
|
||||
// Build best trial data for node display
|
||||
const bestTrial = useMemo((): {
|
||||
trial_number: number;
|
||||
objective: number;
|
||||
design_variables: Record<string, number>;
|
||||
results: Record<string, number>;
|
||||
} | null => {
|
||||
if (!wsBestTrial) return null;
|
||||
return {
|
||||
trial_number: wsBestTrial.trial_number,
|
||||
objective: wsBestTrial.value,
|
||||
design_variables: wsBestTrial.params,
|
||||
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
|
||||
};
|
||||
}, [wsBestTrial]);
|
||||
|
||||
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
|
||||
// The hook handles: status updates, best trial updates, error reporting
|
||||
|
||||
// Validate the spec and show results in panel
|
||||
const handleValidate = useCallback(() => {
|
||||
if (!spec) return;
|
||||
|
||||
const result = validateSpec(spec);
|
||||
setValidationData(result);
|
||||
setValidationStatus(result.valid ? 'valid' : 'invalid');
|
||||
|
||||
// Auto-open validation panel if there are issues
|
||||
if (!result.valid || result.warnings.length > 0) {
|
||||
openPanel('validation');
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [spec, setValidationData, openPanel]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!studyId || !spec) return;
|
||||
|
||||
// Validate before running
|
||||
const validation = handleValidate();
|
||||
if (!validation || !validation.valid) {
|
||||
// Show validation panel with errors
|
||||
return;
|
||||
}
|
||||
|
||||
// Also do a quick sanity check
|
||||
const { canRun, reason } = canRunOptimization(spec);
|
||||
if (!canRun) {
|
||||
addError({
|
||||
type: 'config_error',
|
||||
message: reason || 'Cannot run optimization',
|
||||
recoverable: false,
|
||||
suggestions: ['Check the validation panel for details'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Failed to start');
|
||||
}
|
||||
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
|
||||
setValidationStatus('unchecked'); // Clear validation status when running
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
|
||||
setError(errorMessage);
|
||||
|
||||
// Also add to error panel for persistence
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorMessage,
|
||||
recoverable: true,
|
||||
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to stop');
|
||||
}
|
||||
// isRunning will update via WebSocket when optimization actually stops
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
|
||||
setError(errorMessage);
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorMessage,
|
||||
recoverable: false,
|
||||
suggestions: ['The optimization may still be running in the background'],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load spec on mount if studyId provided
|
||||
useEffect(() => {
|
||||
if (studyId) {
|
||||
@@ -186,8 +414,58 @@ function SpecRendererInner({
|
||||
|
||||
// Convert spec to ReactFlow nodes
|
||||
const nodes = useMemo(() => {
|
||||
return specToNodes(spec);
|
||||
}, [spec]);
|
||||
const baseNodes = specToNodes(spec);
|
||||
|
||||
// Always map nodes to include history for sparklines (even if not showing results)
|
||||
return baseNodes.map(node => {
|
||||
// Create a mutable copy with explicit any type for dynamic property assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newData: any = { ...node.data };
|
||||
|
||||
// Add history for sparklines on objective nodes
|
||||
if (node.type === 'objective') {
|
||||
newData.history = trialHistory['primary'] || [];
|
||||
}
|
||||
|
||||
// Map results to nodes when showing results
|
||||
if (showResults && bestTrial) {
|
||||
if (node.type === 'designVar' && newData.expressionName) {
|
||||
const val = bestTrial.design_variables?.[newData.expressionName];
|
||||
if (val !== undefined) newData.resultValue = val;
|
||||
} else if (node.type === 'objective') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results?.[outputName] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[outputName];
|
||||
}
|
||||
} else if (node.type === 'constraint') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results?.[outputName] !== undefined) {
|
||||
const val = bestTrial.results[outputName];
|
||||
newData.resultValue = val;
|
||||
|
||||
// Check feasibility
|
||||
const op = newData.operator;
|
||||
const threshold = newData.value;
|
||||
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
|
||||
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
|
||||
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
|
||||
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
|
||||
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
|
||||
}
|
||||
} else if (node.type === 'extractor') {
|
||||
const outputNames = newData.outputNames;
|
||||
if (outputNames && outputNames.length > 0 && bestTrial.results) {
|
||||
const firstOut = outputNames[0];
|
||||
if (bestTrial.results[firstOut] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[firstOut];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...node, data: newData };
|
||||
});
|
||||
}, [spec, showResults, bestTrial, trialHistory]);
|
||||
|
||||
// Convert spec to ReactFlow edges with selection styling
|
||||
const edges = useMemo(() => {
|
||||
@@ -208,12 +486,23 @@ function SpecRendererInner({
|
||||
nodesRef.current = nodes;
|
||||
}, [nodes]);
|
||||
|
||||
// Track local node state for smooth dragging
|
||||
const [localNodes, setLocalNodes] = useState(nodes);
|
||||
|
||||
// Sync local nodes with spec-derived nodes when spec changes
|
||||
useEffect(() => {
|
||||
setLocalNodes(nodes);
|
||||
}, [nodes]);
|
||||
|
||||
// Handle node position changes
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
if (!editable) return;
|
||||
|
||||
// Handle position changes
|
||||
// Apply changes to local state for smooth dragging
|
||||
setLocalNodes((nds) => applyNodeChanges(changes, nds));
|
||||
|
||||
// Handle position changes - save to spec when drag ends
|
||||
for (const change of changes) {
|
||||
if (change.type === 'position' && change.position && change.dragging === false) {
|
||||
// Dragging ended - update spec
|
||||
@@ -353,6 +642,18 @@ function SpecRendererInner({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a singleton type that already exists
|
||||
if (isSingletonType(type)) {
|
||||
const existingNode = localNodes.find(n => n.type === type);
|
||||
if (existingNode) {
|
||||
// Select the existing node instead of creating a duplicate
|
||||
selectNode(existingNode.id);
|
||||
// Show a toast notification would be nice here
|
||||
console.log(`${type} already exists - selected existing node`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert screen position to flow position
|
||||
const position = reactFlowInstance.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
@@ -363,8 +664,19 @@ function SpecRendererInner({
|
||||
const nodeData = getDefaultNodeData(type, position);
|
||||
const specType = mapNodeTypeToSpecType(type);
|
||||
|
||||
// For structural types (model, solver, algorithm, surrogate), these are
|
||||
// part of the spec structure rather than array items. Handle differently.
|
||||
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
if (structuralTypes.includes(type)) {
|
||||
// These nodes are derived from spec structure - they shouldn't be "added"
|
||||
// They already exist if the spec has that section configured
|
||||
console.log(`${type} is a structural node - configure via spec directly`);
|
||||
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodeId = await addNode(specType, nodeData);
|
||||
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
|
||||
// Select the newly created node
|
||||
selectNode(nodeId);
|
||||
} catch (err) {
|
||||
@@ -372,7 +684,7 @@ function SpecRendererInner({
|
||||
setError(err instanceof Error ? err.message : 'Failed to add node');
|
||||
}
|
||||
},
|
||||
[editable, addNode, selectNode, setError]
|
||||
[editable, addNode, selectNode, setError, localNodes]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
@@ -458,7 +770,7 @@ function SpecRendererInner({
|
||||
)}
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={localNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
@@ -488,10 +800,113 @@ function SpecRendererInner({
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
|
||||
{/* Results toggle */}
|
||||
{bestTrial && (
|
||||
<button
|
||||
onClick={() => setShowResults(!showResults)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
|
||||
showResults
|
||||
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
|
||||
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
|
||||
}`}
|
||||
title={showResults ? "Hide Results" : "Show Best Trial Results"}
|
||||
>
|
||||
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||
<span className="text-sm font-medium">Results</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Validate button - shows validation status */}
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
|
||||
validationStatus === 'valid'
|
||||
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
|
||||
: validationStatus === 'invalid'
|
||||
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
|
||||
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
|
||||
}`}
|
||||
title="Validate spec before running"
|
||||
>
|
||||
{validationStatus === 'valid' ? (
|
||||
<CheckCircle size={16} />
|
||||
) : validationStatus === 'invalid' ? (
|
||||
<AlertCircle size={16} />
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
<span className="text-sm font-medium">Validate</span>
|
||||
</button>
|
||||
|
||||
{/* Run/Stop button */}
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isStarting || validationStatus === 'invalid'}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
|
||||
validationStatus === 'invalid'
|
||||
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
|
||||
>
|
||||
{isStarting ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" />
|
||||
)}
|
||||
Run
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Study name badge */}
|
||||
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
|
||||
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator when running */}
|
||||
{isRunning && wsProgress && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
|
||||
<ProgressRing
|
||||
progress={wsProgress.percentage}
|
||||
size={36}
|
||||
strokeWidth={3}
|
||||
color="#10b981"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Trial {wsProgress.current} / {wsProgress.total}
|
||||
</span>
|
||||
<span className="text-xs text-dark-400">
|
||||
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
|
||||
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
|
||||
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
|
||||
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
|
||||
</span>
|
||||
</div>
|
||||
{wsBestTrial && (
|
||||
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
|
||||
<span className="text-xs text-dark-400">Best</span>
|
||||
<span className="text-sm font-medium text-emerald-400">
|
||||
{typeof wsBestTrial.value === 'number'
|
||||
? wsBestTrial.value.toFixed(4)
|
||||
: wsBestTrial.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
|
||||
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
|
||||
{data.name && data.operator && data.value !== undefined
|
||||
? `${data.name} ${data.operator} ${data.value}`
|
||||
: 'Set constraint'}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||
<ResultBadge value={data.resultValue} unit={data.unit} />
|
||||
{data.expressionName ? (
|
||||
<span className="font-mono">{data.expressionName}</span>
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
||||
<ResultBadge value={data.resultValue} />
|
||||
{data.extractorName || 'Select extractor'}
|
||||
</BaseNode>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,38 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Target } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
|
||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||
const { data } = props;
|
||||
const hasHistory = data.history && data.history.length > 1;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
</span>
|
||||
<ResultBadge value={data.resultValue} label="Best" />
|
||||
</div>
|
||||
|
||||
{/* Convergence sparkline */}
|
||||
{hasHistory && (
|
||||
<div className="mt-1 -mb-1">
|
||||
<ConvergenceSparkline
|
||||
values={data.history!}
|
||||
width={120}
|
||||
height={20}
|
||||
direction={data.direction || 'minimize'}
|
||||
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
|
||||
showBest={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ResultBadgeProps {
|
||||
value: number | string | null | undefined;
|
||||
unit?: string;
|
||||
isFeasible?: boolean; // For constraints
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const displayValue = typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
: value;
|
||||
|
||||
// Determine color based on feasibility (if provided)
|
||||
let bgColor = 'bg-primary-500/20';
|
||||
let textColor = 'text-primary-300';
|
||||
let borderColor = 'border-primary-500/30';
|
||||
|
||||
if (isFeasible === true) {
|
||||
bgColor = 'bg-emerald-500/20';
|
||||
textColor = 'text-emerald-300';
|
||||
borderColor = 'border-emerald-500/30';
|
||||
} else if (isFeasible === false) {
|
||||
bgColor = 'bg-red-500/20';
|
||||
textColor = 'text-red-300';
|
||||
borderColor = 'border-red-500/30';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
|
||||
{label && <span className="opacity-70 mr-1">{label}:</span>}
|
||||
<span className="font-bold">{displayValue}</span>
|
||||
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,44 @@
|
||||
import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { Cpu, Terminal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { SolverNodeData } from '../../../lib/canvas/schema';
|
||||
import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
|
||||
|
||||
// Human-readable engine names
|
||||
const ENGINE_LABELS: Record<SolverEngine, string> = {
|
||||
nxnastran: 'NX Nastran',
|
||||
mscnastran: 'MSC Nastran',
|
||||
python: 'Python Script',
|
||||
abaqus: 'Abaqus',
|
||||
ansys: 'ANSYS',
|
||||
};
|
||||
|
||||
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
// Build display string: "Engine - SolutionType" or just one
|
||||
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
|
||||
const solverTypeLabel = data.solverType || null;
|
||||
|
||||
let displayText: string;
|
||||
if (engineLabel && solverTypeLabel) {
|
||||
displayText = `${engineLabel} (${solverTypeLabel})`;
|
||||
} else if (engineLabel) {
|
||||
displayText = engineLabel;
|
||||
} else if (solverTypeLabel) {
|
||||
displayText = solverTypeLabel;
|
||||
} else {
|
||||
displayText = 'Configure solver';
|
||||
}
|
||||
|
||||
// Use Terminal icon for Python, Cpu for others
|
||||
const icon = data.engine === 'python'
|
||||
? <Terminal size={16} />
|
||||
: <Cpu size={16} />;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
||||
{data.solverType || 'Select solution'}
|
||||
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
|
||||
{displayText}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface NodePaletteProps {
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Singleton node types - only one of each allowed on canvas */
|
||||
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
@@ -61,15 +64,15 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: Box,
|
||||
description: 'NX model file (.prt, .sim)',
|
||||
color: 'text-blue-400',
|
||||
canAdd: false, // Synthetic - derived from spec
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
icon: Cpu,
|
||||
description: 'Nastran solution type',
|
||||
description: 'Analysis solver config',
|
||||
color: 'text-violet-400',
|
||||
canAdd: false, // Synthetic - derived from model
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'designVar',
|
||||
@@ -109,7 +112,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: BrainCircuit,
|
||||
description: 'Optimization method',
|
||||
color: 'text-indigo-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'surrogate',
|
||||
@@ -117,7 +120,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: Rocket,
|
||||
description: 'Neural acceleration',
|
||||
color: 'text-pink-400',
|
||||
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* ErrorPanel - Displays optimization errors with recovery options
|
||||
*
|
||||
* Shows errors that occurred during optimization with:
|
||||
* - Error classification (NX crash, solver failure, etc.)
|
||||
* - Recovery suggestions
|
||||
* - Ability to dismiss individual errors
|
||||
* - Support for multiple simultaneous errors
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Trash2,
|
||||
Bug,
|
||||
Cpu,
|
||||
FileWarning,
|
||||
Settings,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ErrorPanelProps {
|
||||
onClose: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) {
|
||||
const panel = useErrorPanel();
|
||||
const { minimizePanel, dismissError, clearErrors } = usePanelStore();
|
||||
|
||||
const sortedErrors = useMemo(() => {
|
||||
return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}, [panel.errors]);
|
||||
|
||||
if (!panel.open || panel.errors.length === 0) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('error')}
|
||||
>
|
||||
<AlertOctagon size={16} className="text-red-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertOctagon size={18} className="text-red-400" />
|
||||
<span className="font-medium text-white">
|
||||
Optimization Errors ({panel.errors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{panel.errors.length > 1 && (
|
||||
<button
|
||||
onClick={clearErrors}
|
||||
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Clear all errors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => minimizePanel('error')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{sortedErrors.map((error) => (
|
||||
<ErrorItem
|
||||
key={error.timestamp}
|
||||
error={error}
|
||||
onDismiss={() => dismissError(error.timestamp)}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ErrorItemProps {
|
||||
error: OptimizationError;
|
||||
onDismiss: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) {
|
||||
const icon = getErrorIcon(error.type);
|
||||
const typeLabel = getErrorTypeLabel(error.type);
|
||||
const timeAgo = getTimeAgo(error.timestamp);
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
|
||||
{/* Error header */}
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{error.trial !== undefined && (
|
||||
<span className="text-xs text-dark-500">
|
||||
Trial #{error.trial}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-dark-600 ml-auto">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white">{error.message}</p>
|
||||
{error.details && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
|
||||
{error.details}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{error.suggestions.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
|
||||
<ul className="text-xs text-dark-300 space-y-1">
|
||||
{error.suggestions.map((suggestion, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1.5">
|
||||
<span className="text-dark-500">-</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{error.recoverable && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(error.trial)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
|
||||
text-white text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Retry{error.trial !== undefined ? ' Trial' : ''}
|
||||
</button>
|
||||
)}
|
||||
{onSkipTrial && error.trial !== undefined && (
|
||||
<button
|
||||
onClick={() => onSkipTrial(error.trial!)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
|
||||
text-dark-200 text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
Skip Trial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getErrorIcon(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return <Cpu size={16} className="text-red-400" />;
|
||||
case 'solver_fail':
|
||||
return <AlertTriangle size={16} className="text-amber-400" />;
|
||||
case 'extractor_error':
|
||||
return <FileWarning size={16} className="text-orange-400" />;
|
||||
case 'config_error':
|
||||
return <Settings size={16} className="text-blue-400" />;
|
||||
case 'system_error':
|
||||
return <Server size={16} className="text-purple-400" />;
|
||||
default:
|
||||
return <Bug size={16} className="text-red-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorTypeLabel(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return 'NX Crash';
|
||||
case 'solver_fail':
|
||||
return 'Solver Failure';
|
||||
case 'extractor_error':
|
||||
return 'Extractor Error';
|
||||
case 'config_error':
|
||||
return 'Configuration Error';
|
||||
case 'system_error':
|
||||
return 'System Error';
|
||||
default:
|
||||
return 'Unknown Error';
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default ErrorPanel;
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* FloatingIntrospectionPanel - Persistent introspection panel using store
|
||||
*
|
||||
* This is a wrapper around the existing IntrospectionPanel that:
|
||||
* 1. Gets its state from usePanelStore instead of local state
|
||||
* 2. Persists data when the panel is closed and reopened
|
||||
* 3. Can be opened from anywhere without losing state
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
SlidersHorizontal,
|
||||
Scale,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useIntrospectionPanel,
|
||||
usePanelStore,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
|
||||
interface FloatingIntrospectionPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Reuse types from original IntrospectionPanel
|
||||
interface Expression {
|
||||
name: string;
|
||||
value: number;
|
||||
rhs?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
units?: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ExpressionsResult {
|
||||
user: Expression[];
|
||||
internal: Expression[];
|
||||
total_count: number;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
solver_type?: string;
|
||||
expressions?: ExpressionsResult;
|
||||
// Allow other properties from the API response
|
||||
file_deps?: unknown[];
|
||||
fea_results?: unknown[];
|
||||
fem_mesh?: unknown;
|
||||
sim_solutions?: unknown[];
|
||||
sim_bcs?: unknown[];
|
||||
mass_properties?: {
|
||||
total_mass?: number;
|
||||
center_of_gravity?: { x: number; y: number; z: number };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelFileInfo {
|
||||
name: string;
|
||||
stem: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
size_kb: number;
|
||||
has_cache: boolean;
|
||||
}
|
||||
|
||||
interface ModelFilesResponse {
|
||||
files: {
|
||||
sim: ModelFileInfo[];
|
||||
afm: ModelFileInfo[];
|
||||
fem: ModelFileInfo[];
|
||||
idealized: ModelFileInfo[];
|
||||
prt: ModelFileInfo[];
|
||||
};
|
||||
all_files: ModelFileInfo[];
|
||||
}
|
||||
|
||||
export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) {
|
||||
const panel = useIntrospectionPanel();
|
||||
const {
|
||||
minimizePanel,
|
||||
updateIntrospectionResult,
|
||||
setIntrospectionLoading,
|
||||
setIntrospectionError,
|
||||
setIntrospectionFile,
|
||||
} = usePanelStore();
|
||||
const { addNode } = useSpecStore();
|
||||
|
||||
// Local UI state
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
const data = panel.data;
|
||||
const result = data?.result as IntrospectionResult | undefined;
|
||||
const isLoading = data?.isLoading || false;
|
||||
const error = data?.error as string | null;
|
||||
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!data?.studyId) return;
|
||||
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`);
|
||||
if (res.ok) {
|
||||
const filesData = await res.json();
|
||||
setModelFiles(filesData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch model files:', e);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [data?.studyId]);
|
||||
|
||||
// Run introspection
|
||||
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||
if (!data?.filePath && !data?.studyId) return;
|
||||
|
||||
setIntrospectionLoading(true);
|
||||
setIntrospectionError(null);
|
||||
|
||||
try {
|
||||
let res;
|
||||
|
||||
if (data?.studyId) {
|
||||
const endpoint = fileName
|
||||
? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||
: `/api/optimization/studies/${data.studyId}/nx/introspect`;
|
||||
res = await fetch(endpoint);
|
||||
} else {
|
||||
res = await fetch('/api/nx/introspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: data?.filePath }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Introspection failed');
|
||||
}
|
||||
|
||||
const responseData = await res.json();
|
||||
updateIntrospectionResult(responseData.introspection || responseData);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
|
||||
setIntrospectionError(msg);
|
||||
console.error('Introspection error:', e);
|
||||
}
|
||||
}, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]);
|
||||
|
||||
// Fetch files list on mount
|
||||
useEffect(() => {
|
||||
fetchAvailableFiles();
|
||||
}, [fetchAvailableFiles]);
|
||||
|
||||
// Run introspection when panel opens or selected file changes
|
||||
useEffect(() => {
|
||||
if (panel.open && data && !result && !isLoading) {
|
||||
runIntrospection(data.selectedFile);
|
||||
}
|
||||
}, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFile = e.target.value;
|
||||
setIntrospectionFile(newFile);
|
||||
runIntrospection(newFile);
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle both array format (old) and object format (new API)
|
||||
const allExpressions: Expression[] = useMemo(() => {
|
||||
if (!result?.expressions) return [];
|
||||
|
||||
if (Array.isArray(result.expressions)) {
|
||||
return result.expressions as Expression[];
|
||||
}
|
||||
|
||||
const exprObj = result.expressions as ExpressionsResult;
|
||||
return [...(exprObj.user || []), ...(exprObj.internal || [])];
|
||||
}, [result?.expressions]);
|
||||
|
||||
const filteredExpressions = allExpressions.filter((e) =>
|
||||
e.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const addExpressionAsDesignVar = (expr: Expression) => {
|
||||
const minValue = expr.min ?? expr.value * 0.5;
|
||||
const maxValue = expr.max ?? expr.value * 1.5;
|
||||
|
||||
addNode('designVar', {
|
||||
name: expr.name,
|
||||
expression_name: expr.name,
|
||||
type: 'continuous',
|
||||
bounds: { min: minValue, max: maxValue },
|
||||
baseline: expr.value,
|
||||
units: expr.unit || expr.units,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
>
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="font-medium text-white text-sm">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => runIntrospection(data?.selectedFile)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Selector + Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
|
||||
<select
|
||||
value={data?.selectedFile || ''}
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isLoadingFiles}
|
||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Default (Assembly)</option>
|
||||
|
||||
{modelFiles.files.sim.length > 0 && (
|
||||
<optgroup label="Simulation (.sim)">
|
||||
{modelFiles.files.sim.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.afm.length > 0 && (
|
||||
<optgroup label="Assembly FEM (.afm)">
|
||||
{modelFiles.files.afm.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.fem.length > 0 && (
|
||||
<optgroup label="FEM (.fem)">
|
||||
{modelFiles.files.fem.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.prt.length > 0 && (
|
||||
<optgroup label="Geometry (.prt)">
|
||||
{modelFiles.files.prt.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.idealized.length > 0 && (
|
||||
<optgroup label="Idealized (_i.prt)">
|
||||
{modelFiles.files.idealized.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{isLoadingFiles && (
|
||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter expressions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
||||
Analyzing model...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-red-400 text-sm">{error}</div>
|
||||
) : result ? (
|
||||
<div className="p-2 space-y-2">
|
||||
{/* Solver Type */}
|
||||
{result.solver_type && (
|
||||
<div className="p-2 bg-dark-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu size={14} className="text-violet-400" />
|
||||
<span className="text-dark-300">Solver:</span>
|
||||
<span className="text-white font-medium">{result.solver_type as string}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expressions Section */}
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('expressions')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={14} className="text-emerald-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
Expressions ({filteredExpressions.length})
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('expressions') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('expressions') && (
|
||||
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{filteredExpressions.length === 0 ? (
|
||||
<p className="text-xs text-dark-500 text-center py-2">
|
||||
No expressions found
|
||||
</p>
|
||||
) : (
|
||||
filteredExpressions.map((expr) => (
|
||||
<div
|
||||
key={expr.name}
|
||||
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{expr.name}</p>
|
||||
<p className="text-xs text-dark-500">
|
||||
{expr.value} {expr.units || expr.unit || ''}
|
||||
{expr.source === 'inferred' && (
|
||||
<span className="ml-1 text-amber-500">(inferred)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addExpressionAsDesignVar(expr)}
|
||||
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
|
||||
opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Add as Design Variable"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mass Properties Section */}
|
||||
{result.mass_properties && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('mass')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale size={14} className="text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Mass Properties</span>
|
||||
</div>
|
||||
{expandedSections.has('mass') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('mass') && (
|
||||
<div className="p-2 space-y-1">
|
||||
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
|
||||
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
|
||||
<span className="text-dark-400">Mass</span>
|
||||
<span className="text-white font-mono">
|
||||
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-dark-500 text-sm">
|
||||
Click refresh to analyze the model
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingIntrospectionPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@ import {
|
||||
useSelectedNodeId,
|
||||
useSelectedNode,
|
||||
} from '../../../hooks/useSpecStore';
|
||||
import { usePanelStore } from '../../../hooks/usePanelStore';
|
||||
import { FileBrowser } from './FileBrowser';
|
||||
import { IntrospectionPanel } from './IntrospectionPanel';
|
||||
import {
|
||||
DesignVariable,
|
||||
Extractor,
|
||||
@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
||||
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -249,15 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
||||
/>
|
||||
|
||||
{/* Introspection Panel */}
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -271,7 +262,16 @@ interface SpecConfigProps {
|
||||
}
|
||||
|
||||
function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const { setIntrospectionData, openPanel } = usePanelStore();
|
||||
|
||||
const handleOpenIntrospection = () => {
|
||||
// Set up introspection data and open the panel
|
||||
setIntrospectionData({
|
||||
filePath: spec.model.sim?.path || '',
|
||||
studyId: useSpecStore.getState().studyId || undefined,
|
||||
});
|
||||
openPanel('introspection');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -299,7 +299,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
|
||||
{spec.model.sim?.path && (
|
||||
<button
|
||||
onClick={() => setShowIntrospection(true)}
|
||||
onClick={handleOpenIntrospection}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
|
||||
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
|
||||
text-primary-400 text-sm font-medium transition-colors"
|
||||
@@ -308,32 +308,113 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
Introspect Model
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showIntrospection && spec.model.sim?.path && (
|
||||
<div className="fixed top-20 right-96 z-40">
|
||||
<IntrospectionPanel
|
||||
filePath={spec.model.sim.path}
|
||||
onClose={() => setShowIntrospection(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
||||
const { patchSpec } = useSpecStore();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const engine = spec.model.sim?.engine || 'nxnastran';
|
||||
const solutionType = spec.model.sim?.solution_type || 'SOL101';
|
||||
const scriptPath = spec.model.sim?.script_path || '';
|
||||
const isPython = engine === 'python';
|
||||
|
||||
const handleEngineChange = async (newEngine: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.engine', newEngine);
|
||||
} catch (err) {
|
||||
console.error('Failed to update engine:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = async (newType: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.solution_type', newType);
|
||||
} catch (err) {
|
||||
console.error('Failed to update solution type:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScriptPathChange = async (newPath: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await patchSpec('model.sim.script_path', newPath);
|
||||
} catch (err) {
|
||||
console.error('Failed to update script path:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spec.model.sim?.solution_type || 'Not configured'}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
title="Solver type is determined by the model file."
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
|
||||
</div>
|
||||
<>
|
||||
{isUpdating && (
|
||||
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Solver Engine</label>
|
||||
<select
|
||||
value={engine}
|
||||
onChange={(e) => handleEngineChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="nxnastran">NX Nastran (built-in)</option>
|
||||
<option value="mscnastran">MSC Nastran (external)</option>
|
||||
<option value="python">Python Script</option>
|
||||
<option value="abaqus" disabled>Abaqus (coming soon)</option>
|
||||
<option value="ansys" disabled>ANSYS (coming soon)</option>
|
||||
</select>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isPython && (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<select
|
||||
value={solutionType}
|
||||
onChange={(e) => handleSolutionTypeChange(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="SOL101">SOL101 - Linear Statics</option>
|
||||
<option value="SOL103">SOL103 - Normal Modes</option>
|
||||
<option value="SOL105">SOL105 - Buckling</option>
|
||||
<option value="SOL106">SOL106 - Nonlinear Statics</option>
|
||||
<option value="SOL111">SOL111 - Modal Frequency Response</option>
|
||||
<option value="SOL112">SOL112 - Modal Transient Response</option>
|
||||
<option value="SOL200">SOL200 - Design Optimization</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPython && (
|
||||
<div>
|
||||
<label className={labelClass}>Script Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scriptPath}
|
||||
onChange={(e) => handleScriptPathChange(e.target.value)}
|
||||
placeholder="path/to/solver_script.py"
|
||||
className={`${inputClass} font-mono text-sm`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Python script must define solve(params) function
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -694,38 +775,21 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
{showCodeEditor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode size={18} className="text-violet-400" />
|
||||
<span className="font-medium text-white">Custom Extractor: {node.name}</span>
|
||||
<span className="text-xs text-dark-500 bg-dark-800 px-2 py-0.5 rounded">.py</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCodeEditor(false)}
|
||||
className="p-1.5 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1">
|
||||
<CodeEditorPanel
|
||||
initialCode={currentCode}
|
||||
extractorName={node.name}
|
||||
outputs={node.outputs?.map(o => o.name) || []}
|
||||
onChange={handleCodeChange}
|
||||
onRequestGeneration={handleRequestGeneration}
|
||||
onRequestStreamingGeneration={handleStreamingGeneration}
|
||||
onRun={handleValidateCode}
|
||||
onTest={handleTestCode}
|
||||
onClose={() => setShowCodeEditor(false)}
|
||||
showHeader={false}
|
||||
height="100%"
|
||||
studyId={studyId || undefined}
|
||||
/>
|
||||
</div>
|
||||
{/* Code Editor with built-in header containing toolbar buttons */}
|
||||
<CodeEditorPanel
|
||||
initialCode={currentCode}
|
||||
extractorName={`Custom Extractor: ${node.name}`}
|
||||
outputs={node.outputs?.map(o => o.name) || []}
|
||||
onChange={handleCodeChange}
|
||||
onRequestGeneration={handleRequestGeneration}
|
||||
onRequestStreamingGeneration={handleStreamingGeneration}
|
||||
onRun={handleValidateCode}
|
||||
onTest={handleTestCode}
|
||||
onClose={() => setShowCodeEditor(false)}
|
||||
showHeader={true}
|
||||
height="100%"
|
||||
studyId={studyId || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* PanelContainer - Orchestrates all floating panels in the canvas view
|
||||
*
|
||||
* This component renders floating panels (Introspection, Validation, Error, Results)
|
||||
* in a portal, positioned absolutely within the canvas area.
|
||||
*
|
||||
* Features:
|
||||
* - Draggable panels
|
||||
* - Z-index management (click to bring to front)
|
||||
* - Keyboard shortcuts (Escape to close all)
|
||||
* - Position persistence via usePanelStore
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
usePanelStore,
|
||||
useIntrospectionPanel,
|
||||
useValidationPanel,
|
||||
useErrorPanel,
|
||||
useResultsPanel,
|
||||
PanelPosition,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
|
||||
import { FloatingValidationPanel } from './ValidationPanel';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
import { ResultsPanel } from './ResultsPanel';
|
||||
|
||||
interface PanelContainerProps {
|
||||
/** Container element to render panels into (defaults to document.body) */
|
||||
container?: HTMLElement;
|
||||
/** Callback when retry is requested from error panel */
|
||||
onRetry?: (trial?: number) => void;
|
||||
/** Callback when skip trial is requested */
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
|
||||
|
||||
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
|
||||
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
|
||||
|
||||
const introspectionPanel = useIntrospectionPanel();
|
||||
const validationPanel = useValidationPanel();
|
||||
const errorPanel = useErrorPanel();
|
||||
const resultsPanel = useResultsPanel();
|
||||
|
||||
// Track which panel is on top (for z-index)
|
||||
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
|
||||
|
||||
// Dragging state
|
||||
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
|
||||
// Escape key to close all panels
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAllPanels();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeAllPanels]);
|
||||
|
||||
// Mouse move handler for dragging
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { panel, offset } = dragRef.current;
|
||||
const newPosition: PanelPosition = {
|
||||
x: e.clientX - offset.x,
|
||||
y: e.clientY - offset.y,
|
||||
};
|
||||
|
||||
// Clamp to viewport
|
||||
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
|
||||
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
|
||||
|
||||
setPanelPosition(panel, newPosition);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragRef.current = null;
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
if (dragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, setPanelPosition]);
|
||||
|
||||
// Start dragging a panel
|
||||
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
|
||||
const offset = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
dragRef.current = { panel, offset };
|
||||
setDragging({ panel, offset });
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Click to bring panel to front
|
||||
const handlePanelClick = useCallback((panel: PanelName) => {
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Get z-index for a panel
|
||||
const getZIndex = (panel: PanelName) => {
|
||||
const baseZ = 100;
|
||||
if (panel === topPanel) return baseZ + 10;
|
||||
return baseZ;
|
||||
};
|
||||
|
||||
// Render a draggable wrapper
|
||||
const renderDraggable = (
|
||||
panel: PanelName,
|
||||
position: PanelPosition,
|
||||
isOpen: boolean,
|
||||
children: React.ReactNode
|
||||
) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={panel}
|
||||
className="fixed select-none"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: getZIndex(panel),
|
||||
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
|
||||
}}
|
||||
onClick={() => handlePanelClick(panel)}
|
||||
>
|
||||
{/* Drag handle - the header area */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={(e) => handleDragStart(panel, e, position)}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Panel content */}
|
||||
<div className="relative" style={{ zIndex: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Determine what to render
|
||||
const panels = (
|
||||
<>
|
||||
{/* Introspection Panel */}
|
||||
{renderDraggable(
|
||||
'introspection',
|
||||
introspectionPanel.position || { x: 100, y: 100 },
|
||||
introspectionPanel.open,
|
||||
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
|
||||
)}
|
||||
|
||||
{/* Validation Panel */}
|
||||
{renderDraggable(
|
||||
'validation',
|
||||
validationPanel.position || { x: 150, y: 150 },
|
||||
validationPanel.open,
|
||||
<FloatingValidationPanel onClose={() => closePanel('validation')} />
|
||||
)}
|
||||
|
||||
{/* Error Panel */}
|
||||
{renderDraggable(
|
||||
'error',
|
||||
errorPanel.position || { x: 200, y: 100 },
|
||||
errorPanel.open,
|
||||
<ErrorPanel
|
||||
onClose={() => closePanel('error')}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results Panel */}
|
||||
{renderDraggable(
|
||||
'results',
|
||||
resultsPanel.position || { x: 250, y: 150 },
|
||||
resultsPanel.open,
|
||||
<ResultsPanel onClose={() => closePanel('results')} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Use portal if container specified, otherwise render in place
|
||||
if (container) {
|
||||
return createPortal(panels, container);
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export default PanelContainer;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* ResultsPanel - Shows detailed trial results
|
||||
*
|
||||
* Displays the parameters, objectives, and constraints for a specific trial.
|
||||
* Can be opened by clicking on result badges on nodes.
|
||||
*/
|
||||
|
||||
import {
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Trophy,
|
||||
SlidersHorizontal,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ResultsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResultsPanel({ onClose }: ResultsPanelProps) {
|
||||
const panel = useResultsPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const data = panel.data;
|
||||
|
||||
if (!panel.open || !data) return null;
|
||||
|
||||
const timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('results')}
|
||||
>
|
||||
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="font-medium text-white">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
{data.isBest && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('results')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{data.isFeasible ? (
|
||||
<div className="flex items-center gap-1.5 text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
<span className="text-sm font-medium">Feasible</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-red-400">
|
||||
<XCircle size={16} />
|
||||
<span className="text-sm font-medium">Infeasible</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">{timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<SlidersHorizontal size={12} />
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.params).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-white font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Target size={12} />
|
||||
Objectives
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.objectives).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints (if any) */}
|
||||
{data.constraints && Object.keys(data.constraints).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle size={12} />
|
||||
Constraints
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.constraints).map(([name, constraint]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={`flex justify-between p-2 rounded text-sm ${
|
||||
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-300 flex items-center gap-1.5">
|
||||
{constraint.feasible ? (
|
||||
<CheckCircle size={12} className="text-green-400" />
|
||||
) : (
|
||||
<XCircle size={12} className="text-red-400" />
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
|
||||
{formatValue(constraint.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) {
|
||||
return value.toExponential(3);
|
||||
}
|
||||
return value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export default ResultsPanel;
|
||||
@@ -1,10 +1,41 @@
|
||||
/**
|
||||
* ValidationPanel - Displays spec validation errors and warnings
|
||||
*
|
||||
* Shows a list of validation issues that need to be fixed before
|
||||
* running an optimization. Supports auto-navigation to problematic nodes.
|
||||
*
|
||||
* Can be used in two modes:
|
||||
* 1. Legacy mode: Pass validation prop directly (for backward compatibility)
|
||||
* 2. Store mode: Uses usePanelStore for persistent state
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
import { ValidationResult } from '../../../lib/canvas/validation';
|
||||
|
||||
interface ValidationPanelProps {
|
||||
// ============================================================================
|
||||
// Legacy Props Interface (for backward compatibility)
|
||||
// ============================================================================
|
||||
|
||||
interface LegacyValidationPanelProps {
|
||||
validation: ValidationResult;
|
||||
}
|
||||
|
||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
/**
|
||||
* Legacy ValidationPanel - Inline display for canvas overlay
|
||||
* Kept for backward compatibility with AtomizerCanvas
|
||||
*/
|
||||
export function ValidationPanel({ validation }: LegacyValidationPanelProps) {
|
||||
return (
|
||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
|
||||
{validation.errors.length > 0 && (
|
||||
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New Floating Panel (uses store)
|
||||
// ============================================================================
|
||||
|
||||
interface FloatingValidationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
|
||||
const panel = useValidationPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const { selectNode } = useSpecStore();
|
||||
|
||||
const { errors, warnings, valid } = useMemo(() => {
|
||||
if (!panel.data) {
|
||||
return { errors: [], warnings: [], valid: true };
|
||||
}
|
||||
return {
|
||||
errors: panel.data.errors || [],
|
||||
warnings: panel.data.warnings || [],
|
||||
valid: panel.data.valid,
|
||||
};
|
||||
}, [panel.data]);
|
||||
|
||||
const handleNavigateToNode = (nodeId?: string) => {
|
||||
if (nodeId) {
|
||||
selectNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('validation')}
|
||||
>
|
||||
{valid ? (
|
||||
<CheckCircle size={16} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={16} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-sm text-white font-medium">
|
||||
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{valid ? (
|
||||
<CheckCircle size={18} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
<span className="font-medium text-white">
|
||||
{valid ? 'Validation Passed' : 'Validation Issues'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('validation')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{valid && errors.length === 0 && warnings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CheckCircle size={40} className="text-green-400 mb-3" />
|
||||
<p className="text-white font-medium">All checks passed!</p>
|
||||
<p className="text-sm text-dark-400 mt-1">
|
||||
Your spec is ready to run.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Errors ({errors.length})
|
||||
</h4>
|
||||
{errors.map((error, idx) => (
|
||||
<ValidationItem
|
||||
key={`error-${idx}`}
|
||||
item={error}
|
||||
severity="error"
|
||||
onNavigate={() => handleNavigateToNode(error.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertTriangle size={12} />
|
||||
Warnings ({warnings.length})
|
||||
</h4>
|
||||
{warnings.map((warning, idx) => (
|
||||
<ValidationItem
|
||||
key={`warning-${idx}`}
|
||||
item={warning}
|
||||
severity="warning"
|
||||
onNavigate={() => handleNavigateToNode(warning.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!valid && (
|
||||
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
|
||||
<p className="text-xs text-dark-400">
|
||||
Fix all errors before running the optimization.
|
||||
Warnings can be ignored but may cause issues.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationItemProps {
|
||||
item: StoreValidationError;
|
||||
severity: 'error' | 'warning';
|
||||
onNavigate: () => void;
|
||||
}
|
||||
|
||||
function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) {
|
||||
const isError = severity === 'error';
|
||||
const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10';
|
||||
const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30';
|
||||
const iconColor = isError ? 'text-red-400' : 'text-amber-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isError ? (
|
||||
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
) : (
|
||||
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white">{item.message}</p>
|
||||
{item.path && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
|
||||
)}
|
||||
{item.suggestion && (
|
||||
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.nodeId && (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidationPanel;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
|
||||
*
|
||||
* Displays the last N trial values as a mini line chart.
|
||||
* Used on ObjectiveNode to show convergence trend.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface ConvergenceSparklineProps {
|
||||
/** Array of values (most recent last) */
|
||||
values: number[];
|
||||
/** Width in pixels */
|
||||
width?: number;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Line color */
|
||||
color?: string;
|
||||
/** Best value line color */
|
||||
bestColor?: string;
|
||||
/** Whether to show the best value line */
|
||||
showBest?: boolean;
|
||||
/** Direction: minimize shows lower as better, maximize shows higher as better */
|
||||
direction?: 'minimize' | 'maximize';
|
||||
/** Show dots at each point */
|
||||
showDots?: boolean;
|
||||
/** Number of points to display */
|
||||
maxPoints?: number;
|
||||
}
|
||||
|
||||
export function ConvergenceSparkline({
|
||||
values,
|
||||
width = 80,
|
||||
height = 24,
|
||||
color = '#60a5fa',
|
||||
bestColor = '#34d399',
|
||||
showBest = true,
|
||||
direction = 'minimize',
|
||||
showDots = false,
|
||||
maxPoints = 20,
|
||||
}: ConvergenceSparklineProps) {
|
||||
const { path, bestY, points } = useMemo(() => {
|
||||
if (!values || values.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Take last N points
|
||||
const data = values.slice(-maxPoints);
|
||||
if (data.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Calculate bounds with padding
|
||||
const minVal = Math.min(...data);
|
||||
const maxVal = Math.max(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
const padding = range * 0.1;
|
||||
const yMin = minVal - padding;
|
||||
const yMax = maxVal + padding;
|
||||
const yRange = yMax - yMin;
|
||||
|
||||
// Calculate best value
|
||||
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
|
||||
|
||||
// Map values to SVG coordinates
|
||||
const xStep = width / Math.max(data.length - 1, 1);
|
||||
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
|
||||
|
||||
// Build path
|
||||
const points = data.map((v, i) => ({
|
||||
x: i * xStep,
|
||||
y: mapY(v),
|
||||
value: v,
|
||||
}));
|
||||
|
||||
const pathParts = points.map((p, i) =>
|
||||
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
|
||||
);
|
||||
|
||||
return {
|
||||
path: pathParts.join(' '),
|
||||
bestY: mapY(bestVal),
|
||||
points,
|
||||
minVal,
|
||||
maxVal,
|
||||
};
|
||||
}, [values, width, height, maxPoints, direction]);
|
||||
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-dark-500 text-xs"
|
||||
style={{ width, height }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="overflow-visible"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{/* Best value line */}
|
||||
{showBest && bestY !== null && (
|
||||
<line
|
||||
x1={0}
|
||||
y1={bestY}
|
||||
x2={width}
|
||||
y2={bestY}
|
||||
stroke={bestColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2,2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Gradient fill under the line */}
|
||||
<defs>
|
||||
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{points.length > 1 && (
|
||||
<path
|
||||
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
|
||||
fill="url(#sparkline-gradient)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dots at each point */}
|
||||
{showDots && points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={2}
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Last point highlight */}
|
||||
{points.length > 0 && (
|
||||
<circle
|
||||
cx={points[points.length - 1].x}
|
||||
cy={points[points.length - 1].y}
|
||||
r={3}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressRing - Circular progress indicator
|
||||
*/
|
||||
interface ProgressRingProps {
|
||||
/** Progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Size in pixels */
|
||||
size?: number;
|
||||
/** Stroke width */
|
||||
strokeWidth?: number;
|
||||
/** Progress color */
|
||||
color?: string;
|
||||
/** Background color */
|
||||
bgColor?: string;
|
||||
/** Show percentage text */
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 32,
|
||||
strokeWidth = 3,
|
||||
color = '#60a5fa',
|
||||
bgColor = '#374151',
|
||||
showText = true,
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={bgColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="absolute text-xs font-medium"
|
||||
style={{ color, fontSize: size * 0.25 }}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConvergenceSparkline;
|
||||
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
|
||||
*
|
||||
* This hook provides:
|
||||
* - Real-time trial updates (no polling needed)
|
||||
* - Best trial tracking
|
||||
* - Progress tracking
|
||||
* - Error detection and reporting
|
||||
* - Integration with panel store for error display
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const {
|
||||
* isConnected,
|
||||
* progress,
|
||||
* bestTrial,
|
||||
* recentTrials,
|
||||
* status
|
||||
* } = useOptimizationStream(studyId);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
import { usePanelStore } from './usePanelStore';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TrialData {
|
||||
trial_number: number;
|
||||
trial_num: number;
|
||||
objective: number | null;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs: Record<string, unknown>;
|
||||
source: 'FEA' | 'NN' | string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
study_name: string;
|
||||
constraint_satisfied: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressData {
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
fea_count: number;
|
||||
nn_count: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface BestTrialData {
|
||||
trial_number: number;
|
||||
value: number;
|
||||
params: Record<string, number>;
|
||||
improvement: number;
|
||||
}
|
||||
|
||||
export interface ParetoData {
|
||||
pareto_front: Array<{
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
constraint_satisfied: boolean;
|
||||
source: string;
|
||||
}>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
|
||||
|
||||
export interface OptimizationStreamState {
|
||||
isConnected: boolean;
|
||||
status: OptimizationStatus;
|
||||
progress: ProgressData | null;
|
||||
bestTrial: BestTrialData | null;
|
||||
recentTrials: TrialData[];
|
||||
paretoFront: ParetoData | null;
|
||||
lastUpdate: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseOptimizationStreamOptions {
|
||||
/** Maximum number of recent trials to keep */
|
||||
maxRecentTrials?: number;
|
||||
/** Callback when a new trial completes */
|
||||
onTrialComplete?: (trial: TrialData) => void;
|
||||
/** Callback when a new best is found */
|
||||
onNewBest?: (best: BestTrialData) => void;
|
||||
/** Callback on progress update */
|
||||
onProgress?: (progress: ProgressData) => void;
|
||||
/** Whether to auto-report errors to the error panel */
|
||||
autoReportErrors?: boolean;
|
||||
}
|
||||
|
||||
export function useOptimizationStream(
|
||||
studyId: string | null | undefined,
|
||||
options: UseOptimizationStreamOptions = {}
|
||||
) {
|
||||
const {
|
||||
maxRecentTrials = 20,
|
||||
onTrialComplete,
|
||||
onNewBest,
|
||||
onProgress,
|
||||
autoReportErrors = true,
|
||||
} = options;
|
||||
|
||||
// Panel store for error reporting
|
||||
const { addError } = usePanelStore();
|
||||
|
||||
// State
|
||||
const [state, setState] = useState<OptimizationStreamState>({
|
||||
isConnected: false,
|
||||
status: 'disconnected',
|
||||
progress: null,
|
||||
bestTrial: null,
|
||||
recentTrials: [],
|
||||
paretoFront: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Track last error timestamp to avoid duplicates
|
||||
const lastErrorTime = useRef<number>(0);
|
||||
|
||||
// Build WebSocket URL
|
||||
const socketUrl = studyId
|
||||
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
|
||||
import.meta.env.DEV ? 'localhost:8001' : window.location.host
|
||||
}/api/ws/optimization/${encodeURIComponent(studyId)}`
|
||||
: null;
|
||||
|
||||
// WebSocket connection
|
||||
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
|
||||
shouldReconnect: () => true,
|
||||
reconnectAttempts: 10,
|
||||
reconnectInterval: 3000,
|
||||
onOpen: () => {
|
||||
console.log('[OptStream] Connected to optimization stream');
|
||||
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('[OptStream] Disconnected from optimization stream');
|
||||
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
|
||||
},
|
||||
onError: (event) => {
|
||||
console.error('[OptStream] WebSocket error:', event);
|
||||
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
|
||||
},
|
||||
});
|
||||
|
||||
// Update connection status
|
||||
useEffect(() => {
|
||||
const statusMap: Record<ReadyState, OptimizationStatus> = {
|
||||
[ReadyState.CONNECTING]: 'connecting',
|
||||
[ReadyState.OPEN]: 'connected',
|
||||
[ReadyState.CLOSING]: 'disconnected',
|
||||
[ReadyState.CLOSED]: 'disconnected',
|
||||
[ReadyState.UNINSTANTIATED]: 'disconnected',
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: readyState === ReadyState.OPEN,
|
||||
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
|
||||
? prev.status
|
||||
: statusMap[readyState] || 'disconnected',
|
||||
}));
|
||||
}, [readyState]);
|
||||
|
||||
// Process incoming messages
|
||||
useEffect(() => {
|
||||
if (!lastMessage?.data) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(lastMessage.data);
|
||||
const { type, data } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
console.log('[OptStream] Connection confirmed:', data.message);
|
||||
break;
|
||||
|
||||
case 'trial_completed':
|
||||
handleTrialComplete(data as TrialData);
|
||||
break;
|
||||
|
||||
case 'new_best':
|
||||
handleNewBest(data as BestTrialData);
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
handleProgress(data as ProgressData);
|
||||
break;
|
||||
|
||||
case 'pareto_update':
|
||||
handleParetoUpdate(data as ParetoData);
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
case 'pong':
|
||||
// Keep-alive messages
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
handleError(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[OptStream] Unknown message type:', type, data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OptStream] Failed to parse message:', e);
|
||||
}
|
||||
}, [lastMessage]);
|
||||
|
||||
// Handler functions
|
||||
const handleTrialComplete = useCallback((trial: TrialData) => {
|
||||
setState(prev => {
|
||||
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
|
||||
return {
|
||||
...prev,
|
||||
recentTrials: newTrials,
|
||||
lastUpdate: Date.now(),
|
||||
status: 'running',
|
||||
};
|
||||
});
|
||||
|
||||
onTrialComplete?.(trial);
|
||||
}, [maxRecentTrials, onTrialComplete]);
|
||||
|
||||
const handleNewBest = useCallback((best: BestTrialData) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
bestTrial: best,
|
||||
lastUpdate: Date.now(),
|
||||
}));
|
||||
|
||||
onNewBest?.(best);
|
||||
}, [onNewBest]);
|
||||
|
||||
const handleProgress = useCallback((progress: ProgressData) => {
|
||||
setState(prev => {
|
||||
// Determine status based on progress
|
||||
let status: OptimizationStatus = prev.status;
|
||||
if (progress.current > 0 && progress.current < progress.total) {
|
||||
status = 'running';
|
||||
} else if (progress.current >= progress.total) {
|
||||
status = 'completed';
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
progress,
|
||||
status,
|
||||
lastUpdate: Date.now(),
|
||||
};
|
||||
});
|
||||
|
||||
onProgress?.(progress);
|
||||
}, [onProgress]);
|
||||
|
||||
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
paretoFront: pareto,
|
||||
lastUpdate: Date.now(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Avoid duplicate errors within 5 seconds
|
||||
if (now - lastErrorTime.current < 5000) return;
|
||||
lastErrorTime.current = now;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: errorData.message,
|
||||
status: 'failed',
|
||||
}));
|
||||
|
||||
if (autoReportErrors) {
|
||||
addError({
|
||||
type: 'system_error',
|
||||
message: errorData.message,
|
||||
details: errorData.details,
|
||||
trial: errorData.trial,
|
||||
recoverable: true,
|
||||
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
}, [autoReportErrors, addError]);
|
||||
|
||||
// Send ping to keep connection alive
|
||||
useEffect(() => {
|
||||
if (readyState !== ReadyState.OPEN) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
sendMessage(JSON.stringify({ type: 'ping' }));
|
||||
}, 25000); // Ping every 25 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [readyState, sendMessage]);
|
||||
|
||||
// Reset state when study changes
|
||||
useEffect(() => {
|
||||
setState({
|
||||
isConnected: false,
|
||||
status: 'disconnected',
|
||||
progress: null,
|
||||
bestTrial: null,
|
||||
recentTrials: [],
|
||||
paretoFront: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
}, [studyId]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
|
||||
};
|
||||
}
|
||||
|
||||
export default useOptimizationStream;
|
||||
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal file
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* usePanelStore - Centralized state management for canvas panels
|
||||
*
|
||||
* This store manages the visibility and state of all panels in the canvas view.
|
||||
* Panels persist their state even when the user clicks elsewhere on the canvas.
|
||||
*
|
||||
* Panel Types:
|
||||
* - introspection: Model introspection results (floating, draggable)
|
||||
* - validation: Spec validation errors/warnings (floating)
|
||||
* - results: Trial results details (floating)
|
||||
* - error: Error display with recovery options (floating)
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface IntrospectionData {
|
||||
filePath: string;
|
||||
studyId?: string;
|
||||
selectedFile?: string;
|
||||
result?: Record<string, unknown>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string;
|
||||
severity: 'error' | 'warning';
|
||||
path: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationData {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface OptimizationError {
|
||||
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown';
|
||||
trial?: number;
|
||||
message: string;
|
||||
details?: string;
|
||||
recoverable: boolean;
|
||||
suggestions: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface TrialResultData {
|
||||
trialNumber: number;
|
||||
params: Record<string, number>;
|
||||
objectives: Record<string, number>;
|
||||
constraints?: Record<string, { value: number; feasible: boolean }>;
|
||||
isFeasible: boolean;
|
||||
isBest: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PanelPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PanelState {
|
||||
open: boolean;
|
||||
position?: PanelPosition;
|
||||
minimized?: boolean;
|
||||
}
|
||||
|
||||
export interface IntrospectionPanelState extends PanelState {
|
||||
data?: IntrospectionData;
|
||||
}
|
||||
|
||||
export interface ValidationPanelState extends PanelState {
|
||||
data?: ValidationData;
|
||||
}
|
||||
|
||||
export interface ErrorPanelState extends PanelState {
|
||||
errors: OptimizationError[];
|
||||
}
|
||||
|
||||
export interface ResultsPanelState extends PanelState {
|
||||
data?: TrialResultData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Store Interface
|
||||
// ============================================================================
|
||||
|
||||
interface PanelStore {
|
||||
// Panel states
|
||||
introspection: IntrospectionPanelState;
|
||||
validation: ValidationPanelState;
|
||||
error: ErrorPanelState;
|
||||
results: ResultsPanelState;
|
||||
|
||||
// Generic panel actions
|
||||
openPanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
closePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
togglePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
minimizePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
|
||||
setPanelPosition: (panel: 'introspection' | 'validation' | 'error' | 'results', position: PanelPosition) => void;
|
||||
|
||||
// Introspection-specific actions
|
||||
setIntrospectionData: (data: IntrospectionData) => void;
|
||||
updateIntrospectionResult: (result: Record<string, unknown>) => void;
|
||||
setIntrospectionLoading: (loading: boolean) => void;
|
||||
setIntrospectionError: (error: string | null) => void;
|
||||
setIntrospectionFile: (fileName: string) => void;
|
||||
|
||||
// Validation-specific actions
|
||||
setValidationData: (data: ValidationData) => void;
|
||||
clearValidation: () => void;
|
||||
|
||||
// Error-specific actions
|
||||
addError: (error: OptimizationError) => void;
|
||||
clearErrors: () => void;
|
||||
dismissError: (timestamp: number) => void;
|
||||
|
||||
// Results-specific actions
|
||||
setTrialResult: (data: TrialResultData) => void;
|
||||
clearTrialResult: () => void;
|
||||
|
||||
// Utility
|
||||
closeAllPanels: () => void;
|
||||
hasOpenPanels: () => boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default States
|
||||
// ============================================================================
|
||||
|
||||
const defaultIntrospection: IntrospectionPanelState = {
|
||||
open: false,
|
||||
position: { x: 100, y: 100 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const defaultValidation: ValidationPanelState = {
|
||||
open: false,
|
||||
position: { x: 150, y: 150 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
const defaultError: ErrorPanelState = {
|
||||
open: false,
|
||||
position: { x: 200, y: 100 },
|
||||
minimized: false,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const defaultResults: ResultsPanelState = {
|
||||
open: false,
|
||||
position: { x: 250, y: 150 },
|
||||
minimized: false,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Store Implementation
|
||||
// ============================================================================
|
||||
|
||||
export const usePanelStore = create<PanelStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial states
|
||||
introspection: defaultIntrospection,
|
||||
validation: defaultValidation,
|
||||
error: defaultError,
|
||||
results: defaultResults,
|
||||
|
||||
// Generic panel actions
|
||||
openPanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: true, minimized: false }
|
||||
})),
|
||||
|
||||
closePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: false }
|
||||
})),
|
||||
|
||||
togglePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], open: !state[panel].open, minimized: false }
|
||||
})),
|
||||
|
||||
minimizePanel: (panel) => set((state) => ({
|
||||
[panel]: { ...state[panel], minimized: !state[panel].minimized }
|
||||
})),
|
||||
|
||||
setPanelPosition: (panel, position) => set((state) => ({
|
||||
[panel]: { ...state[panel], position }
|
||||
})),
|
||||
|
||||
// Introspection actions
|
||||
setIntrospectionData: (data) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
updateIntrospectionResult: (result) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, result, isLoading: false, error: null }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionLoading: (loading) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, isLoading: loading }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionError: (error) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, error, isLoading: false }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
setIntrospectionFile: (fileName) => set((state) => ({
|
||||
introspection: {
|
||||
...state.introspection,
|
||||
data: state.introspection.data
|
||||
? { ...state.introspection.data, selectedFile: fileName }
|
||||
: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
// Validation actions
|
||||
setValidationData: (data) => set((state) => ({
|
||||
validation: {
|
||||
...state.validation,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
clearValidation: () => set((state) => ({
|
||||
validation: {
|
||||
...state.validation,
|
||||
data: undefined
|
||||
}
|
||||
})),
|
||||
|
||||
// Error actions
|
||||
addError: (error) => set((state) => ({
|
||||
error: {
|
||||
...state.error,
|
||||
open: true,
|
||||
errors: [...state.error.errors, error]
|
||||
}
|
||||
})),
|
||||
|
||||
clearErrors: () => set((state) => ({
|
||||
error: {
|
||||
...state.error,
|
||||
errors: [],
|
||||
open: false
|
||||
}
|
||||
})),
|
||||
|
||||
dismissError: (timestamp) => set((state) => {
|
||||
const newErrors = state.error.errors.filter(e => e.timestamp !== timestamp);
|
||||
return {
|
||||
error: {
|
||||
...state.error,
|
||||
errors: newErrors,
|
||||
open: newErrors.length > 0
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// Results actions
|
||||
setTrialResult: (data) => set((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
open: true,
|
||||
data
|
||||
}
|
||||
})),
|
||||
|
||||
clearTrialResult: () => set((state) => ({
|
||||
results: {
|
||||
...state.results,
|
||||
data: undefined,
|
||||
open: false
|
||||
}
|
||||
})),
|
||||
|
||||
// Utility
|
||||
closeAllPanels: () => set({
|
||||
introspection: { ...get().introspection, open: false },
|
||||
validation: { ...get().validation, open: false },
|
||||
error: { ...get().error, open: false },
|
||||
results: { ...get().results, open: false },
|
||||
}),
|
||||
|
||||
hasOpenPanels: () => {
|
||||
const state = get();
|
||||
return state.introspection.open ||
|
||||
state.validation.open ||
|
||||
state.error.open ||
|
||||
state.results.open;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'atomizer-panel-store',
|
||||
// Only persist certain fields (not loading states or errors)
|
||||
partialize: (state) => ({
|
||||
introspection: {
|
||||
position: state.introspection.position,
|
||||
// Don't persist open state - start fresh each session
|
||||
},
|
||||
validation: {
|
||||
position: state.validation.position,
|
||||
},
|
||||
error: {
|
||||
position: state.error.position,
|
||||
},
|
||||
results: {
|
||||
position: state.results.position,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Selector Hooks (for convenience)
|
||||
// ============================================================================
|
||||
|
||||
export const useIntrospectionPanel = () => usePanelStore((state) => state.introspection);
|
||||
export const useValidationPanel = () => usePanelStore((state) => state.validation);
|
||||
export const useErrorPanel = () => usePanelStore((state) => state.error);
|
||||
export const useResultsPanel = () => usePanelStore((state) => state.results);
|
||||
|
||||
// Actions
|
||||
export const usePanelActions = () => usePanelStore((state) => ({
|
||||
openPanel: state.openPanel,
|
||||
closePanel: state.closePanel,
|
||||
togglePanel: state.togglePanel,
|
||||
minimizePanel: state.minimizePanel,
|
||||
setPanelPosition: state.setPanelPosition,
|
||||
setIntrospectionData: state.setIntrospectionData,
|
||||
updateIntrospectionResult: state.updateIntrospectionResult,
|
||||
setIntrospectionLoading: state.setIntrospectionLoading,
|
||||
setIntrospectionError: state.setIntrospectionError,
|
||||
setIntrospectionFile: state.setIntrospectionFile,
|
||||
setValidationData: state.setValidationData,
|
||||
clearValidation: state.clearValidation,
|
||||
addError: state.addError,
|
||||
clearErrors: state.clearErrors,
|
||||
dismissError: state.dismissError,
|
||||
setTrialResult: state.setTrialResult,
|
||||
clearTrialResult: state.clearTrialResult,
|
||||
closeAllPanels: state.closeAllPanels,
|
||||
}));
|
||||
156
atomizer-dashboard/frontend/src/hooks/useResizablePanel.ts
Normal file
156
atomizer-dashboard/frontend/src/hooks/useResizablePanel.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* useResizablePanel - Hook for creating resizable panels with persistence
|
||||
*
|
||||
* Features:
|
||||
* - Drag to resize
|
||||
* - Min/max constraints
|
||||
* - localStorage persistence
|
||||
* - Double-click to reset to default
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export interface ResizablePanelConfig {
|
||||
/** Unique key for localStorage persistence */
|
||||
storageKey: string;
|
||||
/** Default width in pixels */
|
||||
defaultWidth: number;
|
||||
/** Minimum width in pixels */
|
||||
minWidth: number;
|
||||
/** Maximum width in pixels */
|
||||
maxWidth: number;
|
||||
/** Side of the panel ('left' or 'right') - affects resize direction */
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface ResizablePanelState {
|
||||
/** Current width in pixels */
|
||||
width: number;
|
||||
/** Whether user is currently dragging */
|
||||
isDragging: boolean;
|
||||
/** Start drag handler - attach to resize handle mousedown */
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
/** Reset to default width */
|
||||
resetWidth: () => void;
|
||||
/** Set width programmatically */
|
||||
setWidth: (width: number) => void;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'atomizer-panel-';
|
||||
|
||||
function getStoredWidth(key: string, defaultWidth: number): number {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_PREFIX + key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return defaultWidth;
|
||||
}
|
||||
|
||||
function storeWidth(key: string, width: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + key, String(width));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
export function useResizablePanel(config: ResizablePanelConfig): ResizablePanelState {
|
||||
const { storageKey, defaultWidth, minWidth, maxWidth, side } = config;
|
||||
|
||||
// Initialize from localStorage
|
||||
const [width, setWidthState] = useState(() => {
|
||||
const stored = getStoredWidth(storageKey, defaultWidth);
|
||||
return Math.max(minWidth, Math.min(maxWidth, stored));
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Track initial position for drag calculation
|
||||
const dragStartRef = useRef<{ x: number; width: number } | null>(null);
|
||||
|
||||
// Clamp width within bounds
|
||||
const clampWidth = useCallback((w: number) => {
|
||||
return Math.max(minWidth, Math.min(maxWidth, w));
|
||||
}, [minWidth, maxWidth]);
|
||||
|
||||
// Set width with clamping and persistence
|
||||
const setWidth = useCallback((newWidth: number) => {
|
||||
const clamped = clampWidth(newWidth);
|
||||
setWidthState(clamped);
|
||||
storeWidth(storageKey, clamped);
|
||||
}, [clampWidth, storageKey]);
|
||||
|
||||
// Reset to default
|
||||
const resetWidth = useCallback(() => {
|
||||
setWidth(defaultWidth);
|
||||
}, [defaultWidth, setWidth]);
|
||||
|
||||
// Start drag handler
|
||||
const startDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, width };
|
||||
}, [width]);
|
||||
|
||||
// Handle mouse move during drag
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const delta = e.clientX - dragStartRef.current.x;
|
||||
|
||||
// For left panels, positive delta increases width
|
||||
// For right panels, negative delta increases width
|
||||
const newWidth = side === 'left'
|
||||
? dragStartRef.current.width + delta
|
||||
: dragStartRef.current.width - delta;
|
||||
|
||||
setWidthState(clampWidth(newWidth));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragStartRef.current) {
|
||||
// Persist the final width
|
||||
storeWidth(storageKey, width);
|
||||
}
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
// Add listeners to document for smooth dragging
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// Change cursor globally during drag
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, side, clampWidth, storageKey, width]);
|
||||
|
||||
return {
|
||||
width,
|
||||
isDragging,
|
||||
startDrag,
|
||||
resetWidth,
|
||||
setWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export default useResizablePanel;
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useUndoRedo, UndoRedoResult } from './useUndoRedo';
|
||||
import { useSpecStore, useSpec, useSpecIsDirty } from './useSpecStore';
|
||||
import { useSpecStore, useSpec } from './useSpecStore';
|
||||
import { AtomizerSpec } from '../types/atomizer-spec';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'atomizer-spec-history-';
|
||||
@@ -28,7 +28,6 @@ export interface SpecUndoRedoResult extends UndoRedoResult<AtomizerSpec | null>
|
||||
|
||||
export function useSpecUndoRedo(): SpecUndoRedoResult {
|
||||
const spec = useSpec();
|
||||
const isDirty = useSpecIsDirty();
|
||||
const studyId = useSpecStore((state) => state.studyId);
|
||||
const lastSpecRef = useRef<AtomizerSpec | null>(null);
|
||||
|
||||
@@ -56,13 +55,21 @@ export function useSpecUndoRedo(): SpecUndoRedoResult {
|
||||
},
|
||||
});
|
||||
|
||||
// Record snapshot when spec changes (and is dirty)
|
||||
// Record snapshot when spec changes
|
||||
// Note: We removed the isDirty check because with auto-save, isDirty is always false
|
||||
// after the API call completes. Instead, we compare the spec directly.
|
||||
useEffect(() => {
|
||||
if (spec && isDirty && spec !== lastSpecRef.current) {
|
||||
lastSpecRef.current = spec;
|
||||
undoRedo.recordSnapshot();
|
||||
if (spec && spec !== lastSpecRef.current) {
|
||||
// Deep compare to avoid recording duplicate snapshots
|
||||
const specStr = JSON.stringify(spec);
|
||||
const lastStr = lastSpecRef.current ? JSON.stringify(lastSpecRef.current) : '';
|
||||
|
||||
if (specStr !== lastStr) {
|
||||
lastSpecRef.current = spec;
|
||||
undoRedo.recordSnapshot();
|
||||
}
|
||||
}
|
||||
}, [spec, isDirty, undoRedo]);
|
||||
}, [spec, undoRedo]);
|
||||
|
||||
// Clear history when study changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface BaseNodeData {
|
||||
label: string;
|
||||
configured: boolean;
|
||||
errors?: string[];
|
||||
resultValue?: number | string | null; // For Results Overlay
|
||||
}
|
||||
|
||||
export interface ModelNodeData extends BaseNodeData {
|
||||
@@ -24,9 +25,17 @@ export interface ModelNodeData extends BaseNodeData {
|
||||
fileType?: 'prt' | 'fem' | 'sim';
|
||||
}
|
||||
|
||||
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
|
||||
export type NastranSolutionType = 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112' | 'SOL200';
|
||||
|
||||
export interface SolverNodeData extends BaseNodeData {
|
||||
type: 'solver';
|
||||
solverType?: 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112';
|
||||
/** Solver engine (nxnastran, mscnastran, python, etc.) */
|
||||
engine?: SolverEngine;
|
||||
/** Solution type for Nastran solvers */
|
||||
solverType?: NastranSolutionType;
|
||||
/** Python script path (for python engine) */
|
||||
scriptPath?: string;
|
||||
}
|
||||
|
||||
export interface DesignVarNodeData extends BaseNodeData {
|
||||
@@ -98,6 +107,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
|
||||
extractorRef?: string; // Reference to extractor ID
|
||||
outputName?: string; // Which output from the extractor
|
||||
penaltyWeight?: number; // For hard constraints (penalty method)
|
||||
history?: number[]; // Recent values for sparkline visualization
|
||||
}
|
||||
|
||||
export interface ConstraintNodeData extends BaseNodeData {
|
||||
@@ -105,6 +115,7 @@ export interface ConstraintNodeData extends BaseNodeData {
|
||||
name?: string;
|
||||
operator?: '<' | '<=' | '>' | '>=' | '==';
|
||||
value?: number;
|
||||
isFeasible?: boolean; // For Results Overlay
|
||||
}
|
||||
|
||||
export interface AlgorithmNodeData extends BaseNodeData {
|
||||
|
||||
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Spec Validator - Validate AtomizerSpec v2.0 before running optimization
|
||||
*
|
||||
* This validator checks the spec for completeness and correctness,
|
||||
* returning structured errors that can be displayed in the ValidationPanel.
|
||||
*/
|
||||
|
||||
import { AtomizerSpec } from '../../types/atomizer-spec';
|
||||
import { ValidationError, ValidationData } from '../../hooks/usePanelStore';
|
||||
|
||||
// ============================================================================
|
||||
// Validation Rules
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationRule {
|
||||
code: string;
|
||||
check: (spec: AtomizerSpec) => ValidationError | null;
|
||||
}
|
||||
|
||||
const validationRules: ValidationRule[] = [
|
||||
// ---- Critical Errors (must fix) ----
|
||||
|
||||
{
|
||||
code: 'NO_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return {
|
||||
code: 'NO_DESIGN_VARS',
|
||||
severity: 'error',
|
||||
path: 'design_variables',
|
||||
message: 'No design variables defined',
|
||||
suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_OBJECTIVES',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length === 0) {
|
||||
return {
|
||||
code: 'NO_OBJECTIVES',
|
||||
severity: 'error',
|
||||
path: 'objectives',
|
||||
message: 'No objectives defined',
|
||||
suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_EXTRACTORS',
|
||||
check: (spec) => {
|
||||
if (spec.extractors.length === 0) {
|
||||
return {
|
||||
code: 'NO_EXTRACTORS',
|
||||
severity: 'error',
|
||||
path: 'extractors',
|
||||
message: 'No extractors defined',
|
||||
suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'NO_MODEL',
|
||||
check: (spec) => {
|
||||
if (!spec.model.sim?.path) {
|
||||
return {
|
||||
code: 'NO_MODEL',
|
||||
severity: 'error',
|
||||
path: 'model.sim.path',
|
||||
message: 'No simulation file configured',
|
||||
suggestion: 'Select a .sim file in the study\'s model directory.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Design Variable Validation ----
|
||||
|
||||
{
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return {
|
||||
code: 'DV_INVALID_BOUNDS',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has invalid bounds (min >= max)`,
|
||||
suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`,
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
if (!dv.expression_name || dv.expression_name.trim() === '') {
|
||||
return {
|
||||
code: 'DV_NO_EXPRESSION',
|
||||
severity: 'error',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has no NX expression name`,
|
||||
suggestion: 'Set the expression_name to match an NX expression in the model.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Extractor Validation ----
|
||||
|
||||
{
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (!ext.type || ext.type.trim() === '') {
|
||||
return {
|
||||
code: 'EXTRACTOR_NO_TYPE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Extractor "${ext.name}" has no type selected`,
|
||||
suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
check: (spec) => {
|
||||
for (const ext of spec.extractors) {
|
||||
if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) {
|
||||
return {
|
||||
code: 'CUSTOM_EXTRACTOR_NO_CODE',
|
||||
severity: 'error',
|
||||
path: `extractors.${ext.id}`,
|
||||
message: `Custom extractor "${ext.name}" has no code defined`,
|
||||
suggestion: 'Open the code editor and write the extraction function.',
|
||||
nodeId: ext.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Objective Validation ----
|
||||
|
||||
{
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
check: (spec) => {
|
||||
for (const obj of spec.objectives) {
|
||||
// Check if objective is connected to an extractor via canvas edges
|
||||
const hasSource = spec.canvas?.edges?.some(
|
||||
edge => edge.target === obj.id && edge.source.startsWith('ext_')
|
||||
);
|
||||
|
||||
// Also check if source.extractor_id is set
|
||||
const hasDirectSource = obj.source?.extractor_id &&
|
||||
spec.extractors.some(e => e.id === obj.source.extractor_id);
|
||||
|
||||
if (!hasSource && !hasDirectSource) {
|
||||
return {
|
||||
code: 'OBJECTIVE_NO_SOURCE',
|
||||
severity: 'error',
|
||||
path: `objectives.${obj.id}`,
|
||||
message: `Objective "${obj.name}" has no connected extractor`,
|
||||
suggestion: 'Connect an extractor to this objective or set source_extractor_id.',
|
||||
nodeId: obj.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Constraint Validation ----
|
||||
|
||||
{
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
check: (spec) => {
|
||||
for (const con of spec.constraints || []) {
|
||||
if (con.threshold === undefined || con.threshold === null) {
|
||||
return {
|
||||
code: 'CONSTRAINT_NO_THRESHOLD',
|
||||
severity: 'error',
|
||||
path: `constraints.${con.id}`,
|
||||
message: `Constraint "${con.name}" has no threshold value`,
|
||||
suggestion: 'Set a threshold value for the constraint.',
|
||||
nodeId: con.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Warnings (can proceed but risky) ----
|
||||
|
||||
{
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials > 500) {
|
||||
return {
|
||||
code: 'HIGH_TRIAL_COUNT',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: `High trial count (${maxTrials}) may take several hours to complete`,
|
||||
suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'SINGLE_TRIAL',
|
||||
check: (spec) => {
|
||||
const maxTrials = spec.optimization.budget?.max_trials || 100;
|
||||
if (maxTrials === 1) {
|
||||
return {
|
||||
code: 'SINGLE_TRIAL',
|
||||
severity: 'warning',
|
||||
path: 'optimization.budget.max_trials',
|
||||
message: 'Only 1 trial configured - this will just run a single evaluation',
|
||||
suggestion: 'Increase max_trials to explore the design space.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
check: (spec) => {
|
||||
for (const dv of spec.design_variables) {
|
||||
if (dv.enabled === false) continue;
|
||||
const range = dv.bounds.max - dv.bounds.min;
|
||||
const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2;
|
||||
const relativeRange = range / Math.abs(baseline || 1);
|
||||
|
||||
if (relativeRange < 0.01) { // Less than 1% variation
|
||||
return {
|
||||
code: 'DV_NARROW_BOUNDS',
|
||||
severity: 'warning',
|
||||
path: `design_variables.${dv.id}`,
|
||||
message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`,
|
||||
suggestion: 'Consider widening the bounds for more meaningful exploration.',
|
||||
nodeId: dv.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
check: (spec) => {
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length > 10) {
|
||||
return {
|
||||
code: 'MANY_DESIGN_VARS',
|
||||
severity: 'warning',
|
||||
path: 'design_variables',
|
||||
message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`,
|
||||
suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
check: (spec) => {
|
||||
if (spec.objectives.length > 1) {
|
||||
const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null);
|
||||
if (!hasWeights) {
|
||||
return {
|
||||
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
|
||||
severity: 'warning',
|
||||
path: 'objectives',
|
||||
message: 'Multi-objective optimization without explicit weights',
|
||||
suggestion: 'Consider setting weights to control the trade-off between objectives.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Main Validation Function
|
||||
// ============================================================================
|
||||
|
||||
export function validateSpec(spec: AtomizerSpec): ValidationData {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationError[] = [];
|
||||
|
||||
for (const rule of validationRules) {
|
||||
const result = rule.check(spec);
|
||||
if (result) {
|
||||
if (result.severity === 'error') {
|
||||
errors.push(result);
|
||||
} else {
|
||||
warnings.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Validation (just checks if can run)
|
||||
// ============================================================================
|
||||
|
||||
export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } {
|
||||
// Check critical requirements only
|
||||
if (!spec.model.sim?.path) {
|
||||
return { canRun: false, reason: 'No simulation file configured' };
|
||||
}
|
||||
|
||||
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
|
||||
if (enabledDVs.length === 0) {
|
||||
return { canRun: false, reason: 'No design variables defined' };
|
||||
}
|
||||
|
||||
if (spec.objectives.length === 0) {
|
||||
return { canRun: false, reason: 'No objectives defined' };
|
||||
}
|
||||
|
||||
if (spec.extractors.length === 0) {
|
||||
return { canRun: false, reason: 'No extractors defined' };
|
||||
}
|
||||
|
||||
// Check for invalid bounds
|
||||
for (const dv of enabledDVs) {
|
||||
if (dv.bounds.min >= dv.bounds.max) {
|
||||
return { canRun: false, reason: `Invalid bounds for "${dv.name}"` };
|
||||
}
|
||||
}
|
||||
|
||||
return { canRun: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export validation result type for backward compatibility
|
||||
// ============================================================================
|
||||
|
||||
export interface LegacyValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult {
|
||||
return {
|
||||
valid: data.valid,
|
||||
errors: data.errors.map(e => e.message),
|
||||
warnings: data.warnings.map(w => w.message),
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,12 @@ import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
|
||||
import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
|
||||
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
||||
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
|
||||
import { PanelContainer } from '../components/canvas/panels/PanelContainer';
|
||||
import { ResizeHandle } from '../components/canvas/ResizeHandle';
|
||||
import { useCanvasStore } from '../hooks/useCanvasStore';
|
||||
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
|
||||
import { useResizablePanel } from '../hooks/useResizablePanel';
|
||||
// usePanelStore is now used by child components - PanelContainer handles panels
|
||||
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
@@ -29,6 +33,23 @@ export function CanvasView() {
|
||||
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
|
||||
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Resizable panels
|
||||
const leftPanel = useResizablePanel({
|
||||
storageKey: 'left-sidebar',
|
||||
defaultWidth: 240,
|
||||
minWidth: 200,
|
||||
maxWidth: 400,
|
||||
side: 'left',
|
||||
});
|
||||
|
||||
const rightPanel = useResizablePanel({
|
||||
storageKey: 'right-panel',
|
||||
defaultWidth: 384,
|
||||
minWidth: 280,
|
||||
maxWidth: 600,
|
||||
side: 'right',
|
||||
});
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Spec mode is the default (AtomizerSpec v2.0)
|
||||
@@ -296,17 +317,34 @@ export function CanvasView() {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Save Button - only show when there's a study and changes */}
|
||||
{activeStudyId && (
|
||||
{/* Save Button - always show in spec mode with study, grayed when no changes */}
|
||||
{useSpecMode && spec && (
|
||||
<button
|
||||
onClick={saveToConfig}
|
||||
disabled={isSaving || (useSpecMode ? !specIsDirty : !hasUnsavedChanges)}
|
||||
disabled={isSaving || !specIsDirty}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
(useSpecMode ? specIsDirty : hasUnsavedChanges)
|
||||
specIsDirty
|
||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
|
||||
}`}
|
||||
title={(useSpecMode ? specIsDirty : hasUnsavedChanges) ? `Save changes to ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}` : 'No changes to save'}
|
||||
title={specIsDirty ? 'Save changes to atomizer_spec.json' : 'No changes to save'}
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Legacy Save Button */}
|
||||
{!useSpecMode && activeStudyId && (
|
||||
<button
|
||||
onClick={saveToConfig}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
hasUnsavedChanges
|
||||
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
|
||||
}`}
|
||||
title={hasUnsavedChanges ? 'Save changes to optimization_config.json' : 'No changes to save'}
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
@@ -314,7 +352,7 @@ export function CanvasView() {
|
||||
)}
|
||||
|
||||
{/* Reload Button */}
|
||||
{activeStudyId && (
|
||||
{(useSpecMode ? spec : activeStudyId) && (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={isLoading || specLoading}
|
||||
@@ -404,7 +442,10 @@ export function CanvasView() {
|
||||
<main className="flex-1 overflow-hidden flex">
|
||||
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
|
||||
{useSpecMode && (
|
||||
<div className={`${paletteCollapsed ? 'w-14' : 'w-60'} bg-dark-850 border-r border-dark-700 flex flex-col transition-all duration-200`}>
|
||||
<div
|
||||
className="relative bg-dark-850 border-r border-dark-700 flex flex-col"
|
||||
style={{ width: paletteCollapsed ? 56 : leftPanel.width }}
|
||||
>
|
||||
{/* Tab buttons (only show when expanded) */}
|
||||
{!paletteCollapsed && (
|
||||
<div className="flex border-b border-dark-700">
|
||||
@@ -450,6 +491,16 @@ export function CanvasView() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize handle (only when not collapsed) */}
|
||||
{!paletteCollapsed && (
|
||||
<ResizeHandle
|
||||
onMouseDown={leftPanel.startDrag}
|
||||
onDoubleClick={leftPanel.resetWidth}
|
||||
isDragging={leftPanel.isDragging}
|
||||
position="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -472,19 +523,38 @@ export function CanvasView() {
|
||||
</div>
|
||||
|
||||
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
|
||||
{selectedNodeId && !showChat && (
|
||||
{/* Shows INSTEAD of chat when a node is selected */}
|
||||
{selectedNodeId ? (
|
||||
useSpecMode ? (
|
||||
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
|
||||
<div
|
||||
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
||||
style={{ width: rightPanel.width }}
|
||||
>
|
||||
<ResizeHandle
|
||||
onMouseDown={rightPanel.startDrag}
|
||||
onDoubleClick={rightPanel.resetWidth}
|
||||
isDragging={rightPanel.isDragging}
|
||||
position="left"
|
||||
/>
|
||||
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
|
||||
<NodeConfigPanel nodeId={selectedNodeId} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Chat/Assistant Panel */}
|
||||
{showChat && (
|
||||
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
|
||||
) : showChat ? (
|
||||
<div
|
||||
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
||||
style={{ width: rightPanel.width }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<ResizeHandle
|
||||
onMouseDown={rightPanel.startDrag}
|
||||
onDoubleClick={rightPanel.resetWidth}
|
||||
isDragging={rightPanel.isDragging}
|
||||
position="left"
|
||||
/>
|
||||
{/* Chat Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -524,7 +594,7 @@ export function CanvasView() {
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{/* Template Selector Modal */}
|
||||
@@ -541,6 +611,9 @@ export function CanvasView() {
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
||||
{useSpecMode && <PanelContainer />}
|
||||
|
||||
{/* Notification Toast */}
|
||||
{notification && (
|
||||
<div
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface SpecMeta {
|
||||
tags?: string[];
|
||||
/** Real-world engineering context */
|
||||
engineering_context?: string;
|
||||
/** Current workflow status */
|
||||
status?: 'draft' | 'introspected' | 'configured' | 'validated' | 'ready' | 'running' | 'completed' | 'failed';
|
||||
/** Topic/folder for organization */
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -64,6 +68,29 @@ export interface FemConfig {
|
||||
}
|
||||
|
||||
export type SolverType = 'nastran' | 'NX_Nastran' | 'abaqus';
|
||||
|
||||
/**
|
||||
* SolverEngine - The actual solver software used for analysis
|
||||
* - nxnastran: NX Nastran (built into Siemens NX)
|
||||
* - mscnastran: MSC Nastran (external)
|
||||
* - python: Custom Python script
|
||||
* - abaqus: Abaqus (future)
|
||||
* - ansys: ANSYS (future)
|
||||
*/
|
||||
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
|
||||
|
||||
/**
|
||||
* NastranSolutionType - Common Nastran solution types
|
||||
*/
|
||||
export type NastranSolutionType =
|
||||
| 'SOL101' // Linear Statics
|
||||
| 'SOL103' // Normal Modes
|
||||
| 'SOL105' // Buckling
|
||||
| 'SOL106' // Nonlinear Statics
|
||||
| 'SOL111' // Modal Frequency Response
|
||||
| 'SOL112' // Modal Transient Response
|
||||
| 'SOL200'; // Design Optimization
|
||||
|
||||
export type SubcaseType = 'static' | 'modal' | 'thermal' | 'buckling';
|
||||
|
||||
export interface Subcase {
|
||||
@@ -75,10 +102,14 @@ export interface Subcase {
|
||||
export interface SimConfig {
|
||||
/** Path to .sim file */
|
||||
path: string;
|
||||
/** Solver type */
|
||||
/** Solver type (legacy, use engine instead) */
|
||||
solver: SolverType;
|
||||
/** Solver engine software */
|
||||
engine?: SolverEngine;
|
||||
/** Solution type (e.g., SOL101) */
|
||||
solution_type?: string;
|
||||
solution_type?: NastranSolutionType | string;
|
||||
/** Python script path (for python engine) */
|
||||
script_path?: string;
|
||||
/** Defined subcases */
|
||||
subcases?: Subcase[];
|
||||
}
|
||||
@@ -89,11 +120,40 @@ export interface NxSettings {
|
||||
auto_start_nx?: boolean;
|
||||
}
|
||||
|
||||
export interface IntrospectionExpression {
|
||||
name: string;
|
||||
value: number | null;
|
||||
units: string | null;
|
||||
formula: string | null;
|
||||
is_candidate: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface IntrospectionData {
|
||||
timestamp: string;
|
||||
solver_type: string | null;
|
||||
mass_kg: number | null;
|
||||
volume_mm3: number | null;
|
||||
expressions: IntrospectionExpression[];
|
||||
warnings: string[];
|
||||
baseline: {
|
||||
timestamp: string;
|
||||
solve_time_seconds: number;
|
||||
mass_kg: number | null;
|
||||
max_displacement_mm: number | null;
|
||||
max_stress_mpa: number | null;
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
nx_part?: NxPartConfig;
|
||||
prt?: NxPartConfig;
|
||||
fem?: FemConfig;
|
||||
sim: SimConfig;
|
||||
sim?: SimConfig;
|
||||
nx_settings?: NxSettings;
|
||||
introspection?: IntrospectionData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Atomizer Documentation Index
|
||||
|
||||
**Last Updated**: 2026-01-20
|
||||
**Project Version**: 1.0.0 (AtomizerSpec v2.0 - Full LLM Integration)
|
||||
**Last Updated**: 2026-01-24
|
||||
**Project Version**: 0.5.0 (AtomizerSpec v2.0 - Canvas Builder)
|
||||
|
||||
---
|
||||
|
||||
@@ -201,6 +201,8 @@ Historical documents are preserved in `archive/`:
|
||||
- `archive/historical/` - Legacy documents, old protocols
|
||||
- `archive/marketing/` - Briefings, presentations
|
||||
- `archive/session_summaries/` - Past development sessions
|
||||
- `archive/plans/` - Superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
|
||||
- `archive/PROTOCOL_V1_MONOLITHIC.md` - Original monolithic protocol (Nov 2025)
|
||||
|
||||
---
|
||||
|
||||
@@ -216,5 +218,5 @@ For Claude/AI integration:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-20
|
||||
**Last Updated**: 2026-01-24
|
||||
**Maintained By**: Antoine / Atomaste
|
||||
|
||||
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal file
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal file
@@ -0,0 +1,438 @@
|
||||
# Canvas Builder Robustness & Enhancement Plan
|
||||
|
||||
**Created**: January 21, 2026
|
||||
**Branch**: `feature/studio-enhancement`
|
||||
**Status**: Planning
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan addresses critical issues and enhancements to make the Canvas Builder robust and production-ready:
|
||||
|
||||
1. **Panel Management** - Panels (Introspection, Config, Chat) disappear unexpectedly
|
||||
2. **Pre-run Validation** - No validation before starting optimization
|
||||
3. **Error Handling** - Poor feedback when things go wrong
|
||||
4. **Live Updates** - Polling is inefficient; need WebSocket
|
||||
5. **Visualization** - No convergence charts or progress indicators
|
||||
6. **Testing** - No automated tests for critical flows
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Panel Management System (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- IntrospectionPanel disappears when user clicks elsewhere on canvas
|
||||
- Panel state is lost (e.g., introspection results, expanded sections)
|
||||
- No way to have multiple panels open simultaneously
|
||||
- Chat panel and Config panel are mutually exclusive
|
||||
|
||||
### Root Cause
|
||||
```typescript
|
||||
// Current: Local state in ModelNodeConfig (NodeConfigPanelV2.tsx:275)
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
|
||||
// When selectedNodeId changes, ModelNodeConfig unmounts, losing state
|
||||
```
|
||||
|
||||
### Solution: Centralized Panel Store
|
||||
|
||||
Create `usePanelStore.ts` - a Zustand store for panel management:
|
||||
|
||||
```typescript
|
||||
// atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
|
||||
|
||||
interface PanelState {
|
||||
// Panel visibility
|
||||
panels: {
|
||||
introspection: { open: boolean; filePath?: string; data?: IntrospectionResult };
|
||||
config: { open: boolean; nodeId?: string };
|
||||
chat: { open: boolean; powerMode: boolean };
|
||||
validation: { open: boolean; errors?: ValidationError[] };
|
||||
results: { open: boolean; trialId?: number };
|
||||
};
|
||||
|
||||
// Actions
|
||||
openPanel: (panel: PanelName, data?: any) => void;
|
||||
closePanel: (panel: PanelName) => void;
|
||||
togglePanel: (panel: PanelName) => void;
|
||||
|
||||
// Panel data persistence
|
||||
setIntrospectionData: (data: IntrospectionResult) => void;
|
||||
clearIntrospectionData: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 1.1 | `usePanelStore.ts` | Create Zustand store for panel state |
|
||||
| 1.2 | `PanelContainer.tsx` | Create container that renders open panels |
|
||||
| 1.3 | `IntrospectionPanel.tsx` | Refactor to use store instead of local state |
|
||||
| 1.4 | `NodeConfigPanelV2.tsx` | Remove local panel state, use store |
|
||||
| 1.5 | `CanvasView.tsx` | Integrate PanelContainer, remove chat panel logic |
|
||||
| 1.6 | `SpecRenderer.tsx` | Add panel trigger buttons (introspect, validate) |
|
||||
|
||||
### UI Changes
|
||||
|
||||
**Before:**
|
||||
```
|
||||
[Canvas] [Config Panel OR Chat Panel]
|
||||
↑ mutually exclusive
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
[Canvas] [Right Panel Area]
|
||||
├── Config Panel (pinnable)
|
||||
├── Chat Panel (collapsible)
|
||||
└── Floating Panels:
|
||||
├── Introspection (draggable, persistent)
|
||||
├── Validation Results
|
||||
└── Trial Details
|
||||
```
|
||||
|
||||
### Panel Behaviors
|
||||
|
||||
| Panel | Trigger | Persistence | Position |
|
||||
|-------|---------|-------------|----------|
|
||||
| **Config** | Node click | While node selected | Right sidebar |
|
||||
| **Chat** | Toggle button | Always available | Right sidebar (below config) |
|
||||
| **Introspection** | "Introspect" button | Until explicitly closed | Floating, draggable |
|
||||
| **Validation** | "Validate" or pre-run | Until fixed or dismissed | Floating |
|
||||
| **Results** | Click on result badge | Until dismissed | Floating |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Pre-run Validation (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- User can click "Run" with incomplete spec
|
||||
- No feedback about missing extractors, objectives, or connections
|
||||
- Optimization fails silently or with cryptic errors
|
||||
|
||||
### Solution: Validation Pipeline
|
||||
|
||||
```typescript
|
||||
// Types of validation
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[]; // Must fix before running
|
||||
warnings: ValidationWarning[]; // Can proceed but risky
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
code: string;
|
||||
severity: 'error' | 'warning';
|
||||
path: string; // e.g., "objectives[0]"
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
autoFix?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Rules
|
||||
|
||||
| Rule | Severity | Message |
|
||||
|------|----------|---------|
|
||||
| No design variables | Error | "Add at least one design variable" |
|
||||
| No objectives | Error | "Add at least one objective" |
|
||||
| Objective not connected to extractor | Error | "Objective '{name}' has no source extractor" |
|
||||
| Extractor type not set | Error | "Extractor '{name}' needs a type selected" |
|
||||
| Design var bounds invalid | Error | "Min must be less than max for '{name}'" |
|
||||
| No model file | Error | "No simulation file configured" |
|
||||
| Custom extractor no code | Warning | "Custom extractor '{name}' has no code" |
|
||||
| High trial count (>500) | Warning | "Large budget may take hours to complete" |
|
||||
| Single trial | Warning | "Only 1 trial - results won't be meaningful" |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 2.1 | `validation/specValidator.ts` | Client-side validation rules |
|
||||
| 2.2 | `ValidationPanel.tsx` | Display validation results |
|
||||
| 2.3 | `SpecRenderer.tsx` | Add "Validate" button, pre-run check |
|
||||
| 2.4 | `api/routes/spec.py` | Server-side validation endpoint |
|
||||
| 2.5 | `useSpecStore.ts` | Add `validate()` action |
|
||||
|
||||
### UI Flow
|
||||
|
||||
```
|
||||
User clicks "Run Optimization"
|
||||
↓
|
||||
[Validate Spec] ──failed──→ [Show ValidationPanel]
|
||||
↓ passed │
|
||||
[Confirm Dialog] │
|
||||
↓ confirmed │
|
||||
[Start Optimization] ←── fix ─────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Error Handling & Recovery (HIGH PRIORITY)
|
||||
|
||||
### Problem
|
||||
- NX crashes don't show useful feedback
|
||||
- Solver failures leave user confused
|
||||
- No way to resume after errors
|
||||
|
||||
### Solution: Error Classification & Display
|
||||
|
||||
```typescript
|
||||
interface OptimizationError {
|
||||
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error';
|
||||
trial?: number;
|
||||
message: string;
|
||||
details?: string;
|
||||
recoverable: boolean;
|
||||
suggestions: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
| Error Type | Display | Recovery |
|
||||
|------------|---------|----------|
|
||||
| NX Crash | Toast + Error Panel | Retry trial, skip trial |
|
||||
| Solver Failure | Badge on trial | Mark infeasible, continue |
|
||||
| Extractor Error | Log + badge | Use NaN, continue |
|
||||
| Config Error | Block run | Show validation panel |
|
||||
| System Error | Full modal | Restart optimization |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 3.1 | `ErrorBoundary.tsx` | Wrap canvas in error boundary |
|
||||
| 3.2 | `ErrorPanel.tsx` | Detailed error display with suggestions |
|
||||
| 3.3 | `optimization.py` | Enhanced error responses with type/recovery |
|
||||
| 3.4 | `SpecRenderer.tsx` | Error state handling, retry buttons |
|
||||
| 3.5 | `useOptimizationStatus.ts` | Hook for status polling with error handling |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Live Updates via WebSocket (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- Current polling (3s) is inefficient and has latency
|
||||
- Missed updates between polls
|
||||
- No real-time progress indication
|
||||
|
||||
### Solution: WebSocket for Trial Updates
|
||||
|
||||
```typescript
|
||||
// WebSocket events
|
||||
interface TrialStartEvent {
|
||||
type: 'trial_start';
|
||||
trial_number: number;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
interface TrialCompleteEvent {
|
||||
type: 'trial_complete';
|
||||
trial_number: number;
|
||||
objectives: Record<string, number>;
|
||||
is_best: boolean;
|
||||
is_feasible: boolean;
|
||||
}
|
||||
|
||||
interface OptimizationCompleteEvent {
|
||||
type: 'optimization_complete';
|
||||
best_trial: number;
|
||||
total_trials: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 4.1 | `websocket.py` | Add optimization events to WS |
|
||||
| 4.2 | `run_optimization.py` | Emit events during optimization |
|
||||
| 4.3 | `useOptimizationWebSocket.ts` | Hook for WS subscription |
|
||||
| 4.4 | `SpecRenderer.tsx` | Use WS instead of polling |
|
||||
| 4.5 | `ResultBadge.tsx` | Animate on new results |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Convergence Visualization (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- No visual feedback on optimization progress
|
||||
- Can't tell if converging or stuck
|
||||
- No Pareto front visualization for multi-objective
|
||||
|
||||
### Solution: Embedded Charts
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `ConvergenceSparkline` | Tiny chart in ObjectiveNode showing trend |
|
||||
| `ProgressRing` | Circular progress in header (trials/total) |
|
||||
| `ConvergenceChart` | Full chart in Results panel |
|
||||
| `ParetoPlot` | 2D Pareto front for multi-objective |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 5.1 | `ConvergenceSparkline.tsx` | SVG sparkline component |
|
||||
| 5.2 | `ObjectiveNode.tsx` | Integrate sparkline |
|
||||
| 5.3 | `ProgressRing.tsx` | Circular progress indicator |
|
||||
| 5.4 | `ConvergenceChart.tsx` | Full chart with Recharts |
|
||||
| 5.5 | `ResultsPanel.tsx` | Panel showing detailed results |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: End-to-End Testing (MEDIUM PRIORITY)
|
||||
|
||||
### Problem
|
||||
- No automated tests for canvas operations
|
||||
- Manual testing is time-consuming and error-prone
|
||||
- Regressions go unnoticed
|
||||
|
||||
### Solution: Playwright E2E Tests
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
| Test | Steps | Assertions |
|
||||
|------|-------|------------|
|
||||
| Load study | Navigate to /canvas/{id} | Spec loads, nodes render |
|
||||
| Add design var | Drag from palette | Node appears, spec updates |
|
||||
| Connect nodes | Drag edge | Edge renders, spec has edge |
|
||||
| Edit node | Click node, change value | Value persists, API called |
|
||||
| Run validation | Click validate | Errors shown for incomplete |
|
||||
| Start optimization | Complete spec, click run | Status shows running |
|
||||
| View results | Wait for trial | Badge shows value |
|
||||
| Stop optimization | Click stop | Status shows stopped |
|
||||
|
||||
### Implementation Tasks
|
||||
|
||||
| Task | File | Description |
|
||||
|------|------|-------------|
|
||||
| 6.1 | `e2e/canvas.spec.ts` | Basic canvas operations |
|
||||
| 6.2 | `e2e/optimization.spec.ts` | Run/stop/status flow |
|
||||
| 6.3 | `e2e/panels.spec.ts` | Panel open/close/persist |
|
||||
| 6.4 | `playwright.config.ts` | Configure Playwright |
|
||||
| 6.5 | `CI workflow` | Run tests in GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
Week 1:
|
||||
├── Phase 1: Panel Management (critical UX fix)
|
||||
│ ├── Day 1-2: usePanelStore + PanelContainer
|
||||
│ └── Day 3-4: Refactor existing panels
|
||||
│
|
||||
├── Phase 2: Validation (prevent user errors)
|
||||
│ └── Day 5: Validation rules + UI
|
||||
|
||||
Week 2:
|
||||
├── Phase 3: Error Handling
|
||||
│ ├── Day 1-2: Error types + ErrorPanel
|
||||
│ └── Day 3: Integration with optimization flow
|
||||
│
|
||||
├── Phase 4: WebSocket Updates
|
||||
│ └── Day 4-5: WS events + frontend hook
|
||||
|
||||
Week 3:
|
||||
├── Phase 5: Visualization
|
||||
│ ├── Day 1-2: Sparklines
|
||||
│ └── Day 3: Progress indicators
|
||||
│
|
||||
├── Phase 6: Testing
|
||||
│ └── Day 4-5: Playwright setup + core tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Can Do Now)
|
||||
|
||||
These can be implemented immediately with minimal changes:
|
||||
|
||||
1. **Persist introspection data in localStorage**
|
||||
- Cache introspection results
|
||||
- Restore on panel reopen
|
||||
|
||||
2. **Add loading states to all buttons**
|
||||
- Disable during operations
|
||||
- Show spinners
|
||||
|
||||
3. **Add confirmation dialogs**
|
||||
- Before stopping optimization
|
||||
- Before clearing canvas
|
||||
|
||||
4. **Improve error messages**
|
||||
- Parse NX error logs
|
||||
- Show actionable suggestions
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
```
|
||||
atomizer-dashboard/frontend/src/
|
||||
├── hooks/
|
||||
│ ├── usePanelStore.ts
|
||||
│ └── useOptimizationWebSocket.ts
|
||||
├── components/canvas/
|
||||
│ ├── PanelContainer.tsx
|
||||
│ ├── panels/
|
||||
│ │ ├── ValidationPanel.tsx
|
||||
│ │ ├── ErrorPanel.tsx
|
||||
│ │ └── ResultsPanel.tsx
|
||||
│ └── visualization/
|
||||
│ ├── ConvergenceSparkline.tsx
|
||||
│ ├── ProgressRing.tsx
|
||||
│ └── ConvergenceChart.tsx
|
||||
└── lib/
|
||||
└── validation/
|
||||
└── specValidator.ts
|
||||
|
||||
e2e/
|
||||
├── canvas.spec.ts
|
||||
├── optimization.spec.ts
|
||||
└── panels.spec.ts
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
atomizer-dashboard/frontend/src/
|
||||
├── pages/CanvasView.tsx
|
||||
├── components/canvas/SpecRenderer.tsx
|
||||
├── components/canvas/panels/IntrospectionPanel.tsx
|
||||
├── components/canvas/panels/NodeConfigPanelV2.tsx
|
||||
├── components/canvas/nodes/ObjectiveNode.tsx
|
||||
└── hooks/useSpecStore.ts
|
||||
|
||||
atomizer-dashboard/backend/api/
|
||||
├── routes/optimization.py
|
||||
├── routes/spec.py
|
||||
└── websocket.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| Phase | Success Metric |
|
||||
|-------|----------------|
|
||||
| 1 | Introspection panel persists across node selections |
|
||||
| 2 | Invalid spec shows clear error before run |
|
||||
| 3 | NX errors display with recovery options |
|
||||
| 4 | Results update within 500ms of trial completion |
|
||||
| 5 | Convergence trend visible on objective nodes |
|
||||
| 6 | All E2E tests pass in CI |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review this plan
|
||||
2. Start with Phase 1 (Panel Management) - fixes your immediate issue
|
||||
3. Implement incrementally, commit after each phase
|
||||
445
docs/plans/CANVAS_UX_IMPROVEMENTS.md
Normal file
445
docs/plans/CANVAS_UX_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Canvas UX Improvements - Master Plan
|
||||
|
||||
**Created:** January 2026
|
||||
**Status:** Planning
|
||||
**Branch:** `feature/studio-enhancement`
|
||||
|
||||
## Overview
|
||||
|
||||
This plan addresses three major UX issues in the Canvas Builder:
|
||||
|
||||
1. **Resizable Panels** - Right pane (chat/config) is fixed at 384px, cannot be adjusted
|
||||
2. **Disabled Palette Items** - Model, Solver, Algorithm, Surrogate are grayed out and not draggable
|
||||
3. **Solver Type Selection** - Solver node should allow selection of solver type (NX Nastran, Python, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Resizable Panels
|
||||
|
||||
### Current State
|
||||
- Left sidebar: Fixed 240px (expanded) or 56px (collapsed)
|
||||
- Right panel (Chat/Config): Fixed 384px
|
||||
- Canvas: Takes remaining space
|
||||
|
||||
### Requirements
|
||||
- Users should be able to drag panel edges to resize
|
||||
- Minimum/maximum constraints for usability
|
||||
- Persist panel sizes in localStorage
|
||||
- Smooth resize with proper cursor feedback
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 7.1 Create Resizable Panel Hook
|
||||
```typescript
|
||||
// hooks/useResizablePanel.ts
|
||||
interface ResizablePanelState {
|
||||
width: number;
|
||||
isDragging: boolean;
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function useResizablePanel(
|
||||
key: string,
|
||||
defaultWidth: number,
|
||||
minWidth: number,
|
||||
maxWidth: number
|
||||
): ResizablePanelState
|
||||
```
|
||||
|
||||
#### 7.2 Update CanvasView Layout
|
||||
- Wrap left sidebar with resizer
|
||||
- Wrap right panel with resizer
|
||||
- Add visual drag handles (thin border that highlights on hover)
|
||||
- Add cursor: col-resize on hover
|
||||
|
||||
#### 7.3 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `hooks/useResizablePanel.ts` | NEW - Resize hook with localStorage persistence |
|
||||
| `pages/CanvasView.tsx` | Add resizers to left/right panels |
|
||||
| `components/canvas/ResizeHandle.tsx` | NEW - Visual resize handle component |
|
||||
|
||||
#### 7.4 Constraints
|
||||
| Panel | Min | Default | Max |
|
||||
|-------|-----|---------|-----|
|
||||
| Left (Palette/Files) | 200px | 240px | 400px |
|
||||
| Right (Chat/Config) | 280px | 384px | 600px |
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Enable All Palette Items
|
||||
|
||||
### Current State
|
||||
- Model, Solver, Algorithm, Surrogate are marked `canAdd: false`
|
||||
- They appear grayed out with "Auto-created" text
|
||||
- Users cannot drag them to canvas
|
||||
|
||||
### Problem Analysis
|
||||
These nodes were marked as "synthetic" because they're derived from:
|
||||
- **Model**: From `spec.model.sim.path`
|
||||
- **Solver**: From model's solution type
|
||||
- **Algorithm**: From `spec.optimization.algorithm`
|
||||
- **Surrogate**: From `spec.optimization.surrogate`
|
||||
|
||||
However, users need to:
|
||||
1. Add a Model node when creating a new study from scratch
|
||||
2. Configure the Solver type
|
||||
3. Choose an Algorithm
|
||||
4. Enable/configure Surrogate
|
||||
|
||||
### Solution: Make All Items Draggable
|
||||
|
||||
#### 8.1 Update NodePalette
|
||||
```typescript
|
||||
// All items should be draggable
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
label: 'Model',
|
||||
canAdd: true, // Changed from false
|
||||
description: 'NX/FEM model file',
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
canAdd: true, // Changed from false
|
||||
description: 'Analysis solver',
|
||||
},
|
||||
// ... etc
|
||||
];
|
||||
```
|
||||
|
||||
#### 8.2 Handle "Singleton" Nodes
|
||||
Some nodes should only exist once on the canvas:
|
||||
- Model (only one model per study)
|
||||
- Solver (one solver)
|
||||
- Algorithm (one algorithm config)
|
||||
- Surrogate (optional, one)
|
||||
|
||||
When user drags a singleton that already exists:
|
||||
- Option A: Show warning toast "Model already exists"
|
||||
- Option B: Select the existing node instead of creating new
|
||||
- **Recommended**: Option B (select existing)
|
||||
|
||||
#### 8.3 Update SpecRenderer Drop Handler
|
||||
```typescript
|
||||
const onDrop = useCallback(async (event: DragEvent) => {
|
||||
const type = event.dataTransfer.getData('application/reactflow');
|
||||
|
||||
// Check if singleton already exists
|
||||
const SINGLETON_TYPES = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
if (SINGLETON_TYPES.includes(type)) {
|
||||
const existingNode = nodes.find(n => n.type === type);
|
||||
if (existingNode) {
|
||||
selectNode(existingNode.id);
|
||||
showNotification(`${type} already exists - selected it`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new node...
|
||||
}, [...]);
|
||||
```
|
||||
|
||||
#### 8.4 Default Data for New Node Types
|
||||
```typescript
|
||||
function getDefaultNodeData(type: NodeType, position) {
|
||||
switch (type) {
|
||||
case 'model':
|
||||
return {
|
||||
name: 'Model',
|
||||
sim: { path: '', solver: 'nastran' },
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'solver':
|
||||
return {
|
||||
name: 'Solver',
|
||||
type: 'nxnastran', // Default solver
|
||||
solution_type: 'SOL101',
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'algorithm':
|
||||
return {
|
||||
name: 'Algorithm',
|
||||
type: 'TPE',
|
||||
budget: { max_trials: 100 },
|
||||
canvas_position: position,
|
||||
};
|
||||
case 'surrogate':
|
||||
return {
|
||||
name: 'Surrogate',
|
||||
enabled: false,
|
||||
model_type: 'MLP',
|
||||
min_trials: 20,
|
||||
canvas_position: position,
|
||||
};
|
||||
// ... existing cases
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.5 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `components/canvas/palette/NodePalette.tsx` | Set `canAdd: true` for all items |
|
||||
| `components/canvas/SpecRenderer.tsx` | Handle singleton logic in onDrop |
|
||||
| `lib/spec/converter.ts` | Ensure synthetic nodes have proper IDs |
|
||||
| `hooks/useSpecStore.ts` | Add model/solver/algorithm to addNode support |
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Solver Type Selection
|
||||
|
||||
### Current State
|
||||
- Solver node shows auto-detected solution type (SOL101, etc.)
|
||||
- No ability to change solver engine or configure it
|
||||
|
||||
### Requirements
|
||||
1. Allow selection of solver engine type
|
||||
2. Configure solution type
|
||||
3. Support future solver types
|
||||
|
||||
### Solver Types to Support
|
||||
|
||||
| Solver | Description | Status |
|
||||
|--------|-------------|--------|
|
||||
| `nxnastran` | NX Nastran (built-in) | Current |
|
||||
| `mscnastran` | MSC Nastran (external) | Future |
|
||||
| `python` | Python-based solver | Future |
|
||||
| `abaqus` | Abaqus (via Python API) | Future |
|
||||
| `ansys` | ANSYS (via Python API) | Future |
|
||||
|
||||
### Solution Types per Solver
|
||||
|
||||
**NX Nastran / MSC Nastran:**
|
||||
- SOL101 - Linear Static
|
||||
- SOL103 - Normal Modes
|
||||
- SOL105 - Buckling
|
||||
- SOL106 - Nonlinear Static
|
||||
- SOL111 - Frequency Response
|
||||
- SOL112 - Transient Response
|
||||
- SOL200 - Design Optimization
|
||||
|
||||
**Python Solver:**
|
||||
- Custom (user-defined)
|
||||
|
||||
### Schema Updates
|
||||
|
||||
#### 9.1 Update AtomizerSpec Types
|
||||
```typescript
|
||||
// types/atomizer-spec.ts
|
||||
|
||||
export type SolverEngine =
|
||||
| 'nxnastran'
|
||||
| 'mscnastran'
|
||||
| 'python'
|
||||
| 'abaqus'
|
||||
| 'ansys';
|
||||
|
||||
export type NastranSolutionType =
|
||||
| 'SOL101'
|
||||
| 'SOL103'
|
||||
| 'SOL105'
|
||||
| 'SOL106'
|
||||
| 'SOL111'
|
||||
| 'SOL112'
|
||||
| 'SOL200';
|
||||
|
||||
export interface SolverConfig {
|
||||
/** Solver engine type */
|
||||
engine: SolverEngine;
|
||||
|
||||
/** Solution type (for Nastran) */
|
||||
solution_type?: NastranSolutionType;
|
||||
|
||||
/** Custom solver script path (for Python solver) */
|
||||
script_path?: string;
|
||||
|
||||
/** Additional solver options */
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
sim?: {
|
||||
path: string;
|
||||
solver: SolverConfig; // Changed from just 'nastran' string
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.2 Update SolverNode Component
|
||||
```typescript
|
||||
// components/canvas/nodes/SolverNode.tsx
|
||||
|
||||
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{data.engine || 'nxnastran'}</span>
|
||||
<span className="text-xs text-dark-400">
|
||||
{data.solution_type || 'Auto-detect'}
|
||||
</span>
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.3 Solver Configuration Panel
|
||||
Add to `NodeConfigPanelV2.tsx`:
|
||||
|
||||
```typescript
|
||||
function SolverNodeConfig({ spec }: SpecConfigProps) {
|
||||
const { patchSpec } = useSpecStore();
|
||||
const solver = spec.model?.sim?.solver || { engine: 'nxnastran' };
|
||||
|
||||
const handleEngineChange = (engine: SolverEngine) => {
|
||||
patchSpec('model.sim.solver.engine', engine);
|
||||
};
|
||||
|
||||
const handleSolutionTypeChange = (type: NastranSolutionType) => {
|
||||
patchSpec('model.sim.solver.solution_type', type);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Solver Engine</label>
|
||||
<select
|
||||
value={solver.engine}
|
||||
onChange={(e) => handleEngineChange(e.target.value as SolverEngine)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="nxnastran">NX Nastran</option>
|
||||
<option value="mscnastran">MSC Nastran</option>
|
||||
<option value="python">Python Script</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(solver.engine === 'nxnastran' || solver.engine === 'mscnastran') && (
|
||||
<div>
|
||||
<label className={labelClass}>Solution Type</label>
|
||||
<select
|
||||
value={solver.solution_type || ''}
|
||||
onChange={(e) => handleSolutionTypeChange(e.target.value as NastranSolutionType)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Auto-detect from model</option>
|
||||
<option value="SOL101">SOL101 - Linear Static</option>
|
||||
<option value="SOL103">SOL103 - Normal Modes</option>
|
||||
<option value="SOL105">SOL105 - Buckling</option>
|
||||
<option value="SOL106">SOL106 - Nonlinear Static</option>
|
||||
<option value="SOL111">SOL111 - Frequency Response</option>
|
||||
<option value="SOL112">SOL112 - Transient Response</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{solver.engine === 'python' && (
|
||||
<div>
|
||||
<label className={labelClass}>Solver Script</label>
|
||||
<input
|
||||
type="text"
|
||||
value={solver.script_path || ''}
|
||||
onChange={(e) => patchSpec('model.sim.solver.script_path', e.target.value)}
|
||||
placeholder="/path/to/solver.py"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Python script that runs the analysis
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 9.4 Files to Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `types/atomizer-spec.ts` | Add SolverEngine, SolverConfig types |
|
||||
| `components/canvas/nodes/SolverNode.tsx` | Show engine and solution type |
|
||||
| `components/canvas/panels/NodeConfigPanelV2.tsx` | Add SolverNodeConfig |
|
||||
| `lib/canvas/schema.ts` | Update SolverNodeData |
|
||||
| Backend: `config/spec_models.py` | Add SolverConfig Pydantic model |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| Phase | Effort | Priority | Dependencies |
|
||||
|-------|--------|----------|--------------|
|
||||
| **7.1** Resizable Panel Hook | 2h | High | None |
|
||||
| **7.2** CanvasView Resizers | 2h | High | 7.1 |
|
||||
| **8.1** Enable Palette Items | 1h | High | None |
|
||||
| **8.2** Singleton Logic | 2h | High | 8.1 |
|
||||
| **8.3** Default Node Data | 1h | High | 8.2 |
|
||||
| **9.1** Schema Updates | 2h | Medium | None |
|
||||
| **9.2** SolverNode UI | 1h | Medium | 9.1 |
|
||||
| **9.3** Solver Config Panel | 2h | Medium | 9.1, 9.2 |
|
||||
|
||||
**Total Estimated Effort:** ~13 hours
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 7 (Resizable Panels)
|
||||
- [ ] Left panel can be resized between 200-400px
|
||||
- [ ] Right panel can be resized between 280-600px
|
||||
- [ ] Resize handles show cursor feedback
|
||||
- [ ] Panel sizes persist across page reload
|
||||
- [ ] Double-click on handle resets to default
|
||||
|
||||
### Phase 8 (Enable Palette Items)
|
||||
- [ ] All 8 node types are draggable from palette
|
||||
- [ ] Dragging singleton to canvas with existing node selects existing
|
||||
- [ ] Toast notification explains the behavior
|
||||
- [ ] New studies can start with empty canvas and add Model first
|
||||
|
||||
### Phase 9 (Solver Selection)
|
||||
- [ ] Solver node shows engine type (nxnastran, python, etc.)
|
||||
- [ ] Clicking solver node opens config panel
|
||||
- [ ] Can select solver engine from dropdown
|
||||
- [ ] Nastran solvers show solution type dropdown
|
||||
- [ ] Python solver shows script path input
|
||||
- [ ] Changes persist to atomizer_spec.json
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Additional Solver Support
|
||||
- ANSYS integration via pyANSYS
|
||||
- Abaqus integration via abaqus-python
|
||||
- OpenFOAM for CFD
|
||||
- Custom Python solvers with standardized interface
|
||||
|
||||
### Multi-Solver Workflows
|
||||
- Support for chained solvers (thermal → structural)
|
||||
- Co-simulation workflows
|
||||
- Parallel solver execution
|
||||
|
||||
### Algorithm Node Enhancement
|
||||
- Similar to Solver, allow algorithm selection
|
||||
- Show algorithm-specific parameters
|
||||
- Support custom algorithms
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
```bash
|
||||
# Phase 7
|
||||
git commit -m "feat: Add resizable panels to canvas view"
|
||||
|
||||
# Phase 8
|
||||
git commit -m "feat: Enable all palette items with singleton handling"
|
||||
|
||||
# Phase 9
|
||||
git commit -m "feat: Add solver type selection and configuration"
|
||||
```
|
||||
@@ -19,14 +19,14 @@ Atomizer is a structural optimization platform that enables engineers to optimiz
|
||||
|
||||
### Architecture Quality Score: **8.5/10**
|
||||
|
||||
| Aspect | Score | Notes |
|
||||
|--------|-------|-------|
|
||||
| Data Integrity | 9/10 | Single source of truth, hash-based conflict detection |
|
||||
| Type Safety | 9/10 | Pydantic models throughout backend |
|
||||
| Extensibility | 8/10 | Custom extractors, algorithms supported |
|
||||
| Performance | 8/10 | Optimistic updates, WebSocket streaming |
|
||||
| Maintainability | 8/10 | Clear separation of concerns |
|
||||
| Documentation | 7/10 | Good inline docs, needs more high-level guides |
|
||||
| Aspect | Score | Notes |
|
||||
| --------------- | ----- | ----------------------------------------------------- |
|
||||
| Data Integrity | 9/10 | Single source of truth, hash-based conflict detection |
|
||||
| Type Safety | 9/10 | Pydantic models throughout backend |
|
||||
| Extensibility | 8/10 | Custom extractors, algorithms supported |
|
||||
| Performance | 8/10 | Optimistic updates, WebSocket streaming |
|
||||
| Maintainability | 8/10 | Clear separation of concerns |
|
||||
| Documentation | 7/10 | Good inline docs, needs more high-level guides |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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:])
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"part_file": "ASSY_M1.prt",
|
||||
"part_path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
|
||||
"success": true,
|
||||
"error": null,
|
||||
"expressions": {
|
||||
"user": [
|
||||
{
|
||||
"name": "p7_CircularPattern_pattern_Circular_Dir_count",
|
||||
"value": 3.0,
|
||||
"rhs": "3",
|
||||
"units": null,
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p8_CircularPattern_pattern_Circular_Dir_offset_angle",
|
||||
"value": 120.0,
|
||||
"rhs": "120",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p66_x",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p68_z",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p67_y",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
{
|
||||
"name": "p14",
|
||||
"value": 0.0,
|
||||
"rhs": "0",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p64",
|
||||
"value": 120.0,
|
||||
"rhs": "120",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p9",
|
||||
"value": 10.0,
|
||||
"rhs": "10",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p10",
|
||||
"value": 240.0,
|
||||
"rhs": "240",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p11",
|
||||
"value": 1.0,
|
||||
"rhs": "1",
|
||||
"units": null,
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p12",
|
||||
"value": 10.0,
|
||||
"rhs": "10",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p13",
|
||||
"value": 0.0,
|
||||
"rhs": "0",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
}
|
||||
],
|
||||
"total_count": 12,
|
||||
"user_count": 5
|
||||
},
|
||||
"mass_properties": {
|
||||
"mass_kg": 0.0,
|
||||
"mass_g": 0.0,
|
||||
"volume_mm3": 0.0,
|
||||
"surface_area_mm2": 0.0,
|
||||
"center_of_gravity_mm": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"num_bodies": 0,
|
||||
"success": false
|
||||
},
|
||||
"materials": {
|
||||
"assigned": [],
|
||||
"available": [],
|
||||
"library": [],
|
||||
"pmm_error": "'NXOpen.Part' object has no attribute 'PhysicalMaterialManager'"
|
||||
},
|
||||
"bodies": {
|
||||
"solid_bodies": [],
|
||||
"sheet_bodies": [],
|
||||
"counts": {
|
||||
"solid": 0,
|
||||
"sheet": 0,
|
||||
"total": 0
|
||||
}
|
||||
},
|
||||
"attributes": [
|
||||
{
|
||||
"title": "NX_Arrangement",
|
||||
"type": "5",
|
||||
"value": "Arrangement 1"
|
||||
},
|
||||
{
|
||||
"title": "NX_ComponentGroup",
|
||||
"type": "5",
|
||||
"value": "AllComponents"
|
||||
},
|
||||
{
|
||||
"title": "NX_ReferenceSet",
|
||||
"type": "5",
|
||||
"value": "Empty"
|
||||
},
|
||||
{
|
||||
"title": "NX_MaterialMissingAssignments",
|
||||
"type": "5",
|
||||
"value": "TRUE"
|
||||
},
|
||||
{
|
||||
"title": "NX_MaterialMultipleAssigned",
|
||||
"type": "5",
|
||||
"value": "FALSE"
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"features": {
|
||||
"total_count": 0,
|
||||
"by_type": {},
|
||||
"first_10": []
|
||||
},
|
||||
"datums": {
|
||||
"planes": [],
|
||||
"csys": [],
|
||||
"axes": []
|
||||
},
|
||||
"units": {
|
||||
"base_units": {
|
||||
"Length": "MilliMeter",
|
||||
"Mass": "Kilogram",
|
||||
"Time": "Second",
|
||||
"Temperature": "Kelvin",
|
||||
"Angle": "Radian",
|
||||
"Area": "SquareMilliMeter",
|
||||
"Volume": "CubicMilliMeter",
|
||||
"Force": "MilliNewton",
|
||||
"Pressure": "PressureMilliNewtonPerSquareMilliMeter"
|
||||
},
|
||||
"system": "Metric (mm)"
|
||||
},
|
||||
"linked_parts": {
|
||||
"loaded_parts": [
|
||||
{
|
||||
"name": "M1_Blank",
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\M1_Blank.prt",
|
||||
"leaf_name": "M1_Blank"
|
||||
},
|
||||
{
|
||||
"name": "ASSY_M1",
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
|
||||
"leaf_name": "ASSY_M1"
|
||||
}
|
||||
],
|
||||
"fem_parts": [],
|
||||
"sim_parts": [],
|
||||
"idealized_parts": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"part_file": "ASSY_M1.prt",
|
||||
"part_path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
|
||||
"success": true,
|
||||
"error": null,
|
||||
"expressions": {
|
||||
"user": [
|
||||
{
|
||||
"name": "p7_CircularPattern_pattern_Circular_Dir_count",
|
||||
"value": 3.0,
|
||||
"rhs": "3",
|
||||
"units": null,
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p8_CircularPattern_pattern_Circular_Dir_offset_angle",
|
||||
"value": 120.0,
|
||||
"rhs": "120",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p66_x",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p68_z",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p67_y",
|
||||
"value": 0.0,
|
||||
"rhs": "0.00000000000",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
{
|
||||
"name": "p14",
|
||||
"value": 0.0,
|
||||
"rhs": "0",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p64",
|
||||
"value": 120.0,
|
||||
"rhs": "120",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p9",
|
||||
"value": 10.0,
|
||||
"rhs": "10",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p10",
|
||||
"value": 240.0,
|
||||
"rhs": "240",
|
||||
"units": "Degrees",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p11",
|
||||
"value": 1.0,
|
||||
"rhs": "1",
|
||||
"units": null,
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p12",
|
||||
"value": 10.0,
|
||||
"rhs": "10",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
},
|
||||
{
|
||||
"name": "p13",
|
||||
"value": 0.0,
|
||||
"rhs": "0",
|
||||
"units": "MilliMeter",
|
||||
"type": "Number"
|
||||
}
|
||||
],
|
||||
"total_count": 12,
|
||||
"user_count": 5
|
||||
},
|
||||
"mass_properties": {
|
||||
"mass_kg": 0.0,
|
||||
"mass_g": 0.0,
|
||||
"volume_mm3": 0.0,
|
||||
"surface_area_mm2": 0.0,
|
||||
"center_of_gravity_mm": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"num_bodies": 0,
|
||||
"success": false
|
||||
},
|
||||
"materials": {
|
||||
"assigned": [],
|
||||
"available": [],
|
||||
"library": [],
|
||||
"pmm_error": "'NXOpen.Part' object has no attribute 'PhysicalMaterialManager'"
|
||||
},
|
||||
"bodies": {
|
||||
"solid_bodies": [],
|
||||
"sheet_bodies": [],
|
||||
"counts": {
|
||||
"solid": 0,
|
||||
"sheet": 0,
|
||||
"total": 0
|
||||
}
|
||||
},
|
||||
"attributes": [
|
||||
{
|
||||
"title": "NX_Arrangement",
|
||||
"type": "5",
|
||||
"value": "Arrangement 1"
|
||||
},
|
||||
{
|
||||
"title": "NX_ComponentGroup",
|
||||
"type": "5",
|
||||
"value": "AllComponents"
|
||||
},
|
||||
{
|
||||
"title": "NX_ReferenceSet",
|
||||
"type": "5",
|
||||
"value": "Empty"
|
||||
},
|
||||
{
|
||||
"title": "NX_MaterialMissingAssignments",
|
||||
"type": "5",
|
||||
"value": "TRUE"
|
||||
},
|
||||
{
|
||||
"title": "NX_MaterialMultipleAssigned",
|
||||
"type": "5",
|
||||
"value": "FALSE"
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"features": {
|
||||
"total_count": 0,
|
||||
"by_type": {},
|
||||
"first_10": []
|
||||
},
|
||||
"datums": {
|
||||
"planes": [],
|
||||
"csys": [],
|
||||
"axes": []
|
||||
},
|
||||
"units": {
|
||||
"base_units": {
|
||||
"Length": "MilliMeter",
|
||||
"Mass": "Kilogram",
|
||||
"Time": "Second",
|
||||
"Temperature": "Kelvin",
|
||||
"Angle": "Radian",
|
||||
"Area": "SquareMilliMeter",
|
||||
"Volume": "CubicMilliMeter",
|
||||
"Force": "MilliNewton",
|
||||
"Pressure": "PressureMilliNewtonPerSquareMilliMeter"
|
||||
},
|
||||
"system": "Metric (mm)"
|
||||
},
|
||||
"linked_parts": {
|
||||
"loaded_parts": [
|
||||
{
|
||||
"name": "M1_Blank",
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\M1_Blank.prt",
|
||||
"leaf_name": "M1_Blank"
|
||||
},
|
||||
{
|
||||
"name": "ASSY_M1",
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
|
||||
"leaf_name": "ASSY_M1"
|
||||
}
|
||||
],
|
||||
"fem_parts": [],
|
||||
"sim_parts": [],
|
||||
"idealized_parts": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"blank_backface_angle": 4.0,
|
||||
"lateral_inner_angle": 31.93,
|
||||
"whiffle_p1": 390.0,
|
||||
"whiffle_p2": 135.0,
|
||||
"whiffle_p3": 80.0,
|
||||
"mirror_face_thickness": 15.0,
|
||||
"blank_mass": 66.7514321518352,
|
||||
"lateral_second_row_angle": 10.0,
|
||||
"p1049_x": 0.0,
|
||||
"blank_backface_max_radius": 582.0,
|
||||
"p1051_z": 0.0,
|
||||
"rib_thickness_lataral": 10.0,
|
||||
"hole_count": 10.0,
|
||||
"Pocket_Radius": 10.05,
|
||||
"lateral_closeness": 7.89,
|
||||
"offset_whiffle": 35.0,
|
||||
"inner_circular_rib_dia": 537.86,
|
||||
"whiffle_min": 56.65,
|
||||
"Pattern_p5610": 60.0,
|
||||
"whiffle_triangle_closeness": 69.24,
|
||||
"beam_face_thickness": 20.0,
|
||||
"offset_lateral_support_contact": 5.0,
|
||||
"Pattern_p2656": 360.0,
|
||||
"rib_thickness_lateral_truss": 12.06,
|
||||
"whiffle_max": 8.0,
|
||||
"outer_post_distance": 551.7504884773748,
|
||||
"Pattern_p2653": 3.0,
|
||||
"Pattern_p2654": 120.0,
|
||||
"Pattern_p2883": 3.0,
|
||||
"Pattern_p2884": 120.0,
|
||||
"Pattern_p2886": 360.0,
|
||||
"ribs_circular_thk": 6.81,
|
||||
"support_cone_angle": 0.0,
|
||||
"rib_thickness": 8.07,
|
||||
"rib_lin_1": 60.0,
|
||||
"rib_lin_2": 80.0,
|
||||
"rib_lin_4": 80.0,
|
||||
"in_between_u": 0.5,
|
||||
"beam_half_core_thickness": 20.0,
|
||||
"lateral_middle_pivot": 21.07,
|
||||
"Pattern_p5609": 6.0,
|
||||
"lateral_inner_u": 0.3,
|
||||
"center_thickness": 85.0,
|
||||
"rib_pocket_bottom_radius": 10.0,
|
||||
"lateral_outer_pivot": 8.615999999999998,
|
||||
"Pattern_p5612": 360.0,
|
||||
"Pattern_p3829": 3.0,
|
||||
"Pattern_p3830": 120.0,
|
||||
"Pattern_p3832": 360.0,
|
||||
"p1050_y": 0.0,
|
||||
"holes_diameter": 400.0,
|
||||
"lateral_outer_angle": 10.77,
|
||||
"vertical_support_diameter_seat_offset": 10.0,
|
||||
"lateral_outer_u": 0.8,
|
||||
"vertical_support_seat_depth": 20.0,
|
||||
"lateral_inner_pivot": 9.578999999999997
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"$schema": "Atomizer M1 Mirror Cost Reduction - Lateral Supports Optimization",
|
||||
"study_name": "m1_mirror_cost_reduction_lateral",
|
||||
"study_tag": "CMA-ES-100",
|
||||
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u) for cost reduction model. Focus on WFE and MFG only - no mass objective.",
|
||||
"business_context": {
|
||||
"purpose": "Optimize lateral support geometry using new U-joint parameterization on cost reduction model",
|
||||
"benefit": "Improved lateral support performance with cleaner parameterization",
|
||||
"goal": "Minimize WFE at 40/60 deg and MFG at 90 deg"
|
||||
},
|
||||
"optimization": {
|
||||
"algorithm": "CMA-ES",
|
||||
"n_trials": 100,
|
||||
"n_startup_trials": 0,
|
||||
"sigma0": 0.3,
|
||||
"notes": "CMA-ES is optimal for 5D continuous optimization - fast convergence, robust"
|
||||
},
|
||||
"extraction_method": {
|
||||
"type": "zernike_opd",
|
||||
"class": "ZernikeOPDExtractor",
|
||||
"method": "extract_relative",
|
||||
"inner_radius": 135.75,
|
||||
"description": "OPD-based Zernike with ANNULAR aperture (271.5mm central hole excluded)"
|
||||
},
|
||||
"design_variables": [
|
||||
{
|
||||
"name": "lateral_inner_u",
|
||||
"expression_name": "lateral_inner_u",
|
||||
"min": 0.2,
|
||||
"max": 0.95,
|
||||
"baseline": 0.3,
|
||||
"units": "unitless",
|
||||
"enabled": true,
|
||||
"notes": "U-joint ratio for inner lateral support (replaces lateral_inner_pivot)"
|
||||
},
|
||||
{
|
||||
"name": "lateral_outer_u",
|
||||
"expression_name": "lateral_outer_u",
|
||||
"min": 0.2,
|
||||
"max": 0.95,
|
||||
"baseline": 0.8,
|
||||
"units": "unitless",
|
||||
"enabled": true,
|
||||
"notes": "U-joint ratio for outer lateral support (replaces lateral_outer_pivot)"
|
||||
},
|
||||
{
|
||||
"name": "lateral_middle_pivot",
|
||||
"expression_name": "lateral_middle_pivot",
|
||||
"min": 15.0,
|
||||
"max": 27.0,
|
||||
"baseline": 21.07,
|
||||
"units": "mm",
|
||||
"enabled": true,
|
||||
"notes": "Middle pivot position on lateral support"
|
||||
},
|
||||
{
|
||||
"name": "lateral_inner_angle",
|
||||
"expression_name": "lateral_inner_angle",
|
||||
"min": 25.0,
|
||||
"max": 35.0,
|
||||
"baseline": 31.93,
|
||||
"units": "degrees",
|
||||
"enabled": true,
|
||||
"notes": "Inner lateral support angle"
|
||||
},
|
||||
{
|
||||
"name": "lateral_outer_angle",
|
||||
"expression_name": "lateral_outer_angle",
|
||||
"min": 8.0,
|
||||
"max": 17.0,
|
||||
"baseline": 10.77,
|
||||
"units": "degrees",
|
||||
"enabled": true,
|
||||
"notes": "Outer lateral support angle"
|
||||
}
|
||||
],
|
||||
"fixed_parameters": [],
|
||||
"constraints": [
|
||||
{
|
||||
"name": "blank_mass_max",
|
||||
"type": "hard",
|
||||
"expression": "mass_kg <= 120.0",
|
||||
"description": "Maximum blank mass constraint (still enforced even though mass not optimized)",
|
||||
"penalty_weight": 1000.0
|
||||
}
|
||||
],
|
||||
"objectives": [
|
||||
{
|
||||
"name": "wfe_40_20",
|
||||
"description": "Filtered RMS WFE at 40 deg relative to 20 deg (annular)",
|
||||
"direction": "minimize",
|
||||
"weight": 6.0,
|
||||
"target": 4.0,
|
||||
"units": "nm"
|
||||
},
|
||||
{
|
||||
"name": "wfe_60_20",
|
||||
"description": "Filtered RMS WFE at 60 deg relative to 20 deg (annular)",
|
||||
"direction": "minimize",
|
||||
"weight": 5.0,
|
||||
"target": 10.0,
|
||||
"units": "nm"
|
||||
},
|
||||
{
|
||||
"name": "mfg_90",
|
||||
"description": "Manufacturing deformation at 90 deg polishing (J1-J3 filtered, annular)",
|
||||
"direction": "minimize",
|
||||
"weight": 3.0,
|
||||
"target": 20.0,
|
||||
"units": "nm"
|
||||
}
|
||||
],
|
||||
"weighted_sum_formula": "6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90",
|
||||
"zernike_settings": {
|
||||
"n_modes": 50,
|
||||
"filter_low_orders": 4,
|
||||
"displacement_unit": "mm",
|
||||
"subcases": ["1", "2", "3", "4"],
|
||||
"subcase_labels": {"1": "90deg", "2": "20deg", "3": "40deg", "4": "60deg"},
|
||||
"reference_subcase": "2",
|
||||
"method": "opd",
|
||||
"inner_radius": 135.75
|
||||
},
|
||||
"nx_settings": {
|
||||
"nx_install_path": "C:\\Program Files\\Siemens\\DesigncenterNX2512",
|
||||
"sim_file": "ASSY_M1_assyfem1_sim1.sim",
|
||||
"solution_name": "Solution 1",
|
||||
"op2_pattern": "*-solution_1.op2",
|
||||
"simulation_timeout_s": 600
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"study_name": "m1_mirror_cost_reduction_lateral",
|
||||
"algorithm": "CMA-ES",
|
||||
"extraction_method": "ZernikeOPD_Annular",
|
||||
"inner_radius_mm": 135.75,
|
||||
"objectives_note": "Mass NOT in objective - WFE only",
|
||||
"total_trials": 169,
|
||||
"feasible_trials": 167,
|
||||
"best_trial": {
|
||||
"number": 163,
|
||||
"weighted_sum": 181.1220151783071,
|
||||
"objectives": {
|
||||
"wfe_40_20": 5.901179945313834,
|
||||
"wfe_60_20": 13.198682506114679,
|
||||
"mfg_90": 26.573840991950224,
|
||||
"mass_kg": 96.75011491846891
|
||||
},
|
||||
"params": {
|
||||
"lateral_inner_u": 0.32248417341983515,
|
||||
"lateral_outer_u": 0.9038210727913156,
|
||||
"lateral_middle_pivot": 21.25398896032501,
|
||||
"lateral_inner_angle": 30.182447933329243,
|
||||
"lateral_outer_angle": 15.08932828662093
|
||||
},
|
||||
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\2_iterations\\iter164"
|
||||
},
|
||||
"timestamp": "2026-01-14T17:59:38.649254"
|
||||
}
|
||||
Binary file not shown.
136
studies/M1_Mirror/m1_mirror_cost_reduction_lateral/README.md
Normal file
136
studies/M1_Mirror/m1_mirror_cost_reduction_lateral/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# M1 Mirror Cost Reduction - Lateral Supports Optimization
|
||||
|
||||
> See [../README.md](../README.md) for project overview and optical specifications.
|
||||
|
||||
## Study Overview
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Study Name** | m1_mirror_cost_reduction_lateral |
|
||||
| **Algorithm** | CMA-ES |
|
||||
| **Status** | Ready to run |
|
||||
| **Created** | 2026-01-13 |
|
||||
| **Trials** | 100 planned |
|
||||
| **Focus** | Lateral support geometry only |
|
||||
|
||||
## Purpose
|
||||
|
||||
Optimize **lateral support parameters only** for the COST REDUCTION model using the new U-joint parameterization:
|
||||
- `lateral_inner_u` and `lateral_outer_u` replace the old `lateral_inner_pivot` and `lateral_outer_pivot`
|
||||
- All other parameters (whiffle, ribs, thickness) are **fixed at baseline values**
|
||||
- **Mass is NOT an objective** - only WFE and MFG are optimized
|
||||
|
||||
## Design Variables (5)
|
||||
|
||||
| Variable | Min | Max | Baseline | Units | Notes |
|
||||
|----------|-----|-----|----------|-------|-------|
|
||||
| `lateral_inner_u` | 0.2 | 0.95 | TBD | unitless | U-joint ratio for inner lateral (NEW) |
|
||||
| `lateral_outer_u` | 0.2 | 0.95 | TBD | unitless | U-joint ratio for outer lateral (NEW) |
|
||||
| `lateral_middle_pivot` | 15.0 | 27.0 | TBD | mm | Middle pivot position |
|
||||
| `lateral_inner_angle` | 25.0 | 35.0 | TBD | degrees | Inner lateral angle |
|
||||
| `lateral_outer_angle` | 8.0 | 17.0 | TBD | degrees | Outer lateral angle |
|
||||
|
||||
**Note:** Baselines marked TBD will be updated after model introspection.
|
||||
|
||||
## Objectives
|
||||
|
||||
| Objective | Weight | Target | Description |
|
||||
|-----------|--------|--------|-------------|
|
||||
| `wfe_40_20` | 6.0 | 4.0 nm | Filtered RMS WFE at 40 deg relative to 20 deg |
|
||||
| `wfe_60_20` | 5.0 | 10.0 nm | Filtered RMS WFE at 60 deg relative to 20 deg |
|
||||
| `mfg_90` | 3.0 | 20.0 nm | Manufacturing deformation at 90 deg polishing |
|
||||
|
||||
**Weighted Sum Formula:** `6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90`
|
||||
|
||||
**Note:** Mass is **NOT** in the objective function. A hard constraint (mass <= 120 kg) is still enforced.
|
||||
|
||||
## Fixed Parameters (Baked in Model)
|
||||
|
||||
All non-lateral parameters are fixed at the model's current values. These are **not pushed** during optimization - the model already contains the correct values.
|
||||
|
||||
## Why CMA-ES?
|
||||
|
||||
| Factor | This Problem | Why CMA-ES |
|
||||
|--------|--------------|------------|
|
||||
| Dimensions | 5 variables | CMA-ES optimal for 5-50D |
|
||||
| Variable type | All continuous | CMA-ES designed for continuous |
|
||||
| Landscape | Smooth (physics-based) | CMA-ES exploits gradient structure |
|
||||
| Correlation | Lateral params likely correlated | CMA-ES learns correlations automatically |
|
||||
| Convergence | 100 trials budget | CMA-ES converges 2-3x faster than TPE |
|
||||
|
||||
### CMA-ES Settings
|
||||
- `sigma0`: 0.3 (30% of range for initial exploration)
|
||||
- `restart_strategy`: IPOP (restarts with larger population if stuck)
|
||||
- `seed`: 42
|
||||
|
||||
## Model Files
|
||||
|
||||
Place the following files in `1_setup/model/`:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ASSY_M1_assyfem1_sim1.sim` | Simulation file |
|
||||
| `*.fem` | FEM mesh files |
|
||||
| `*.prt` | Geometry parts |
|
||||
| `*_i.prt` | Idealized part (critical for mesh updates) |
|
||||
|
||||
## Extraction Method
|
||||
|
||||
- **Type:** ZernikeOPDExtractor with ANNULAR aperture
|
||||
- **Inner radius:** 135.75 mm (271.5 mm central hole excluded)
|
||||
- **Zernike modes:** 50
|
||||
- **Filter orders:** J1-J4 removed for WFE, J1-J3 for MFG
|
||||
- **Subcases:** 90 deg (1), 20 deg (2), 40 deg (3), 60 deg (4)
|
||||
- **Reference:** 20 deg (subcase 2)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Single test trial
|
||||
python run_optimization.py --test
|
||||
|
||||
# Full optimization (100 trials) - auto-launches dashboard
|
||||
python run_optimization.py --start
|
||||
|
||||
# Custom trial count
|
||||
python run_optimization.py --start --trials 50
|
||||
|
||||
# Resume interrupted run
|
||||
python run_optimization.py --start --resume
|
||||
|
||||
# Without dashboard
|
||||
python run_optimization.py --start --no-dashboard
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
m1_mirror_cost_reduction_lateral/
|
||||
|-- 1_setup/
|
||||
| |-- model/ # NX model files (user to add)
|
||||
| `-- optimization_config.json # Study configuration
|
||||
|-- 2_iterations/ # FEA iteration folders
|
||||
|-- 3_results/ # Results database & summaries
|
||||
| |-- study.db # Optuna SQLite database
|
||||
| |-- optimization.log # Run log
|
||||
| `-- optimization_summary.json # Final results
|
||||
|-- run_optimization.py # Main optimization script
|
||||
|-- README.md # This file
|
||||
`-- STUDY_REPORT.md # Results template
|
||||
```
|
||||
|
||||
## Setup Checklist
|
||||
|
||||
- [ ] Copy model files to `1_setup/model/`
|
||||
- [ ] Run introspection to get baseline values
|
||||
- [ ] Update `optimization_config.json` with correct baselines
|
||||
- [ ] Run `--test` to verify setup
|
||||
- [ ] Run full optimization
|
||||
|
||||
## Results
|
||||
|
||||
*Study not yet run. Results will be updated after optimization completes.*
|
||||
|
||||
## References
|
||||
|
||||
- Sister study: [m1_mirror_flatback_lateral](../m1_mirror_flatback_lateral/) (same approach, flat back model)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Study Report: m1_mirror_cost_reduction_lateral
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Study Name** | m1_mirror_cost_reduction_lateral |
|
||||
| **Algorithm** | CMA-ES |
|
||||
| **Trials Completed** | _pending_ |
|
||||
| **Best Weighted Sum** | _pending_ |
|
||||
| **Constraint Satisfaction** | _pending_ |
|
||||
|
||||
## Optimization Focus
|
||||
|
||||
This study optimizes **lateral support parameters only** for the COST REDUCTION model using the new U-joint parameterization:
|
||||
- `lateral_inner_u`, `lateral_outer_u` (NEW - replace pivot params)
|
||||
- `lateral_middle_pivot`, `lateral_inner_angle`, `lateral_outer_angle`
|
||||
|
||||
**Key Difference**: Mass is NOT an objective - only WFE and MFG are optimized.
|
||||
|
||||
## Optimization Progress
|
||||
|
||||
_To be filled after optimization run_
|
||||
|
||||
### Convergence Plot
|
||||
|
||||
_Insert convergence plot here_
|
||||
|
||||
### Parameter Evolution
|
||||
|
||||
_Insert parameter evolution plots here_
|
||||
|
||||
## Best Designs Found
|
||||
|
||||
### Top 5 Designs
|
||||
|
||||
| Rank | Trial | WS | WFE 40/20 | WFE 60/20 | MFG 90 | Mass |
|
||||
|------|-------|-------|-----------|-----------|--------|------|
|
||||
| 1 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
|
||||
| 2 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
|
||||
| 3 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
|
||||
| 4 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
|
||||
| 5 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
|
||||
|
||||
### Best Design Parameters
|
||||
|
||||
| Parameter | Baseline | Best | Change |
|
||||
|-----------|----------|------|--------|
|
||||
| lateral_inner_u | _TBD_ | _pending_ | _pending_ |
|
||||
| lateral_outer_u | _TBD_ | _pending_ | _pending_ |
|
||||
| lateral_middle_pivot | _TBD_ | _pending_ | _pending_ |
|
||||
| lateral_inner_angle | _TBD_ | _pending_ | _pending_ |
|
||||
| lateral_outer_angle | _TBD_ | _pending_ | _pending_ |
|
||||
|
||||
## Parameter Sensitivity
|
||||
|
||||
_To be filled after analysis_
|
||||
|
||||
### Most Influential Parameters
|
||||
|
||||
1. _pending_
|
||||
2. _pending_
|
||||
3. _pending_
|
||||
|
||||
### Parameter Correlations
|
||||
|
||||
_Insert correlation analysis_
|
||||
|
||||
## Comparison to Baseline
|
||||
|
||||
| Metric | Baseline | Best | Improvement |
|
||||
|--------|----------|------|-------------|
|
||||
| Weighted Sum | _pending_ | _pending_ | _pending_ |
|
||||
| WFE 40/20 | _pending_ | _pending_ | _pending_ |
|
||||
| WFE 60/20 | _pending_ | _pending_ | _pending_ |
|
||||
| MFG 90 | _pending_ | _pending_ | _pending_ |
|
||||
|
||||
## Comparison to Flat Back Lateral Study
|
||||
|
||||
| Metric | Flat Back | Cost Reduction | Difference |
|
||||
|--------|-----------|----------------|------------|
|
||||
| Best WS | _pending_ | _pending_ | _pending_ |
|
||||
| Best WFE 40/20 | _pending_ | _pending_ | _pending_ |
|
||||
| Best WFE 60/20 | _pending_ | _pending_ | _pending_ |
|
||||
|
||||
## Key Learnings
|
||||
|
||||
_To be filled after analysis_
|
||||
|
||||
1. _pending_
|
||||
2. _pending_
|
||||
3. _pending_
|
||||
|
||||
## Recommendations
|
||||
|
||||
_To be filled after analysis_
|
||||
|
||||
### For Next Study
|
||||
|
||||
- [ ] _pending_
|
||||
|
||||
### For Production
|
||||
|
||||
- [ ] _pending_
|
||||
|
||||
## Appendix
|
||||
|
||||
### Run Configuration
|
||||
|
||||
```json
|
||||
Algorithm: CMA-ES
|
||||
Trials: 100
|
||||
sigma0: 0.3
|
||||
restart_strategy: IPOP
|
||||
```
|
||||
|
||||
### Files Generated
|
||||
|
||||
- `3_results/study.db` - Optuna database
|
||||
- `3_results/optimization.log` - Run log
|
||||
- `3_results/optimization_summary.json` - Final results
|
||||
- `3_results/best_design_archive/` - Archived best designs
|
||||
|
||||
---
|
||||
|
||||
*Report generated: _pending_*
|
||||
@@ -2,9 +2,9 @@
|
||||
"meta": {
|
||||
"version": "2.0",
|
||||
"created": "2026-01-17T15:35:12.024432Z",
|
||||
"modified": "2026-01-17T16:33:51.000000Z",
|
||||
"modified": "2026-01-20T20:05:28.197219Z",
|
||||
"created_by": "migration",
|
||||
"modified_by": "claude",
|
||||
"modified_by": "test",
|
||||
"study_name": "m1_mirror_cost_reduction_lateral",
|
||||
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u) for cost reduction model. Focus on WFE and MFG only - no mass objective.",
|
||||
"tags": [
|
||||
@@ -151,6 +151,38 @@
|
||||
"x": 50,
|
||||
"y": 580
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variable_1768938898079",
|
||||
"expression_name": "expr_1768938898079",
|
||||
"type": "continuous",
|
||||
"bounds": {
|
||||
"min": 0,
|
||||
"max": 1
|
||||
},
|
||||
"baseline": 0.5,
|
||||
"enabled": true,
|
||||
"canvas_position": {
|
||||
"x": -185.06035488622524,
|
||||
"y": 91.62521000204346
|
||||
},
|
||||
"id": "dv_008"
|
||||
},
|
||||
{
|
||||
"name": "test_dv",
|
||||
"expression_name": "test_expr",
|
||||
"type": "continuous",
|
||||
"bounds": {
|
||||
"min": 0,
|
||||
"max": 1
|
||||
},
|
||||
"baseline": 0.5,
|
||||
"enabled": true,
|
||||
"id": "dv_009",
|
||||
"canvas_position": {
|
||||
"x": 50,
|
||||
"y": 680
|
||||
}
|
||||
}
|
||||
],
|
||||
"extractors": [
|
||||
@@ -204,16 +236,12 @@
|
||||
},
|
||||
{
|
||||
"id": "ext_003",
|
||||
"name": "Volume Extractor",
|
||||
"type": "custom",
|
||||
"name": "Volume Extractor custom",
|
||||
"type": "custom_function",
|
||||
"builtin": false,
|
||||
"config": {
|
||||
"density_kg_m3": 2530.0
|
||||
},
|
||||
"custom_function": {
|
||||
"name": "extract_volume",
|
||||
"code": "def extract_volume(trial_dir, config, context):\n \"\"\"\n Extract volume from mass using material density.\n Volume = Mass / Density\n \n For Zerodur glass-ceramic: density ~ 2530 kg/m³\n \"\"\"\n import json\n from pathlib import Path\n \n # Get mass from the mass extractor results\n results_file = Path(trial_dir) / 'results.json'\n if results_file.exists():\n with open(results_file) as f:\n results = json.load(f)\n mass_kg = results.get('mass_kg', 0)\n else:\n # If no results yet, try to get from context\n mass_kg = context.get('mass_kg', 0)\n \n density = config.get('density_kg_m3', 2530.0) # Zerodur default\n \n # Volume in m³\n volume_m3 = mass_kg / density if density > 0 else 0\n \n # Also calculate in liters for convenience (1 m³ = 1000 L)\n volume_liters = volume_m3 * 1000\n \n return {\n 'volume_m3': volume_m3,\n 'volume_liters': volume_liters\n }\n"
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "volume_m3",
|
||||
@@ -227,7 +255,55 @@
|
||||
"canvas_position": {
|
||||
"x": 740,
|
||||
"y": 400
|
||||
},
|
||||
"function": {
|
||||
"name": "extract_volume",
|
||||
"module": null,
|
||||
"signature": null,
|
||||
"source_code": "\"\"\"Extract modal mass matrix from Nastran OP2 file\"\"\"\n\nfrom pyNastran.op2.op2 import OP2\nimport numpy as np\n\ndef extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:\n \"\"\"\n Extract modal mass matrix from Nastran modal analysis results.\n \n The modal mass matrix (generalized mass) is typically diagonal for\n mass-normalized modes, with each diagonal entry representing the\n effective mass participation for that mode.\n \n Returns:\n dict with keys:\n - modal_mass_1, modal_mass_2, modal_mass_3: First three modal masses\n - total_modal_mass: Sum of all modal masses\n - modal_mass_matrix: Full diagonal modal mass array (as list)\n \"\"\"\n op2 = OP2()\n op2.read_op2(op2_path)\n \n # Initialize outputs\n modal_mass_1 = 0.0\n modal_mass_2 = 0.0\n modal_mass_3 = 0.0\n total_modal_mass = 0.0\n modal_mass_matrix = []\n \n try:\n # Method 1: Check for modal participation factors / effective mass\n # This is stored in op2.modal_contribution if available\n if hasattr(op2, 'modal_contribution') and op2.modal_contribution:\n mc = op2.modal_contribution\n if subcase_id in mc:\n modal_data = mc[subcase_id]\n if hasattr(modal_data, 'effective_mass'):\n eff_mass = modal_data.effective_mass\n modal_mass_matrix = eff_mass.tolist() if hasattr(eff_mass, 'tolist') else list(eff_mass)\n \n # Method 2: Check eigenvalues for generalized mass\n # pyNastran stores generalized mass in eigenvalues object\n if not modal_mass_matrix and subcase_id in op2.eigenvalues:\n eig = op2.eigenvalues[subcase_id]\n \n # Generalized mass is typically stored as 'generalized_mass' attribute\n if hasattr(eig, 'generalized_mass'):\n gen_mass = eig.generalized_mass\n modal_mass_matrix = gen_mass.tolist() if hasattr(gen_mass, 'tolist') else list(gen_mass)\n \n # Alternative: mass-normalized modes have unit modal mass\n # Check the mode_cycle attribute for mass normalization info\n elif hasattr(eig, 'mass'):\n mass = eig.mass\n modal_mass_matrix = mass.tolist() if hasattr(mass, 'tolist') else list(mass)\n \n # Method 3: For mass-normalized eigenvectors, modal mass = 1.0\n # Check if eigenvectors exist and compute modal mass from them\n if not modal_mass_matrix and subcase_id in op2.eigenvectors:\n eigvec = op2.eigenvectors[subcase_id]\n n_modes = eigvec.data.shape[1] if len(eigvec.data.shape) > 1 else 1\n # For mass-normalized modes, modal mass is unity\n modal_mass_matrix = [1.0] * n_modes\n \n # Extract individual modal masses\n if modal_mass_matrix:\n if len(modal_mass_matrix) >= 1:\n modal_mass_1 = float(modal_mass_matrix[0])\n if len(modal_mass_matrix) >= 2:\n modal_mass_2 = float(modal_mass_matrix[1])\n if len(modal_mass_matrix) >= 3:\n modal_mass_3 = float(modal_mass_matrix[2])\n total_modal_mass = float(sum(modal_mass_matrix))\n \n except Exception as e:\n # Log error but return zeros gracefully\n print(f\"Warning: Could not extract modal mass matrix: {e}\")\n \n return {\n 'modal_mass_1': modal_mass_1,\n 'modal_mass_2': modal_mass_2,\n 'modal_mass_3': modal_mass_3,\n 'total_modal_mass': total_modal_mass,\n 'modal_mass_matrix': modal_mass_matrix,\n }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "extractor_1768938758443",
|
||||
"type": "custom_function",
|
||||
"builtin": false,
|
||||
"enabled": true,
|
||||
"function": {
|
||||
"name": "extract",
|
||||
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"metric": "custom"
|
||||
}
|
||||
],
|
||||
"canvas_position": {
|
||||
"x": 522.740988960073,
|
||||
"y": 560.0208026883463
|
||||
},
|
||||
"id": "ext_005"
|
||||
},
|
||||
{
|
||||
"name": "extractor_1768938897219",
|
||||
"type": "custom_function",
|
||||
"builtin": false,
|
||||
"enabled": true,
|
||||
"function": {
|
||||
"name": "extract",
|
||||
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"metric": "custom"
|
||||
}
|
||||
],
|
||||
"canvas_position": {
|
||||
"x": -197.5451097726711,
|
||||
"y": 262.2501934501369
|
||||
},
|
||||
"id": "ext_004"
|
||||
}
|
||||
],
|
||||
"objectives": [
|
||||
@@ -374,10 +450,6 @@
|
||||
"source": "ext_001",
|
||||
"target": "obj_003"
|
||||
},
|
||||
{
|
||||
"source": "ext_002",
|
||||
"target": "con_001"
|
||||
},
|
||||
{
|
||||
"source": "obj_001",
|
||||
"target": "optimization"
|
||||
@@ -393,6 +465,10 @@
|
||||
{
|
||||
"source": "con_001",
|
||||
"target": "optimization"
|
||||
},
|
||||
{
|
||||
"source": "ext_002",
|
||||
"target": "con_001"
|
||||
}
|
||||
],
|
||||
"layout_version": "2.0"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Introspect M1_Blank.prt to get current expression values."""
|
||||
import sys
|
||||
import json
|
||||
sys.path.insert(0, 'c:/Users/antoi/Atomizer')
|
||||
|
||||
from optimization_engine.extractors.introspect_part import introspect_part, get_expressions_dict
|
||||
|
||||
MODEL_PATH = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model/M1_Blank.prt'
|
||||
OUTPUT_DIR = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model'
|
||||
|
||||
# Expressions we care about
|
||||
LATERAL_VARS = [
|
||||
'lateral_inner_u',
|
||||
'lateral_outer_u',
|
||||
'lateral_middle_pivot',
|
||||
'lateral_inner_angle',
|
||||
'lateral_outer_angle',
|
||||
'lateral_closeness',
|
||||
]
|
||||
|
||||
print("Running NX introspection on M1_Blank.prt (cost reduction model)...")
|
||||
result = introspect_part(MODEL_PATH, OUTPUT_DIR, verbose=True)
|
||||
|
||||
if result.get('success'):
|
||||
exprs = get_expressions_dict(result)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("DESIGN VARIABLES (lateral) - current values in model:")
|
||||
print("=" * 60)
|
||||
for name in LATERAL_VARS:
|
||||
if name in exprs:
|
||||
print(f" {name}: {exprs[name]}")
|
||||
else:
|
||||
print(f" {name}: NOT FOUND")
|
||||
|
||||
# Save all expressions to JSON for easy reference
|
||||
output_json = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model_expressions.json'
|
||||
with open(output_json, 'w') as f:
|
||||
json.dump(exprs, f, indent=2)
|
||||
print(f"\nAll expressions saved to: {output_json}")
|
||||
else:
|
||||
print(f"Introspection failed: {result.get('error', 'Unknown error')}")
|
||||
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
M1 Mirror Cost Reduction Lateral - Lateral Supports Optimization (CMA-ES)
|
||||
==========================================================================
|
||||
|
||||
Lateral support optimization for COST REDUCTION model with new U-joint expressions:
|
||||
- lateral_inner_u (replaces lateral_inner_pivot)
|
||||
- lateral_outer_u (replaces lateral_outer_pivot)
|
||||
- lateral_middle_pivot (unchanged)
|
||||
- lateral_inner_angle (unchanged)
|
||||
- lateral_outer_angle (unchanged)
|
||||
|
||||
Key Features:
|
||||
1. CMA-ES sampler - ideal for 5D continuous optimization
|
||||
2. ANNULAR APERTURE - excludes 271.5mm central hole from Zernike fitting
|
||||
3. Uses ZernikeOPDExtractor.extract_relative() with inner_radius=135.75mm
|
||||
4. Weighted sum: 6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90 (NO mass objective)
|
||||
5. Hard constraint: mass <= 120 kg (still enforced)
|
||||
|
||||
Usage:
|
||||
python run_optimization.py --start
|
||||
python run_optimization.py --start --trials 100
|
||||
python run_optimization.py --start --trials 100 --resume
|
||||
python run_optimization.py --test # Single trial test
|
||||
|
||||
Author: Atomizer
|
||||
Created: 2026-01-13
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
LICENSE_SERVER = "28000@dalidou;28000@100.80.199.40"
|
||||
os.environ['SPLM_LICENSE_SERVER'] = LICENSE_SERVER
|
||||
print(f"[LICENSE] SPLM_LICENSE_SERVER set to: {LICENSE_SERVER}")
|
||||
|
||||
# Add Atomizer root to path (study is at studies/M1_Mirror/study_name/)
|
||||
STUDY_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(STUDY_DIR)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
import shutil
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard Auto-Launch
|
||||
# ============================================================================
|
||||
|
||||
def launch_dashboard():
|
||||
"""Launch the Atomizer dashboard in background."""
|
||||
dashboard_dir = Path(PROJECT_ROOT) / "atomizer-dashboard"
|
||||
start_script = dashboard_dir / "start-dashboard.bat"
|
||||
|
||||
if not start_script.exists():
|
||||
print(f"[DASHBOARD] Warning: start-dashboard.bat not found at {start_script}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Launch dashboard in background (detached process)
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "/min", str(start_script)],
|
||||
cwd=str(dashboard_dir),
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
print("[DASHBOARD] Launched in background")
|
||||
print("[DASHBOARD] Frontend: http://localhost:5173")
|
||||
print("[DASHBOARD] Backend: http://localhost:8000")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[DASHBOARD] Failed to launch: {e}")
|
||||
return False
|
||||
|
||||
import optuna
|
||||
from optuna.samplers import CmaEsSampler
|
||||
|
||||
# Atomizer imports
|
||||
from optimization_engine.nx.solver import NXSolver
|
||||
from optimization_engine.extractors import ZernikeOPDExtractor # Supports annular apertures
|
||||
|
||||
# ============================================================================
|
||||
# Paths
|
||||
# ============================================================================
|
||||
|
||||
STUDY_DIR = Path(__file__).parent
|
||||
SETUP_DIR = STUDY_DIR / "1_setup"
|
||||
MODEL_DIR = SETUP_DIR / "model"
|
||||
ITERATIONS_DIR = STUDY_DIR / "2_iterations"
|
||||
RESULTS_DIR = STUDY_DIR / "3_results"
|
||||
CONFIG_PATH = SETUP_DIR / "optimization_config.json"
|
||||
|
||||
# Ensure directories exist
|
||||
ITERATIONS_DIR.mkdir(exist_ok=True)
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Logging
|
||||
LOG_FILE = RESULTS_DIR / "optimization.log"
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s | %(levelname)-8s | %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(LOG_FILE, mode='a')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
with open(CONFIG_PATH) as f:
|
||||
CONFIG = json.load(f)
|
||||
|
||||
STUDY_NAME = CONFIG["study_name"]
|
||||
|
||||
# Objective weights from config (NO MASS!)
|
||||
OBJ_WEIGHTS = {
|
||||
'wfe_40_20': 6.0,
|
||||
'wfe_60_20': 5.0,
|
||||
'mfg_90': 3.0
|
||||
}
|
||||
|
||||
# Hard constraint: blank_mass <= 120kg (still enforced even though not optimizing)
|
||||
MAX_BLANK_MASS_KG = 120.0
|
||||
CONSTRAINT_PENALTY = 1e6
|
||||
|
||||
# ANNULAR APERTURE: 271.5mm central hole diameter -> 135.75mm radius
|
||||
INNER_RADIUS_MM = CONFIG.get('extraction_method', {}).get('inner_radius', 135.75)
|
||||
|
||||
|
||||
def compute_weighted_sum(objectives: Dict[str, float]) -> float:
|
||||
"""Compute weighted sum of objectives (NO MASS!)."""
|
||||
return (OBJ_WEIGHTS['wfe_40_20'] * objectives.get('wfe_40_20', 1000.0) +
|
||||
OBJ_WEIGHTS['wfe_60_20'] * objectives.get('wfe_60_20', 1000.0) +
|
||||
OBJ_WEIGHTS['mfg_90'] * objectives.get('mfg_90', 1000.0))
|
||||
|
||||
|
||||
def check_mass_constraint(mass_kg: float) -> tuple:
|
||||
"""Check if mass constraint is satisfied."""
|
||||
if mass_kg <= MAX_BLANK_MASS_KG:
|
||||
return True, 0.0
|
||||
else:
|
||||
return False, mass_kg - MAX_BLANK_MASS_KG
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FEA Runner with Annular Zernike Extraction
|
||||
# ============================================================================
|
||||
|
||||
class FEARunner:
|
||||
"""Runs FEA simulations with annular aperture Zernike extraction."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.nx_solver = None
|
||||
self.master_model_dir = MODEL_DIR
|
||||
|
||||
# Get fixed parameter values to apply on every run
|
||||
self.fixed_params = {}
|
||||
for fp in config.get('fixed_parameters', []):
|
||||
self.fixed_params[fp['name']] = fp['value']
|
||||
|
||||
def setup(self):
|
||||
"""Setup NX solver (assumes NX is already running)."""
|
||||
study_name = self.config.get('study_name', 'm1_mirror_flatback_lateral')
|
||||
|
||||
nx_settings = self.config.get('nx_settings', {})
|
||||
nx_install_dir = nx_settings.get('nx_install_path', 'C:\\Program Files\\Siemens\\DesigncenterNX2512')
|
||||
version_match = re.search(r'NX(\d+)|DesigncenterNX(\d+)', nx_install_dir)
|
||||
nastran_version = (version_match.group(1) or version_match.group(2)) if version_match else "2512"
|
||||
|
||||
self.nx_solver = NXSolver(
|
||||
master_model_dir=str(self.master_model_dir),
|
||||
nx_install_dir=nx_install_dir,
|
||||
nastran_version=nastran_version,
|
||||
timeout=nx_settings.get('simulation_timeout_s', 600),
|
||||
use_iteration_folders=True,
|
||||
study_name=study_name
|
||||
)
|
||||
logger.info(f"[NX] Solver ready (Nastran {nastran_version})")
|
||||
|
||||
def run_fea(self, params: Dict[str, float], trial_num: int) -> Optional[Dict]:
|
||||
"""Run FEA and extract objectives using ZernikeOPDExtractor with annular aperture."""
|
||||
if self.nx_solver is None:
|
||||
self.setup()
|
||||
|
||||
logger.info(f" [FEA {trial_num}] Running simulation...")
|
||||
|
||||
# Build expressions: start with fixed params, then add optimization params
|
||||
expressions = {}
|
||||
|
||||
# Fixed parameters
|
||||
for name, value in self.fixed_params.items():
|
||||
expressions[name] = value
|
||||
|
||||
# Optimization variables (the ones we're actually varying)
|
||||
for var in self.config['design_variables']:
|
||||
if var.get('enabled', True) and var['name'] in params:
|
||||
expressions[var['expression_name']] = params[var['name']]
|
||||
|
||||
iter_folder = self.nx_solver.create_iteration_folder(
|
||||
iterations_base_dir=ITERATIONS_DIR,
|
||||
iteration_number=trial_num,
|
||||
expression_updates=expressions
|
||||
)
|
||||
|
||||
try:
|
||||
nx_settings = self.config.get('nx_settings', {})
|
||||
sim_file = iter_folder / nx_settings.get('sim_file', 'ASSY_M1_assyfem1_sim1.sim')
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
result = self.nx_solver.run_simulation(
|
||||
sim_file=sim_file,
|
||||
working_dir=iter_folder,
|
||||
expression_updates=expressions,
|
||||
solution_name=nx_settings.get('solution_name', 'Solution 1'),
|
||||
cleanup=False
|
||||
)
|
||||
|
||||
solve_time = time.time() - t_start
|
||||
|
||||
if not result['success']:
|
||||
logger.error(f" [FEA {trial_num}] Solve failed: {result.get('error')}")
|
||||
return None
|
||||
|
||||
logger.info(f" [FEA {trial_num}] Solved in {solve_time:.1f}s")
|
||||
|
||||
# Extract objectives using ZernikeOPDExtractor with ANNULAR APERTURE
|
||||
op2_path = Path(result['op2_file'])
|
||||
objectives, lateral_diag = self._extract_objectives_annular(op2_path, iter_folder)
|
||||
|
||||
if objectives is None:
|
||||
return None
|
||||
|
||||
# Check constraint
|
||||
mass_kg = objectives['mass_kg']
|
||||
is_feasible, violation = check_mass_constraint(mass_kg)
|
||||
|
||||
if is_feasible:
|
||||
weighted_sum = compute_weighted_sum(objectives)
|
||||
constraint_status = "OK"
|
||||
else:
|
||||
weighted_sum = compute_weighted_sum(objectives) + CONSTRAINT_PENALTY * violation
|
||||
constraint_status = f"VIOLATED (+{violation:.1f}kg)"
|
||||
|
||||
logger.info(f" [FEA {trial_num}] 40-20: {objectives['wfe_40_20']:.2f} nm (Annular OPD)")
|
||||
logger.info(f" [FEA {trial_num}] 60-20: {objectives['wfe_60_20']:.2f} nm (Annular OPD)")
|
||||
logger.info(f" [FEA {trial_num}] Mfg: {objectives['mfg_90']:.2f} nm (Annular OPD)")
|
||||
logger.info(f" [FEA {trial_num}] Mass: {objectives['mass_kg']:.3f} kg [Constraint: {constraint_status}]")
|
||||
logger.info(f" [FEA {trial_num}] Weighted Sum: {weighted_sum:.2f}")
|
||||
|
||||
return {
|
||||
'trial_num': trial_num,
|
||||
'params': params,
|
||||
'objectives': objectives,
|
||||
'weighted_sum': weighted_sum,
|
||||
'is_feasible': is_feasible,
|
||||
'constraint_violation': violation,
|
||||
'source': 'FEA_ZernikeOPD_Annular',
|
||||
'solve_time': solve_time,
|
||||
'iter_folder': str(iter_folder),
|
||||
'lateral_diagnostics': lateral_diag
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" [FEA {trial_num}] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _extract_objectives_annular(self, op2_path: Path, iter_folder: Path) -> tuple:
|
||||
"""
|
||||
Extract objectives using ZernikeOPDExtractor with ANNULAR APERTURE.
|
||||
|
||||
The central hole (271.5mm diameter, inner_radius=135.75mm) is EXCLUDED
|
||||
from Zernike fitting and RMS calculations.
|
||||
"""
|
||||
try:
|
||||
zernike_settings = self.config.get('zernike_settings', {})
|
||||
|
||||
# Create ZernikeOPDExtractor with ANNULAR APERTURE
|
||||
extractor = ZernikeOPDExtractor(
|
||||
op2_path,
|
||||
figure_path=None, # Uses BDF geometry
|
||||
bdf_path=None, # Auto-detected
|
||||
displacement_unit=zernike_settings.get('displacement_unit', 'mm'),
|
||||
n_modes=zernike_settings.get('n_modes', 50),
|
||||
filter_orders=zernike_settings.get('filter_low_orders', 4),
|
||||
inner_radius=INNER_RADIUS_MM # ANNULAR APERTURE!
|
||||
)
|
||||
|
||||
ref = zernike_settings.get('reference_subcase', '2')
|
||||
|
||||
# Extract RELATIVE metrics with annular masking
|
||||
rel_40 = extractor.extract_relative("3", ref) # 40 deg vs 20 deg
|
||||
rel_60 = extractor.extract_relative("4", ref) # 60 deg vs 20 deg
|
||||
rel_90 = extractor.extract_relative("1", ref) # 90 deg vs 20 deg (for MFG)
|
||||
|
||||
# Log annular info
|
||||
if 'obscuration_ratio' in rel_40:
|
||||
logger.info(f" [Annular] Inner R={INNER_RADIUS_MM:.1f}mm, Obscuration={rel_40['obscuration_ratio']*100:.1f}%")
|
||||
logger.info(f" [Annular] Using {rel_40.get('n_annular_nodes', '?')} nodes (excl. central hole)")
|
||||
|
||||
# Extract mass from temp file
|
||||
mass_kg = 0.0
|
||||
mass_file = iter_folder / "_temp_mass.txt"
|
||||
if mass_file.exists():
|
||||
try:
|
||||
with open(mass_file, 'r') as f:
|
||||
mass_kg = float(f.read().strip())
|
||||
except Exception as mass_err:
|
||||
logger.warning(f" Could not read mass file: {mass_err}")
|
||||
|
||||
# Also check _temp_part_properties.json
|
||||
if mass_kg == 0:
|
||||
props_file = iter_folder / "_temp_part_properties.json"
|
||||
if props_file.exists():
|
||||
try:
|
||||
with open(props_file, 'r') as f:
|
||||
props = json.load(f)
|
||||
mass_kg = props.get('mass_kg', 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lateral_diag = {
|
||||
'max_um': rel_40.get('max_lateral_displacement_um', 0),
|
||||
'rms_um': rel_40.get('rms_lateral_displacement_um', 0),
|
||||
}
|
||||
|
||||
objectives = {
|
||||
'wfe_40_20': rel_40['relative_filtered_rms_nm'],
|
||||
'wfe_60_20': rel_60['relative_filtered_rms_nm'],
|
||||
'mfg_90': rel_90['relative_rms_filter_j1to3'],
|
||||
'mass_kg': mass_kg
|
||||
}
|
||||
|
||||
return objectives, lateral_diag
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Annular Zernike extraction failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CMA-ES Optimizer
|
||||
# ============================================================================
|
||||
|
||||
class CMAESOptimizer:
|
||||
"""CMA-ES optimizer for lateral support parameters."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], resume: bool = False):
|
||||
self.config = config
|
||||
self.resume = resume
|
||||
self.fea_runner = FEARunner(config)
|
||||
|
||||
# Load design variable bounds (only enabled variables)
|
||||
self.design_vars = {
|
||||
v['name']: {'min': v['min'], 'max': v['max'], 'baseline': v.get('baseline')}
|
||||
for v in config['design_variables']
|
||||
if v.get('enabled', True)
|
||||
}
|
||||
|
||||
# CMA-ES settings
|
||||
opt_settings = config.get('optimization', {})
|
||||
self.sigma0 = opt_settings.get('sigma0', 0.3)
|
||||
self.seed = opt_settings.get('seed', 42)
|
||||
|
||||
# Study
|
||||
self.study_name = config.get('study_name', 'm1_mirror_flatback_lateral')
|
||||
self.db_path = RESULTS_DIR / "study.db"
|
||||
|
||||
# Track best
|
||||
self.best_weighted_sum = float('inf')
|
||||
self.best_trial_info = None
|
||||
|
||||
# Track FEA count
|
||||
self._count_existing_iterations()
|
||||
|
||||
def _count_existing_iterations(self):
|
||||
"""Count existing iteration folders."""
|
||||
self.fea_count = 0
|
||||
if ITERATIONS_DIR.exists():
|
||||
for d in ITERATIONS_DIR.iterdir():
|
||||
if d.is_dir() and d.name.startswith('iter'):
|
||||
try:
|
||||
num = int(d.name.replace('iter', ''))
|
||||
self.fea_count = max(self.fea_count, num)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info(f"Existing FEA iterations: {self.fea_count}")
|
||||
|
||||
def create_study(self) -> optuna.Study:
|
||||
"""Create or load Optuna study with CMA-ES sampler."""
|
||||
# Get baseline values for x0 (starting point)
|
||||
x0 = {}
|
||||
for name, bounds in self.design_vars.items():
|
||||
x0[name] = bounds['baseline']
|
||||
|
||||
sampler = CmaEsSampler(
|
||||
x0=x0,
|
||||
sigma0=self.sigma0,
|
||||
seed=self.seed,
|
||||
restart_strategy='ipop'
|
||||
)
|
||||
|
||||
storage = f"sqlite:///{self.db_path}"
|
||||
|
||||
if self.resume:
|
||||
try:
|
||||
study = optuna.load_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
sampler=sampler
|
||||
)
|
||||
logger.info(f"Resumed study with {len(study.trials)} existing trials")
|
||||
|
||||
# Find current best
|
||||
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
|
||||
feasible = [t for t in completed if t.user_attrs.get('is_feasible', False)]
|
||||
if feasible:
|
||||
best = min(feasible, key=lambda t: t.value if t.value else float('inf'))
|
||||
if best.value:
|
||||
self.best_weighted_sum = best.value
|
||||
logger.info(f"Current best (feasible): {self.best_weighted_sum:.2f}")
|
||||
|
||||
return study
|
||||
except KeyError:
|
||||
logger.info("No existing study found, creating new one")
|
||||
|
||||
study = optuna.create_study(
|
||||
study_name=self.study_name,
|
||||
storage=storage,
|
||||
sampler=sampler,
|
||||
direction="minimize",
|
||||
load_if_exists=True
|
||||
)
|
||||
|
||||
# Enqueue baseline as first trial
|
||||
if len(study.trials) == 0:
|
||||
logger.info("Enqueueing baseline as trial 0...")
|
||||
study.enqueue_trial(x0)
|
||||
|
||||
return study
|
||||
|
||||
def objective(self, trial: optuna.Trial) -> float:
|
||||
"""CMA-ES objective function."""
|
||||
# Sample parameters
|
||||
params = {}
|
||||
for name, bounds in self.design_vars.items():
|
||||
params[name] = trial.suggest_float(name, bounds['min'], bounds['max'])
|
||||
|
||||
# Increment FEA counter
|
||||
self.fea_count += 1
|
||||
iter_num = self.fea_count
|
||||
|
||||
logger.info(f"Trial {trial.number} -> iter{iter_num}")
|
||||
for name, value in params.items():
|
||||
logger.info(f" {name} = {value:.3f}")
|
||||
|
||||
# Run FEA
|
||||
result = self.fea_runner.run_fea(params, iter_num)
|
||||
|
||||
if result is None:
|
||||
trial.set_user_attr('source', 'FEA_FAILED')
|
||||
trial.set_user_attr('iter_num', iter_num)
|
||||
trial.set_user_attr('is_feasible', False)
|
||||
return 1e6
|
||||
|
||||
# Store metadata
|
||||
trial.set_user_attr('source', 'FEA_ZernikeOPD_Annular')
|
||||
trial.set_user_attr('iter_num', iter_num)
|
||||
trial.set_user_attr('iter_folder', result['iter_folder'])
|
||||
trial.set_user_attr('wfe_40_20', result['objectives']['wfe_40_20'])
|
||||
trial.set_user_attr('wfe_60_20', result['objectives']['wfe_60_20'])
|
||||
trial.set_user_attr('mfg_90', result['objectives']['mfg_90'])
|
||||
trial.set_user_attr('mass_kg', result['objectives']['mass_kg'])
|
||||
trial.set_user_attr('solve_time', result['solve_time'])
|
||||
trial.set_user_attr('is_feasible', result['is_feasible'])
|
||||
|
||||
weighted_sum = result['weighted_sum']
|
||||
|
||||
# Check if new best
|
||||
if result['is_feasible'] and weighted_sum < self.best_weighted_sum:
|
||||
logger.info(f" NEW BEST! {weighted_sum:.2f} (was {self.best_weighted_sum:.2f})")
|
||||
self.best_weighted_sum = weighted_sum
|
||||
self.best_trial_info = {
|
||||
'trial_number': trial.number,
|
||||
'iter_num': iter_num,
|
||||
'iter_folder': result['iter_folder'],
|
||||
'weighted_sum': weighted_sum,
|
||||
'objectives': result['objectives'],
|
||||
'params': params
|
||||
}
|
||||
self._archive_best_design()
|
||||
|
||||
return weighted_sum
|
||||
|
||||
def _archive_best_design(self):
|
||||
"""Archive current best design."""
|
||||
if self.best_trial_info is None:
|
||||
return
|
||||
|
||||
try:
|
||||
archive_dir = RESULTS_DIR / "best_design_archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
dest_dir = archive_dir / timestamp
|
||||
|
||||
src_dir = Path(self.best_trial_info['iter_folder'])
|
||||
if src_dir.exists():
|
||||
shutil.copytree(src_dir, dest_dir)
|
||||
|
||||
info = {
|
||||
'study_name': self.study_name,
|
||||
'trial_number': self.best_trial_info['trial_number'],
|
||||
'iteration_folder': f"iter{self.best_trial_info['iter_num']}",
|
||||
'weighted_sum': self.best_trial_info['weighted_sum'],
|
||||
'objectives': self.best_trial_info['objectives'],
|
||||
'params': self.best_trial_info['params'],
|
||||
'extraction_method': 'ZernikeOPD_Annular (271.5mm central hole excluded)',
|
||||
'inner_radius_mm': INNER_RADIUS_MM,
|
||||
'archived_at': datetime.now().isoformat()
|
||||
}
|
||||
with open(dest_dir / '_archive_info.json', 'w') as f:
|
||||
json.dump(info, f, indent=2)
|
||||
|
||||
logger.info(f" Archived to: {dest_dir.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not archive best design: {e}")
|
||||
|
||||
def run(self, n_trials: int):
|
||||
"""Run CMA-ES optimization."""
|
||||
study = self.create_study()
|
||||
|
||||
logger.info("=" * 70)
|
||||
logger.info("M1 MIRROR FLAT BACK - LATERAL SUPPORTS OPTIMIZATION (CMA-ES)")
|
||||
logger.info("=" * 70)
|
||||
logger.info("*** ANNULAR APERTURE: 271.5mm central hole EXCLUDED ***")
|
||||
logger.info(f"*** Inner radius: {INNER_RADIUS_MM} mm ***")
|
||||
logger.info(f"Study: {self.study_name}")
|
||||
logger.info("*** OBJECTIVES: WFE only (mass NOT in objective) ***")
|
||||
logger.info(f"Total trials in DB: {len(study.trials)}")
|
||||
logger.info(f"New FEA trials to run: {n_trials}")
|
||||
logger.info(f"Active Design Variables: {len(self.design_vars)}")
|
||||
for name, bounds in self.design_vars.items():
|
||||
baseline = bounds.get('baseline', 'N/A')
|
||||
logger.info(f" - {name}: [{bounds['min']}, {bounds['max']}] (baseline: {baseline})")
|
||||
logger.info(f"CONSTRAINT: blank_mass <= {MAX_BLANK_MASS_KG} kg")
|
||||
logger.info(f"CMA-ES sigma0: {self.sigma0}")
|
||||
logger.info("=" * 70)
|
||||
|
||||
try:
|
||||
study.optimize(
|
||||
self.objective,
|
||||
n_trials=n_trials,
|
||||
show_progress_bar=True,
|
||||
gc_after_trial=True
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Optimization interrupted by user")
|
||||
|
||||
self._report_results(study)
|
||||
return study
|
||||
|
||||
def _report_results(self, study: optuna.Study):
|
||||
"""Report optimization results."""
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("OPTIMIZATION RESULTS (Annular Aperture)")
|
||||
logger.info("=" * 70)
|
||||
|
||||
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE and t.value < 1e5]
|
||||
feasible = [t for t in completed if t.user_attrs.get('is_feasible', False)]
|
||||
|
||||
logger.info(f"\nTotal completed: {len(completed)}")
|
||||
logger.info(f"Feasible (mass <= {MAX_BLANK_MASS_KG}kg): {len(feasible)}")
|
||||
|
||||
if not feasible:
|
||||
logger.warning("No feasible trials found!")
|
||||
return
|
||||
|
||||
sorted_trials = sorted(feasible, key=lambda t: t.value)
|
||||
|
||||
print(f"\n{'Trial':>6} | {'WS':>10} | {'40vs20':>10} | {'60vs20':>10} | {'MFG':>10} | {'Mass':>10} | Iter")
|
||||
print("-" * 85)
|
||||
|
||||
for t in sorted_trials[:15]:
|
||||
obj_40 = t.user_attrs.get('wfe_40_20', 0)
|
||||
obj_60 = t.user_attrs.get('wfe_60_20', 0)
|
||||
obj_mfg = t.user_attrs.get('mfg_90', 0)
|
||||
obj_mass = t.user_attrs.get('mass_kg', 0)
|
||||
iter_num = t.user_attrs.get('iter_num', '?')
|
||||
print(f"{t.number:>6} | {t.value:>10.2f} | {obj_40:>10.2f} | {obj_60:>10.2f} | {obj_mfg:>10.2f} | {obj_mass:>10.3f} | iter{iter_num}")
|
||||
|
||||
best = sorted_trials[0]
|
||||
logger.info(f"\nBEST FEASIBLE TRIAL: #{best.number}")
|
||||
logger.info(f" Weighted Sum: {best.value:.2f}")
|
||||
logger.info(f" 40-20: {best.user_attrs.get('wfe_40_20', 0):.2f} nm")
|
||||
logger.info(f" 60-20: {best.user_attrs.get('wfe_60_20', 0):.2f} nm")
|
||||
logger.info(f" MFG: {best.user_attrs.get('mfg_90', 0):.2f} nm")
|
||||
logger.info(f" Mass: {best.user_attrs.get('mass_kg', 0):.3f} kg")
|
||||
logger.info(f"\n Best Lateral Parameters:")
|
||||
for k, v in best.params.items():
|
||||
logger.info(f" {k}: {v:.3f}")
|
||||
|
||||
# Save summary
|
||||
results_summary = {
|
||||
'study_name': self.study_name,
|
||||
'algorithm': 'CMA-ES',
|
||||
'extraction_method': 'ZernikeOPD_Annular',
|
||||
'inner_radius_mm': INNER_RADIUS_MM,
|
||||
'objectives_note': 'Mass NOT in objective - WFE only',
|
||||
'total_trials': len(study.trials),
|
||||
'feasible_trials': len(feasible),
|
||||
'best_trial': {
|
||||
'number': best.number,
|
||||
'weighted_sum': best.value,
|
||||
'objectives': {
|
||||
'wfe_40_20': best.user_attrs.get('wfe_40_20'),
|
||||
'wfe_60_20': best.user_attrs.get('wfe_60_20'),
|
||||
'mfg_90': best.user_attrs.get('mfg_90'),
|
||||
'mass_kg': best.user_attrs.get('mass_kg')
|
||||
},
|
||||
'params': dict(best.params),
|
||||
'iter_folder': best.user_attrs.get('iter_folder')
|
||||
},
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(RESULTS_DIR / 'optimization_summary.json', 'w') as f:
|
||||
json.dump(results_summary, f, indent=2)
|
||||
|
||||
logger.info(f"\nResults saved to: {RESULTS_DIR / 'optimization_summary.json'}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="M1 Mirror Cost Reduction Lateral - Lateral Supports Optimization")
|
||||
parser.add_argument("--start", action="store_true", help="Start optimization")
|
||||
parser.add_argument("--trials", type=int, default=100, help="Number of FEA trials")
|
||||
parser.add_argument("--resume", action="store_true", help="Resume interrupted run")
|
||||
parser.add_argument("--test", action="store_true", help="Run single test trial")
|
||||
parser.add_argument("--no-dashboard", action="store_true", help="Don't auto-launch dashboard")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.start and not args.test:
|
||||
parser.print_help()
|
||||
print("\nUse --start to begin optimization or --test for single trial")
|
||||
print("\n*** Optimizing LATERAL SUPPORT parameters only ***")
|
||||
print("*** Design Variables: lateral_inner_u, lateral_outer_u, lateral_middle_pivot, ***")
|
||||
print("*** lateral_inner_angle, lateral_outer_angle ***")
|
||||
print("*** Objectives: WFE only (mass NOT included) ***")
|
||||
print("*** Using ANNULAR APERTURE - central hole excluded from Zernike fitting ***")
|
||||
return
|
||||
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"Error: Config not found at {CONFIG_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
# Auto-launch dashboard (unless disabled)
|
||||
if not args.no_dashboard:
|
||||
launch_dashboard()
|
||||
time.sleep(2) # Give dashboard time to start
|
||||
|
||||
with open(CONFIG_PATH) as f:
|
||||
config = json.load(f)
|
||||
|
||||
optimizer = CMAESOptimizer(config, resume=args.resume)
|
||||
|
||||
n_trials = 1 if args.test else args.trials
|
||||
optimizer.run(n_trials=n_trials)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,25 +4,25 @@
|
||||
"extraction_method": "ZernikeOPD_Annular",
|
||||
"inner_radius_mm": 135.75,
|
||||
"objectives_note": "Mass NOT in objective - WFE only",
|
||||
"total_trials": 1,
|
||||
"feasible_trials": 1,
|
||||
"total_trials": 101,
|
||||
"feasible_trials": 100,
|
||||
"best_trial": {
|
||||
"number": 0,
|
||||
"weighted_sum": 341.40717511411987,
|
||||
"number": 76,
|
||||
"weighted_sum": 220.12317796085603,
|
||||
"objectives": {
|
||||
"wfe_40_20": 9.738648075724171,
|
||||
"wfe_60_20": 24.138392317227122,
|
||||
"mfg_90": 54.09444169121308,
|
||||
"mass_kg": 102.89579477048632
|
||||
"wfe_40_20": 7.033921022459454,
|
||||
"wfe_60_20": 16.109562572565014,
|
||||
"mfg_90": 32.457279654424745,
|
||||
"mass_kg": 102.89579477048622
|
||||
},
|
||||
"params": {
|
||||
"lateral_inner_u": 0.4,
|
||||
"lateral_outer_u": 0.4,
|
||||
"lateral_middle_pivot": 22.42,
|
||||
"lateral_inner_angle": 31.96,
|
||||
"lateral_outer_angle": 9.08
|
||||
"lateral_inner_u": 0.40304412850085514,
|
||||
"lateral_outer_u": 0.9043062289622721,
|
||||
"lateral_middle_pivot": 25.869245488671304,
|
||||
"lateral_inner_angle": 32.008659765295675,
|
||||
"lateral_outer_angle": 13.952742709877848
|
||||
},
|
||||
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_flatback_lateral\\2_iterations\\iter1"
|
||||
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_flatback_lateral\\2_iterations\\iter77"
|
||||
},
|
||||
"timestamp": "2026-01-13T11:01:22.360549"
|
||||
"timestamp": "2026-01-13T18:41:14.992549"
|
||||
}
|
||||
Binary file not shown.
@@ -2,9 +2,9 @@
|
||||
"meta": {
|
||||
"version": "2.0",
|
||||
"created": "2026-01-17T15:35:12.034330Z",
|
||||
"modified": "2026-01-17T15:35:12.034330Z",
|
||||
"modified": "2026-01-20T18:24:29.805432Z",
|
||||
"created_by": "migration",
|
||||
"modified_by": "migration",
|
||||
"modified_by": "canvas",
|
||||
"study_name": "m1_mirror_flatback_lateral",
|
||||
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u). Focus on WFE and MFG only - no mass objective.",
|
||||
"tags": [
|
||||
@@ -104,10 +104,10 @@
|
||||
"expression_name": "lateral_outer_angle",
|
||||
"type": "continuous",
|
||||
"bounds": {
|
||||
"min": 8.0,
|
||||
"max": 17.0
|
||||
"min": 8,
|
||||
"max": 17
|
||||
},
|
||||
"baseline": 9.08,
|
||||
"baseline": 10,
|
||||
"units": "degrees",
|
||||
"enabled": true,
|
||||
"description": "Outer lateral support angle",
|
||||
|
||||
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()
|
||||
BIN
studies/new_study/1_model/Beam_sim1.sim
Normal file
BIN
studies/new_study/1_model/Beam_sim1.sim
Normal file
Binary file not shown.
BIN
studies/new_study/1_model/Bracket_sim1.sim
Normal file
BIN
studies/new_study/1_model/Bracket_sim1.sim
Normal file
Binary file not shown.
Reference in New Issue
Block a user