19 Commits

Author SHA1 Message Date
a3f18dc377 chore: Project cleanup and Canvas UX improvements (Phase 7-9)
## Cleanup (v0.5.0)
- Delete 102+ orphaned MCP session temp files
- Remove build artifacts (htmlcov, dist, __pycache__)
- Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Move debug/analysis scripts from tests/ to tools/analysis/
- Archive redundant NX journals to archive/nx_journals/
- Archive monolithic PROTOCOL.md to docs/archive/
- Update .gitignore with missing patterns
- Clean old study files (optimization_log_old.txt, run_optimization_old.py)

## Canvas UX (Phases 7-9)
- Phase 7: Resizable panels with localStorage persistence
  - Left sidebar: 200-400px, Right panel: 280-600px
  - New useResizablePanel hook and ResizeHandle component
- Phase 8: Enable all palette items
  - All 8 node types now draggable
  - Singleton logic for model/solver/algorithm/surrogate
- Phase 9: Solver configuration
  - Add SolverEngine type (nxnastran, mscnastran, python, etc.)
  - Add NastranSolutionType (SOL101-SOL200)
  - Engine/solution dropdowns in config panel
  - Python script path support

## Documentation
- Update CHANGELOG.md with recent versions
- Update docs/00_INDEX.md
- Create examples/README.md
- Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
2026-01-24 15:17:34 -05:00
2cb8dccc3a feat: Add WebSocket live updates and convergence visualization
Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
2026-01-21 21:48:35 -05:00
c224b16ac3 feat: Add panel management, validation, and error handling to canvas
Phase 1 - Panel Management System:
- Create usePanelStore.ts for centralized panel state management
- Add PanelContainer.tsx for draggable floating panels
- Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click)
- Create ResultsPanel.tsx for trial result details
- Refactor NodeConfigPanelV2 to use panel store for introspection
- Integrate PanelContainer into CanvasView

Phase 2 - Pre-run Validation:
- Create specValidator.ts with comprehensive validation rules
- Add ValidationPanel (enhanced version with error navigation)
- Add Validate button to SpecRenderer with status indicator
- Block run if validation fails
- Check for: design vars, objectives, extractors, bounds, connections

Phase 3 - Error Handling & Recovery:
- Create ErrorPanel.tsx for displaying optimization errors
- Add error classification (nx_crash, solver_fail, extractor_error, etc.)
- Add recovery suggestions based on error type
- Update status endpoint to return error info
- Add _get_study_error_info helper to check error_status.json and DB
- Integrate error detection into status polling

Documentation:
- Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
2026-01-21 21:35:31 -05:00
e1c59a51c1 feat: Add optimization execution and live results overlay to canvas
Phase 2 - Execution Bridge:
- Update /start endpoint to fallback to generic runner when no study script exists
- Auto-detect model files (.prt, .sim) from 1_setup/model/ directory
- Pass atomizer_spec.json path to generic runner

Phase 3 - Live Monitoring & Results Overlay:
- Add ResultBadge component for displaying values on canvas nodes
- Extend schema with resultValue and isFeasible fields
- Update DesignVarNode, ObjectiveNode, ConstraintNode, ExtractorNode to show results
- Add Run/Stop buttons and Results toggle to SpecRenderer
- Poll /status endpoint every 3s and map best_trial values to nodes
- Show green/red badges for constraint feasibility
2026-01-21 21:21:47 -05:00
f725e75164 feat: Add SIM file introspection journal and enhanced file-type specific UI
- Create introspect_sim.py NX journal to extract solutions, BCs from SIM files
- Update introspect_sim_file() to optionally call NX journal for full introspection
- Add FEM mesh section (nodes, elements, materials, properties) to IntrospectionPanel
- Add SIM solutions and boundary conditions sections to IntrospectionPanel
- Show introspection method and NX errors in panel
2026-01-20 21:20:14 -05:00
e954b130f5 feat: Multi-file introspection for FEM/SIM/PRT with PyNastran parsing 2026-01-20 21:14:16 -05:00
5b22439357 feat: Add part selector dropdown to IntrospectionPanel
- Fetch available parts from /nx/parts on panel mount
- Dropdown to select which part to introspect (default = assembly)
- Hides idealized parts (*_i.prt) from dropdown
- Shows part size in dropdown (KB or MB)
- Header shows selected part name in primary color
- Refresh button respects current part selection
- Auto-introspects when part selection changes
2026-01-20 21:04:36 -05:00
0c252e3a65 feat: Add sub-part introspection and existing FEA results UI
Backend:
- GET /nx/parts - List all .prt files in model directory
- GET /nx/introspect/{part_name} - Introspect a specific part file
  (e.g., M1_Blank.prt instead of just the assembly)
- Each part gets its own cache file (_introspection_{stem}.json)

Frontend IntrospectionPanel:
- Add 'FEA Results' section showing existing OP2/F06 sources
- Green checkmark when results exist, shows recommended source
- Expand file_deps and fea_results sections by default
- Add CheckCircle2 and Database icons

This allows introspecting component parts that contain the actual
design variable expressions (e.g., M1_Blank has 56 expressions
while the assembly ASSY_M1 only has 5).
2026-01-20 20:59:04 -05:00
4749944a48 feat: Add extract endpoint to use existing FEA results without re-solving
- scan_existing_fea_results() scans study for existing OP2/F06 files
- Introspection now returns existing_fea_results with recommended source
- New POST /nx/extract endpoint runs extractors on existing OP2 files
- Supports: displacement, stress, frequency, mass_bdf, zernike
- No NX solve needed - uses PyNastran and Atomizer extractors directly

This allows users to test extractors and get physics data from existing
simulation results without re-running the FEA solver.
2026-01-20 20:51:25 -05:00
3229c31349 fix: Rewrite run-baseline to use NXSolver iteration folder pattern
- Use same approach as run_optimization.py with use_iteration_folders=True
- NXSolver.create_iteration_folder() handles proper file copying
- Read NX settings from atomizer_spec.json or optimization_config.json
- Extract Nastran version from NX install path
- Creates iter0 folder for baseline (consistent with optimization numbering)

This fixes the issue where manually copying files didn't preserve
NX file dependency chain (.sim -> .afm -> .fem -> _i.prt -> .prt)
2026-01-20 19:06:40 -05:00
14354a2606 feat: Add NX file dependency tree to introspection panel
Backend:
- Add scan_nx_file_dependencies() function to parse NX file chain
- Uses naming conventions to build dependency tree (.sim -> .afm -> .fem -> _i.prt -> .prt)
- Include file_dependencies in introspection response
- Works without NX (pure file-based analysis)

Frontend:
- Add FileDependencies interface for typed API response
- Add collapsible 'File Dependencies' section with tree visualization
- Color-coded file types (purple=sim, blue=afm, green=fem, yellow=idealized, orange=prt)
- Shows orphan geometry files that aren't in the dependency chain
2026-01-20 15:33:04 -05:00
abbc7b1b50 feat: Add detailed Nastran memory error detection in run-baseline
- Parse Nastran log file to detect memory allocation failures
- Extract requested vs available memory from log
- Provide actionable error message with specific values
- Include log files in result_files response
2026-01-20 15:29:29 -05:00
1cdcc17ffd fix: NX installation path detection for run-baseline endpoint
- Read nx_install_path from atomizer_spec.json if available
- Auto-detect from common Siemens installation paths
- Fixes issue where NX2512 wasn't found (actual path is DesigncenterNX2512)
2026-01-20 15:23:10 -05:00
5c419e2358 fix(canvas): Multiple fixes for drag-drop, undo/redo, and code generation
Drag-drop fixes:
- Fix Objective default data: use nested 'source' object with extractor_id/output_name
- Fix Constraint default data: use 'type' field (not constraint_type), 'threshold' (not limit)

Undo/Redo fixes:
- Remove dependency on isDirty flag (which is always false due to auto-save)
- Record snapshots based on actual spec changes via deep comparison

Code generation improvements:
- Update system prompt to support multiple extractor types:
  * OP2-based extractors for FEA results (stress, displacement, frequency)
  * Expression-based extractors for NX model values (dimensions, volumes)
  * Computed extractors for derived values (no FEA needed)
- Claude will now choose appropriate signature based on user's description
2026-01-20 15:08:49 -05:00
89694088a2 feat(canvas): Add 'Run Baseline' FEA simulation feature to IntrospectionPanel
Backend:
- Add POST /api/optimization/studies/{study_id}/nx/run-baseline endpoint
- Creates trial_baseline folder in 2_iterations/
- Copies all model files and runs NXSolver
- Returns paths to result files (.op2, .f06, .bdf) for extractor testing

Frontend:
- Add 'Run Baseline Simulation' button to IntrospectionPanel
- Show progress spinner during simulation
- Display result files when complete (OP2, F06, BDF)
- Show error messages if simulation fails

This enables:
- Testing custom extractors against real FEA results
- Validating the simulation pipeline before optimization
- Inspecting boundary conditions and loads
2026-01-20 14:50:50 -05:00
91cf9ca1fd fix(canvas): Add Save/Reload buttons and expand IntrospectionPanel to show all model data
CanvasView:
- Fix Save button visibility - now shows when spec is loaded (grayed if no changes)
- Separate logic for spec mode vs legacy mode save buttons
- Fix Reload button visibility

IntrospectionPanel:
- Add Mass Properties section (mass, volume, surface area, CoG, body count)
- Add Linked Parts section showing file dependencies
- Add Bodies section (solid/sheet body counts)
- Add Units section showing unit system
- Type-safe access to all nested properties
2026-01-20 14:47:09 -05:00
ced79b8d39 fix(canvas): Fix IntrospectionPanel to handle new NX introspection API response format
- Handle expressions as object with user/internal arrays (new format) or direct array (old)
- Add useMemo for expression filtering
- Make extractors_available, dependent_files, warnings optional with safe access
- Support both 'unit' and 'units' field names
2026-01-20 14:26:20 -05:00
2f0f45de86 fix(spec): Correct volume extractor structure in m1_mirror_cost_reduction_lateral
- Change custom_function.code to function.source_code per AtomizerSpec v2.0 schema
2026-01-20 14:14:20 -05:00
47f8b50112 fix(canvas): Bug fixes for node movement, drag-drop, config panel, and introspection
- SpecRenderer: Add localNodes state with applyNodeChanges for smooth node dragging
- SpecRenderer: Fix getDefaultNodeData() - extractor uses 'custom_function' type with function definition
- SpecRenderer: Fix constraint default - use constraint_type instead of type
- CanvasView: Show config panel INSTEAD of chat when node selected (not blocked)
- NodeConfigPanelV2: Enable showHeader for code editor toolbar (Generate/Snippets/Validate/Test buttons)
- NodeConfigPanelV2: Pass studyId to IntrospectionPanel
- IntrospectionPanel: Accept studyId prop and use correct API endpoint
- optimization.py: Search multiple directories for model files including 1_setup/model/
2026-01-20 14:14:14 -05:00
85 changed files with 10350 additions and 3624 deletions

View File

@@ -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"}}}}

View File

@@ -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"}}}}

View File

@@ -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`

View File

@@ -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`

View File

@@ -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
View File

@@ -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:*

View File

@@ -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

View File

@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
- If no study context: Offer to create one or list available studies
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
### Step 5: Use DevLoop for Multi-Step Development Tasks
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
```bash
# Plan a task with Gemini
python tools/devloop_cli.py plan "fix extractor exports"
# Implement with Claude
python tools/devloop_cli.py implement
# Test filesystem/API
python tools/devloop_cli.py test --study support_arm
# Test dashboard UI with Playwright
python tools/devloop_cli.py browser --level full
# Analyze failures
python tools/devloop_cli.py analyze
# Full autonomous cycle
python tools/devloop_cli.py start "add new stress extractor"
```
**When to use DevLoop:**
- Fixing bugs that require multiple file changes
- Adding new features or extractors
- Debugging optimization failures
- Testing dashboard UI changes
- Any task that would take 3+ manual steps
**Browser test levels:**
- `quick` - Smoke test (1 test)
- `home` - Home page verification (2 tests)
- `full` - All UI tests (5+ tests)
- `study` - Canvas/dashboard for specific study
**DO NOT default to manual debugging** - use the automation we built!
**Full documentation**: `docs/guides/DEVLOOP.md`
---
## Quick Start - Protocol Operating System

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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'}

View File

@@ -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>
) : (

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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>
);
}

View File

@@ -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
},
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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,
}));

View 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;

View File

@@ -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(() => {

View File

@@ -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 {

View 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),
};
}

View File

@@ -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

View File

@@ -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;
}
// ============================================================================

View File

@@ -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

View 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

View 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"
```

View File

@@ -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
View 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)

View 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:])

View File

@@ -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": []
}
}

View File

@@ -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": []
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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"
}

View 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)

View File

@@ -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_*

View File

@@ -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"

View File

@@ -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')}")

View File

@@ -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()

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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()

Binary file not shown.

Binary file not shown.