diff --git a/CLAUDE.md b/CLAUDE.md index 37d3fe78..d83a6832 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,9 +178,28 @@ Atomizer/ ├── studies/ # User studies ├── tools/ # CLI tools (archive_study.bat, zernike_html_generator.py) ├── archive/ # Deprecated code (for reference) -└── atomizer-dashboard/ # React dashboard +└── atomizer-dashboard/ # React dashboard (V3.0) + ├── frontend/ # React + Vite + Tailwind + │ └── src/components/canvas/ # Canvas Builder with 8 node types + └── backend/api/ # FastAPI + SQLite + ├── services/ # claude_agent, nx_introspection, session_manager + └── routes/ # files, nx, terminal, optimization ``` +### Dashboard Quick Reference + +| Feature | Documentation | +|---------|--------------| +| **Canvas Builder** | `docs/04_USER_GUIDES/CANVAS.md` | +| **Dashboard Overview** | `docs/04_USER_GUIDES/DASHBOARD.md` | +| **Implementation Status** | `docs/04_USER_GUIDES/DASHBOARD_IMPLEMENTATION_STATUS.md` | + +**Canvas V3 Features:** +- File browser for model selection +- Model introspection (expressions, solver type, dependencies) +- One-click add expressions as design variables +- Claude chat integration with WebSocket + ### Import Migration (v2.0) Old imports still work with deprecation warnings. New paths: ```python diff --git a/atomizer-dashboard/backend/api/routes/claude.py b/atomizer-dashboard/backend/api/routes/claude.py index a853eed6..0be8b556 100644 --- a/atomizer-dashboard/backend/api/routes/claude.py +++ b/atomizer-dashboard/backend/api/routes/claude.py @@ -93,7 +93,10 @@ async def create_session(request: CreateSessionRequest): "is_alive": session.is_alive(), } except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + import traceback + error_msg = f"{type(e).__name__}: {str(e) or 'No message'}" + traceback.print_exc() + raise HTTPException(status_code=500, detail=error_msg) @router.get("/sessions/{session_id}") @@ -146,8 +149,9 @@ async def session_websocket(websocket: WebSocket, session_id: str): WebSocket for real-time chat with a session. Message formats (client -> server): - {"type": "message", "content": "user message"} + {"type": "message", "content": "user message", "canvas_state": {...}} {"type": "set_study", "study_id": "study_name"} + {"type": "set_canvas", "canvas_state": {...}} {"type": "ping"} Message formats (server -> client): @@ -158,6 +162,7 @@ async def session_websocket(websocket: WebSocket, session_id: str): {"type": "error", "message": "..."} {"type": "pong"} {"type": "context_updated", "study_id": "..."} + {"type": "canvas_updated", "canvas_state": {...}} """ await websocket.accept() @@ -169,6 +174,9 @@ async def session_websocket(websocket: WebSocket, session_id: str): await websocket.close() return + # Track current canvas state for this connection + current_canvas_state: Dict[str, Any] = {} + try: while True: data = await websocket.receive_json() @@ -178,7 +186,14 @@ async def session_websocket(websocket: WebSocket, session_id: str): if not content: continue - async for chunk in manager.send_message(session_id, content): + # Get canvas state from message or use stored state + canvas_state = data.get("canvas_state") or current_canvas_state + + async for chunk in manager.send_message( + session_id, + content, + canvas_state=canvas_state if canvas_state else None, + ): await websocket.send_json(chunk) elif data.get("type") == "set_study": @@ -190,6 +205,14 @@ async def session_websocket(websocket: WebSocket, session_id: str): "study_id": study_id, }) + elif data.get("type") == "set_canvas": + # Update canvas state for this connection + current_canvas_state = data.get("canvas_state", {}) + await websocket.send_json({ + "type": "canvas_updated", + "canvas_state": current_canvas_state, + }) + elif data.get("type") == "ping": await websocket.send_json({"type": "pong"}) diff --git a/atomizer-dashboard/backend/api/routes/files.py b/atomizer-dashboard/backend/api/routes/files.py index 8df29b5c..7675ce13 100644 --- a/atomizer-dashboard/backend/api/routes/files.py +++ b/atomizer-dashboard/backend/api/routes/files.py @@ -1,16 +1,28 @@ """ Files API Routes -Provides file browsing capabilities for the Canvas Builder. +Provides file browsing and import capabilities for the Canvas Builder. +Supports importing NX model files from anywhere on the file system. """ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, UploadFile, File, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel from pathlib import Path from typing import List, Optional import os +import shutil +import re router = APIRouter() + +class ImportRequest(BaseModel): + """Request to import a file from a Windows path""" + source_path: str + study_name: str + copy_related: bool = True + # Path to studies root (go up 5 levels from this file) _file_path = os.path.abspath(__file__) ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname( @@ -153,3 +165,240 @@ async def check_file_exists(path: str): result["name"] = file_path.name return result + + +def find_related_nx_files(source_path: Path) -> List[Path]: + """ + Find all related NX files based on naming conventions. + + Given a .sim file like 'model_sim1.sim', finds: + - model.prt (geometry part) + - model_fem1.fem (FEM file) + - model_fem1_i.prt (idealized part) + - model_sim1.sim (simulation) + + Args: + source_path: Path to any NX file + + Returns: + List of all related file paths that exist + """ + related = [] + parent = source_path.parent + stem = source_path.stem + suffix = source_path.suffix.lower() + + # Extract base name by removing _sim1, _fem1, _i suffixes + base_name = stem + base_name = re.sub(r'_sim\d*$', '', base_name) + base_name = re.sub(r'_fem\d*$', '', base_name) + base_name = re.sub(r'_i$', '', base_name) + + # Define patterns to search for + patterns = [ + f"{base_name}.prt", # Main geometry + f"{base_name}_i.prt", # Idealized part + f"{base_name}_fem*.fem", # FEM files + f"{base_name}_fem*_i.prt", # Idealized FEM parts + f"{base_name}_sim*.sim", # Simulation files + f"{base_name}.afem", # Assembled FEM + ] + + # Search for matching files + for pattern in patterns: + for match in parent.glob(pattern): + if match.exists() and match not in related: + related.append(match) + + # Also include the source file itself + if source_path.exists() and source_path not in related: + related.append(source_path) + + return related + + +@router.get("/validate-path") +async def validate_external_path(path: str): + """ + Validate an external Windows path and return info about related files. + + Args: + path: Absolute Windows path (e.g., C:\\Models\\bracket.sim) + + Returns: + Information about the file and related files + """ + try: + source_path = Path(path) + + if not source_path.exists(): + return { + "valid": False, + "error": f"Path does not exist: {path}", + } + + if not source_path.is_file(): + return { + "valid": False, + "error": "Path is not a file", + } + + # Check if it's a valid NX file type + valid_extensions = ['.prt', '.sim', '.fem', '.afem'] + if source_path.suffix.lower() not in valid_extensions: + return { + "valid": False, + "error": f"Invalid file type. Expected: {', '.join(valid_extensions)}", + } + + # Find related files + related = find_related_nx_files(source_path) + + return { + "valid": True, + "path": str(source_path), + "name": source_path.name, + "size": source_path.stat().st_size, + "related_files": [ + { + "name": f.name, + "path": str(f), + "size": f.stat().st_size, + "type": f.suffix.lower(), + } + for f in related + ], + } + + except Exception as e: + return { + "valid": False, + "error": str(e), + } + + +@router.post("/import-from-path") +async def import_from_path(request: ImportRequest): + """ + Import NX model files from an external path into a study folder. + + This will: + 1. Create the study folder if it doesn't exist + 2. Copy the specified file + 3. Optionally copy all related files (.prt, .sim, .fem, _i.prt) + + Args: + request: ImportRequest with source_path, study_name, and copy_related flag + + Returns: + List of imported files + """ + try: + source_path = Path(request.source_path) + + if not source_path.exists(): + raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}") + + # Create study folder structure + study_dir = STUDIES_ROOT / request.study_name + model_dir = study_dir / "1_model" + model_dir.mkdir(parents=True, exist_ok=True) + + # Find files to copy + if request.copy_related: + files_to_copy = find_related_nx_files(source_path) + else: + files_to_copy = [source_path] + + imported = [] + for src_file in files_to_copy: + dest_file = model_dir / src_file.name + + # Skip if already exists (avoid overwrite) + if dest_file.exists(): + imported.append({ + "name": src_file.name, + "status": "skipped", + "reason": "Already exists", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + }) + continue + + # Copy file + shutil.copy2(src_file, dest_file) + imported.append({ + "name": src_file.name, + "status": "imported", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "size": dest_file.stat().st_size, + }) + + return { + "success": True, + "study_name": request.study_name, + "imported_files": imported, + "total_imported": len([f for f in imported if f["status"] == "imported"]), + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/upload") +async def upload_files( + files: List[UploadFile] = File(...), + study_name: str = Query(...), +): + """ + Upload NX model files to a study folder. + + Args: + files: List of files to upload + study_name: Target study name + + Returns: + List of uploaded files + """ + try: + # Create study folder structure + study_dir = STUDIES_ROOT / study_name + model_dir = study_dir / "1_model" + model_dir.mkdir(parents=True, exist_ok=True) + + uploaded = [] + for file in files: + # Validate file type + suffix = Path(file.filename).suffix.lower() + if suffix not in ['.prt', '.sim', '.fem', '.afem']: + uploaded.append({ + "name": file.filename, + "status": "rejected", + "reason": f"Invalid file type: {suffix}", + }) + continue + + dest_file = model_dir / file.filename + + # Save file + content = await file.read() + with open(dest_file, 'wb') as f: + f.write(content) + + uploaded.append({ + "name": file.filename, + "status": "uploaded", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "size": len(content), + }) + + return { + "success": True, + "study_name": study_name, + "uploaded_files": uploaded, + "total_uploaded": len([f for f in uploaded if f["status"] == "uploaded"]), + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/atomizer-dashboard/backend/api/services/context_builder.py b/atomizer-dashboard/backend/api/services/context_builder.py index 105f09ea..d0f9f486 100644 --- a/atomizer-dashboard/backend/api/services/context_builder.py +++ b/atomizer-dashboard/backend/api/services/context_builder.py @@ -25,6 +25,7 @@ class ContextBuilder: mode: Literal["user", "power"], study_id: Optional[str] = None, conversation_history: Optional[List[Dict[str, Any]]] = None, + canvas_state: Optional[Dict[str, Any]] = None, ) -> str: """ Build full system prompt with context. @@ -33,12 +34,17 @@ class ContextBuilder: mode: "user" for safe operations, "power" for full access study_id: Optional study name to provide context for conversation_history: Optional recent messages for continuity + canvas_state: Optional canvas state (nodes, edges) from the UI Returns: Complete system prompt string """ parts = [self._base_context(mode)] + # Canvas context takes priority - if user is working on a canvas, include it + if canvas_state: + parts.append(self._canvas_context(canvas_state)) + if study_id: parts.append(self._study_context(study_id)) else: @@ -200,6 +206,166 @@ Important guidelines: return context + def _canvas_context(self, canvas_state: Dict[str, Any]) -> str: + """ + Build context from canvas state (nodes and edges). + + This is CRITICAL for Claude to understand the current workflow + being built in the Canvas UI. + """ + context = "# Current Canvas State\n\n" + context += "**You are assisting the user with a Canvas Builder workflow.**\n" + context += "The canvas represents an optimization pipeline being configured visually.\n\n" + + nodes = canvas_state.get("nodes", []) + edges = canvas_state.get("edges", []) + study_name = canvas_state.get("studyName", "Untitled") + study_path = canvas_state.get("studyPath", None) + + context += f"**Study Name**: {study_name}\n" + if study_path: + context += f"**Study Path**: {study_path}\n" + context += "\n" + + # Group nodes by type + node_types = {} + for node in nodes: + node_type = node.get("type", "unknown") + if node_type not in node_types: + node_types[node_type] = [] + node_types[node_type].append(node) + + # Model node + if "model" in node_types: + model = node_types["model"][0] + data = model.get("data", {}) + context += "## Model\n" + context += f"- **Label**: {data.get('label', 'Model')}\n" + context += f"- **File Path**: {data.get('filePath', 'Not set')}\n" + context += f"- **File Type**: {data.get('fileType', 'Not set')}\n\n" + + # Solver node + if "solver" in node_types: + solver = node_types["solver"][0] + data = solver.get("data", {}) + context += "## Solver\n" + context += f"- **Type**: {data.get('solverType', 'Not set')}\n\n" + + # Design variables + if "designVar" in node_types: + context += "## Design Variables\n\n" + context += "| Name | Expression | Min | Max | Baseline | Unit | Enabled |\n" + context += "|------|------------|-----|-----|----------|------|---------|\n" + for dv in node_types["designVar"]: + data = dv.get("data", {}) + name = data.get("label", "?") + expr = data.get("expressionName", data.get("label", "?")) + min_val = data.get("minValue", "?") + max_val = data.get("maxValue", "?") + baseline = data.get("baseline", "-") + unit = data.get("unit", "-") + enabled = "✓" if data.get("enabled", True) else "✗" + context += f"| {name} | {expr} | {min_val} | {max_val} | {baseline} | {unit} | {enabled} |\n" + context += "\n" + + # Extractors + if "extractor" in node_types: + context += "## Extractors\n\n" + for ext in node_types["extractor"]: + data = ext.get("data", {}) + context += f"### {data.get('extractorName', data.get('label', 'Extractor'))}\n" + context += f"- **ID**: {data.get('extractorId', 'Not set')}\n" + context += f"- **Type**: {data.get('extractorType', 'Not set')}\n" + if data.get("extractMethod"): + context += f"- **Method**: {data.get('extractMethod')}\n" + if data.get("innerRadius"): + context += f"- **Inner Radius**: {data.get('innerRadius')}\n" + if data.get("nModes"): + context += f"- **Zernike Modes**: {data.get('nModes')}\n" + if data.get("subcases"): + context += f"- **Subcases**: {data.get('subcases')}\n" + if data.get("config"): + config = data.get("config", {}) + if config.get("subcaseLabels"): + context += f"- **Subcase Labels**: {config.get('subcaseLabels')}\n" + if config.get("referenceSubcase"): + context += f"- **Reference Subcase**: {config.get('referenceSubcase')}\n" + context += "\n" + + # Objectives + if "objective" in node_types: + context += "## Objectives\n\n" + context += "| Name | Direction | Weight | Penalty |\n" + context += "|------|-----------|--------|---------|\n" + for obj in node_types["objective"]: + data = obj.get("data", {}) + name = data.get("name", data.get("label", "?")) + direction = data.get("direction", "minimize") + weight = data.get("weight", 1) + penalty = data.get("penaltyWeight", "-") + context += f"| {name} | {direction} | {weight} | {penalty} |\n" + context += "\n" + + # Constraints + if "constraint" in node_types: + context += "## Constraints\n\n" + context += "| Name | Operator | Value |\n" + context += "|------|----------|-------|\n" + for con in node_types["constraint"]: + data = con.get("data", {}) + name = data.get("name", data.get("label", "?")) + operator = data.get("operator", "?") + value = data.get("value", "?") + context += f"| {name} | {operator} | {value} |\n" + context += "\n" + + # Algorithm + if "algorithm" in node_types: + algo = node_types["algorithm"][0] + data = algo.get("data", {}) + context += "## Algorithm\n" + context += f"- **Method**: {data.get('method', 'Not set')}\n" + context += f"- **Max Trials**: {data.get('maxTrials', 'Not set')}\n" + if data.get("sigma0"): + context += f"- **CMA-ES Sigma0**: {data.get('sigma0')}\n" + if data.get("restartStrategy"): + context += f"- **Restart Strategy**: {data.get('restartStrategy')}\n" + context += "\n" + + # Surrogate + if "surrogate" in node_types: + sur = node_types["surrogate"][0] + data = sur.get("data", {}) + context += "## Surrogate\n" + context += f"- **Enabled**: {data.get('enabled', False)}\n" + context += f"- **Type**: {data.get('modelType', 'Not set')}\n" + context += f"- **Min Trials**: {data.get('minTrials', 'Not set')}\n\n" + + # Edge connections summary + context += "## Connections\n\n" + context += f"Total edges: {len(edges)}\n" + context += "Flow: Design Variables → Model → Solver → Extractors → Objectives/Constraints → Algorithm\n\n" + + # Canvas modification instructions + context += """## Canvas Modification Tools + +When the user asks to modify the canvas (add/remove nodes, change values), use these MCP tools: +- `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint) +- `canvas_update_node` - Update node properties (bounds, weights, names) +- `canvas_remove_node` - Remove a node from the canvas +- `canvas_connect_nodes` - Create an edge between nodes + +**Example user requests you can handle:** +- "Add a design variable called hole_diameter with range 5-15 mm" → Use canvas_add_node +- "Change the weight of wfe_40_20 to 8" → Use canvas_update_node +- "Remove the constraint node" → Use canvas_remove_node +- "Connect the new extractor to the objective" → Use canvas_connect_nodes + +Always respond with confirmation of changes made to the canvas. +""" + + return context + def _mode_instructions(self, mode: str) -> str: """Mode-specific instructions""" if mode == "power": diff --git a/atomizer-dashboard/backend/api/services/nx_introspection.py b/atomizer-dashboard/backend/api/services/nx_introspection.py index 04d2530d..af4cf43d 100644 --- a/atomizer-dashboard/backend/api/services/nx_introspection.py +++ b/atomizer-dashboard/backend/api/services/nx_introspection.py @@ -1,15 +1,18 @@ """ -NX Model Introspection Service +NX Model Introspection Service - Real Implementation + +Discovers expressions, solver types, dependent files, and actual result data +from NX model files. Uses PyNastran for OP2 result parsing. -Discovers expressions, solver types, and dependent files from NX model files. Used by the Canvas Builder to help users configure optimization workflows. """ import json import os import re +import struct from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import logging logger = logging.getLogger(__name__) @@ -21,6 +24,14 @@ ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.di ))))) STUDIES_ROOT = ATOMIZER_ROOT / "studies" +# Try to import PyNastran for OP2 parsing +try: + from pyNastran.op2.op2 import OP2 + HAS_PYNASTRAN = True +except ImportError: + HAS_PYNASTRAN = False + logger.warning("PyNastran not available - OP2 parsing disabled") + class NXIntrospector: """Introspect NX model files to discover expressions, dependencies, and solver info.""" @@ -36,13 +47,31 @@ class NXIntrospector: self.file_path = STUDIES_ROOT / self.relative_path self.file_type = self.file_path.suffix.lower() self.parent_dir = self.file_path.parent + self.study_dir = self._find_study_dir() + + def _find_study_dir(self) -> Path: + """Find the study root directory.""" + # Walk up to find study markers (optimization_config.json, study.db, etc.) + current = self.parent_dir + for _ in range(5): # Max 5 levels up + if (current / "optimization_config.json").exists(): + return current + if (current / "3_results").exists(): + return current + if (current / "1_model").exists(): + return current + if current == STUDIES_ROOT: + break + current = current.parent + return self.parent_dir def introspect(self) -> Dict[str, Any]: """ Full introspection of the model file. Returns: - Dict with expressions, solver_type, dependent_files, extractors_available, warnings + Dict with expressions, solver_type, dependent_files, extractors_available, + mesh_info, result_files, warnings """ result = { "file_path": self.relative_path, @@ -50,8 +79,11 @@ class NXIntrospector: "expressions": [], "solver_type": None, "dependent_files": [], + "result_files": [], + "mesh_info": None, "extractors_available": [], "warnings": [], + "study_dir": str(self.study_dir.relative_to(STUDIES_ROOT)).replace("\\", "/") if self.study_dir != self.parent_dir else None, } if not self.file_path.exists(): @@ -59,170 +91,372 @@ class NXIntrospector: return result try: - if self.file_type == '.sim': - result.update(self._introspect_sim()) - elif self.file_type == '.prt': - result.update(self._introspect_prt()) - elif self.file_type in ['.fem', '.afem']: - result.update(self._introspect_fem()) + # Step 1: Discover related files + result["dependent_files"] = self._discover_related_files() - # Try to load expressions from optimization_config.json if present + # Step 2: Detect solver type from files + result["solver_type"] = self._detect_solver_type() + + # Step 3: Find and analyze OP2 result files + op2_files = self._find_op2_files() + if op2_files: + result["result_files"] = op2_files + # Analyze the first OP2 file for available result types + op2_analysis = self._analyze_op2(op2_files[0]["path"]) if HAS_PYNASTRAN else None + if op2_analysis: + result["op2_analysis"] = op2_analysis + + # Step 4: Try to get mesh info from FEM files + fem_file = self._find_fem_file() + if fem_file: + mesh_info = self._analyze_fem(fem_file) + if mesh_info: + result["mesh_info"] = mesh_info + + # Step 5: Parse BDF for actual parameter values + bdf_file = self._find_bdf_file() + if bdf_file: + bdf_analysis = self._analyze_bdf(bdf_file) + if bdf_analysis: + result["bdf_analysis"] = bdf_analysis + if bdf_analysis.get("mass"): + result["mass_from_bdf"] = bdf_analysis["mass"] + + # Step 6: Try to load expressions from config or discover them config_expressions = self._load_expressions_from_config() if config_expressions: result["expressions"] = config_expressions - - # If still no expressions, try from study history - if not result["expressions"]: - result["expressions"] = self._discover_common_expressions() + else: + # Try to discover from study history + historical = self._discover_from_study_history() + if historical: + result["expressions"] = historical + else: + # Fall back to common patterns + result["expressions"] = self._discover_common_expressions() except Exception as e: - logger.error(f"Introspection error: {e}") + logger.error(f"Introspection error: {e}", exc_info=True) result["warnings"].append(str(e)) - # Suggest extractors based on solver type - result["extractors_available"] = self._suggest_extractors(result.get("solver_type")) + # Suggest extractors based on solver type and available data + result["extractors_available"] = self._suggest_extractors( + result.get("solver_type"), + result.get("result_files", []), + result.get("op2_analysis") + ) return result - def _introspect_sim(self) -> Dict[str, Any]: - """Introspect .sim file.""" - result = { - "solver_type": None, - "dependent_files": [], - } + def _discover_related_files(self) -> List[Dict[str, Any]]: + """Find all related NX files by naming convention.""" + related = [] + # Get base name without _sim1, _fem1, _i suffixes base_name = self.file_path.stem + base_name = re.sub(r'_sim\d*$', '', base_name) + base_name = re.sub(r'_fem\d*$', '', base_name) + base_name = re.sub(r'_i$', '', base_name) - # Find related files in the same directory and parent + # Search directories search_dirs = [self.parent_dir] - if self.parent_dir.name in ['1_config', '1_setup', 'config', 'setup']: - search_dirs.append(self.parent_dir.parent) + if self.study_dir != self.parent_dir: + search_dirs.append(self.study_dir) + # Also check 1_model subfolder + model_dir = self.study_dir / "1_model" + if model_dir.exists(): + search_dirs.append(model_dir) + + seen_paths = set() for search_dir in search_dirs: if not search_dir.exists(): continue - for ext in ['.prt', '.fem', '.afem']: - # Look for variations of the file name - patterns = [ - f"{base_name}{ext}", - f"{base_name.replace('_sim1', '')}{ext}", - f"{base_name.replace('_sim1', '_fem1')}{ext}", - ] + # Define patterns to search for + patterns = [ + (f"{base_name}.prt", "geometry"), + (f"{base_name}_i.prt", "idealized"), + (f"{base_name}_fem*.fem", "fem"), + (f"{base_name}_fem*_i.prt", "idealized_fem"), + (f"{base_name}_sim*.sim", "simulation"), + (f"{base_name}.afem", "assembled_fem"), + ] - for pattern in patterns: - file_candidate = search_dir / pattern - if file_candidate.exists(): - result["dependent_files"].append({ - "path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "type": ext[1:], - "name": file_candidate.name, + for pattern, file_category in patterns: + for match in search_dir.glob(pattern): + if match.exists() and str(match) not in seen_paths: + seen_paths.add(str(match)) + try: + rel_path = str(match.relative_to(STUDIES_ROOT)).replace("\\", "/") + except ValueError: + rel_path = str(match) + + related.append({ + "name": match.name, + "path": rel_path, + "type": match.suffix[1:].lower(), + "category": file_category, + "size": match.stat().st_size, }) - # Find idealized part (*_i.prt) - critical for mesh updates - for f in search_dir.glob("*_i.prt"): - result["dependent_files"].append({ - "path": str(f.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "type": "idealized_prt", - "name": f.name, + return related + + def _find_op2_files(self) -> List[Dict[str, Any]]: + """Find OP2 result files in the study.""" + op2_files = [] + + # Search in iterations/results folders + search_dirs = [ + self.study_dir / "2_iterations", + self.study_dir / "3_results", + self.parent_dir, + ] + + for search_dir in search_dirs: + if not search_dir.exists(): + continue + + # Search recursively for OP2 files (limit depth to avoid going too deep) + for op2_path in search_dir.rglob("*.op2"): + try: + rel_path = str(op2_path.relative_to(STUDIES_ROOT)).replace("\\", "/") + except ValueError: + rel_path = str(op2_path) + + op2_files.append({ + "name": op2_path.name, + "path": rel_path, + "full_path": str(op2_path), + "size": op2_path.stat().st_size, + "trial_folder": op2_path.parent.name if "trial_" in op2_path.parent.name else None, }) - # Try to determine solver type - result["solver_type"] = self._detect_solver_type() + # Limit to 10 OP2 files for performance + if len(op2_files) >= 10: + break - return result + return op2_files - def _introspect_prt(self) -> Dict[str, Any]: - """Introspect .prt file.""" - result = { - "dependent_files": [], - } + def _analyze_op2(self, op2_path: str) -> Optional[Dict[str, Any]]: + """Analyze an OP2 file to discover available result types.""" + if not HAS_PYNASTRAN: + return None + full_path = STUDIES_ROOT / op2_path + if not full_path.exists(): + return None + + try: + op2 = OP2() + op2.set_results_to_include({ + 'displacements': True, + 'eigenvectors': True, + 'solid_stress': True, + 'plate_stress': True, + }) + op2.read_op2(str(full_path), build_dataframe=False) + + analysis = { + "subcases": list(op2.displacements.keys()) if op2.displacements else [], + "has_displacements": bool(op2.displacements), + "has_eigenvectors": bool(op2.eigenvectors), + "has_solid_stress": bool(getattr(op2, 'solid_stress', None)), + "has_plate_stress": bool(getattr(op2, 'plate_stress', None)), + } + + # Get node count from displacement results + if op2.displacements: + first_subcase = list(op2.displacements.values())[0] + analysis["node_count"] = len(first_subcase.node_gridtype) + + # Get eigenvalue info if modal analysis + if op2.eigenvectors: + first_subcase = list(op2.eigenvectors.values())[0] + if hasattr(first_subcase, 'eigrs'): + # Convert eigenvalues to frequencies + import numpy as np + eigenvalues = first_subcase.eigrs + frequencies = np.sqrt(np.abs(eigenvalues)) / (2 * np.pi) + analysis["frequencies_hz"] = frequencies[:10].tolist() # First 10 modes + analysis["num_modes"] = len(eigenvalues) + + return analysis + + except Exception as e: + logger.warning(f"OP2 analysis failed: {e}") + return {"error": str(e)} + + def _find_fem_file(self) -> Optional[Path]: + """Find the FEM file for this model.""" base_name = self.file_path.stem + base_name = re.sub(r'_sim\d*$', '', base_name) + base_name = re.sub(r'_i$', '', base_name) - # Look for associated .sim and .fem files - search_dirs = [self.parent_dir] - if self.parent_dir.name in ['1_config', '1_setup', 'config', 'setup']: - search_dirs.append(self.parent_dir.parent) + patterns = [ + f"{base_name}.fem", + f"{base_name}_fem1.fem", + f"{base_name}_fem.fem", + ] - for search_dir in search_dirs: + for search_dir in [self.parent_dir, self.study_dir / "1_model"]: if not search_dir.exists(): continue - - for ext in ['.sim', '.fem', '.afem']: - patterns = [ - f"{base_name}{ext}", - f"{base_name}_sim1{ext}", - f"{base_name}_fem1{ext}", - ] - for pattern in patterns: - file_candidate = search_dir / pattern - if file_candidate.exists(): - result["dependent_files"].append({ - "path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "type": ext[1:], - "name": file_candidate.name, - }) - - return result - - def _introspect_fem(self) -> Dict[str, Any]: - """Introspect .fem or .afem file.""" - result = { - "dependent_files": [], - } - - base_name = self.file_path.stem - - # Look for associated files - for ext in ['.prt', '.sim']: - patterns = [ - f"{base_name}{ext}", - f"{base_name.replace('_fem1', '')}{ext}", - f"{base_name.replace('_fem1', '_sim1')}{ext}", - ] for pattern in patterns: - file_candidate = self.parent_dir / pattern - if file_candidate.exists(): - result["dependent_files"].append({ - "path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "type": ext[1:], - "name": file_candidate.name, - }) + fem_path = search_dir / pattern + if fem_path.exists(): + return fem_path - return result + return None + + def _analyze_fem(self, fem_path: Path) -> Optional[Dict[str, Any]]: + """Analyze FEM file for mesh statistics.""" + try: + # FEM files are binary - we can get basic stats from file size + # For actual mesh data, we'd need NX Open API + stats = { + "path": str(fem_path.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "size_mb": round(fem_path.stat().st_size / 1024 / 1024, 2), + } + + # Try to find corresponding .dat file for actual mesh info + dat_path = fem_path.with_suffix('.dat') + if dat_path.exists(): + dat_analysis = self._analyze_dat_file(dat_path) + if dat_analysis: + stats.update(dat_analysis) + + return stats + + except Exception as e: + logger.warning(f"FEM analysis failed: {e}") + return None + + def _find_bdf_file(self) -> Optional[Path]: + """Find BDF/DAT file in the study.""" + # Check iterations folder first (most recent analysis) + iterations_dir = self.study_dir / "2_iterations" + if iterations_dir.exists(): + # Look in the most recent trial folder + trial_folders = sorted( + [d for d in iterations_dir.iterdir() if d.is_dir() and d.name.startswith("trial_")], + key=lambda x: x.name, + reverse=True + ) + if trial_folders: + for trial in trial_folders[:3]: # Check last 3 trials + for ext in ['.dat', '.bdf']: + for bdf_path in trial.glob(f"*{ext}"): + return bdf_path + + # Check model directory + for search_dir in [self.parent_dir, self.study_dir / "1_model"]: + if search_dir.exists(): + for ext in ['.dat', '.bdf']: + for bdf_path in search_dir.glob(f"*{ext}"): + return bdf_path + + return None + + def _analyze_bdf(self, bdf_path: Path) -> Optional[Dict[str, Any]]: + """Analyze BDF/DAT file for mass and other properties.""" + try: + analysis = { + "path": str(bdf_path.relative_to(STUDIES_ROOT)).replace("\\", "/"), + } + + with open(bdf_path, 'r', errors='ignore') as f: + content = f.read() + + # Extract mass from GRID+element cards or PARAM,WTMASS + # Look for mass in comments or parameters + mass_match = re.search(r'(?:MASS|mass)\s*[=:]\s*([\d.eE+-]+)', content) + if mass_match: + analysis["mass"] = float(mass_match.group(1)) + + # Count grid points + grid_count = len(re.findall(r'^GRID[\s,]', content, re.MULTILINE)) + if grid_count > 0: + analysis["grid_count"] = grid_count + + # Count elements by type + element_counts = {} + for elem_type in ['CTETRA', 'CHEXA', 'CPENTA', 'CTRIA3', 'CQUAD4', 'CBAR', 'CBEAM']: + count = len(re.findall(rf'^{elem_type}[\s,]', content, re.MULTILINE)) + if count > 0: + element_counts[elem_type.lower()] = count + + if element_counts: + analysis["elements"] = element_counts + analysis["total_elements"] = sum(element_counts.values()) + + # Detect solver type from executive control + if 'SOL 101' in content or 'SOL101' in content: + analysis["solver"] = "SOL101" + elif 'SOL 103' in content or 'SOL103' in content: + analysis["solver"] = "SOL103" + elif 'SOL 111' in content or 'SOL111' in content: + analysis["solver"] = "SOL111" + + return analysis + + except Exception as e: + logger.warning(f"BDF analysis failed: {e}") + return None + + def _analyze_dat_file(self, dat_path: Path) -> Optional[Dict[str, Any]]: + """Analyze .dat file for mesh/model info.""" + try: + analysis = {} + + with open(dat_path, 'r', errors='ignore') as f: + # Read first 10000 chars for efficiency + content = f.read(10000) + + # Count grid points + grid_count = len(re.findall(r'^GRID[\s,]', content, re.MULTILINE)) + if grid_count > 0: + analysis["node_count"] = grid_count + + return analysis if analysis else None + + except Exception as e: + return None def _detect_solver_type(self) -> Optional[str]: - """Detect solver type from file name or contents.""" - name_lower = self.file_path.name.lower() - parent_lower = str(self.parent_dir).lower() + """Detect solver type from files and naming.""" + # First check BDF file + bdf_file = self._find_bdf_file() + if bdf_file: + analysis = self._analyze_bdf(bdf_file) + if analysis and analysis.get("solver"): + return analysis["solver"] # Infer from naming conventions - if 'modal' in name_lower or 'freq' in name_lower or 'modal' in parent_lower: - return 'SOL103' # Modal analysis - elif 'static' in name_lower or 'stress' in name_lower: - return 'SOL101' # Static analysis - elif 'thermal' in name_lower or 'heat' in name_lower: - return 'SOL153' # Thermal - elif 'dynamic' in name_lower: - return 'SOL111' # Frequency response - elif 'mirror' in parent_lower or 'wfe' in parent_lower: - return 'SOL101' # Mirrors usually use static analysis + name_lower = self.file_path.name.lower() + parent_lower = str(self.study_dir).lower() - # Default to static - return 'SOL101' + if 'modal' in name_lower or 'freq' in name_lower or 'modal' in parent_lower: + return 'SOL103' + elif 'static' in name_lower or 'stress' in name_lower: + return 'SOL101' + elif 'thermal' in name_lower or 'heat' in name_lower: + return 'SOL153' + elif 'dynamic' in name_lower: + return 'SOL111' + elif 'mirror' in parent_lower or 'wfe' in parent_lower: + return 'SOL101' + + return 'SOL101' # Default def _load_expressions_from_config(self) -> List[Dict[str, Any]]: """Load expressions from optimization_config.json if it exists.""" expressions = [] - # Look for config file in study directory config_paths = [ + self.study_dir / "optimization_config.json", + self.study_dir / "1_config" / "optimization_config.json", self.parent_dir / "optimization_config.json", - self.parent_dir / "1_config" / "optimization_config.json", - self.parent_dir / "1_setup" / "optimization_config.json", - self.parent_dir.parent / "optimization_config.json", - self.parent_dir.parent / "1_config" / "optimization_config.json", ] for config_path in config_paths: @@ -231,14 +465,17 @@ class NXIntrospector: with open(config_path, 'r') as f: config = json.load(f) - # Extract design variables design_vars = config.get("design_variables", []) for dv in design_vars: + expr_name = dv.get("name", dv.get("expression", "unknown")) + expr_min = dv.get("min", 0) + expr_max = dv.get("max", 100) + expressions.append({ - "name": dv.get("name", dv.get("expression", "unknown")), - "value": (dv.get("min", 0) + dv.get("max", 100)) / 2, - "min": dv.get("min"), - "max": dv.get("max"), + "name": expr_name, + "value": (expr_min + expr_max) / 2, + "min": expr_min, + "max": expr_max, "unit": dv.get("unit", "mm"), "type": "design_variable", "source": "config", @@ -250,68 +487,139 @@ class NXIntrospector: return expressions + def _discover_from_study_history(self) -> List[Dict[str, Any]]: + """Try to discover expressions from study database or previous trials.""" + expressions = [] + + # Check study.db for parameter history + db_path = self.study_dir / "3_results" / "study.db" + if db_path.exists(): + try: + import sqlite3 + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # Try Optuna schema first + cursor.execute(""" + SELECT DISTINCT param_name, param_value + FROM trial_params + ORDER BY trial_id DESC + LIMIT 20 + """) + rows = cursor.fetchall() + + param_values: Dict[str, List[float]] = {} + for name, value in rows: + if name not in param_values: + param_values[name] = [] + try: + param_values[name].append(float(value)) + except (ValueError, TypeError): + pass + + for name, values in param_values.items(): + if values: + expressions.append({ + "name": name, + "value": sum(values) / len(values), + "min": min(values), + "max": max(values), + "unit": "mm", + "type": "design_variable", + "source": "database", + }) + + conn.close() + + except Exception as e: + logger.debug(f"Database query failed: {e}") + + return expressions + def _discover_common_expressions(self) -> List[Dict[str, Any]]: """Discover common expressions based on study type.""" - # Check parent directory name to infer study type - parent_lower = str(self.parent_dir).lower() + parent_lower = str(self.study_dir).lower() if 'mirror' in parent_lower: return [ - {"name": "flatback_thickness", "value": 30.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "rib_height", "value": 40.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "rib_width", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "fillet_radius", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "web_thickness", "value": 4.0, "unit": "mm", "type": "dimension", "source": "inferred"}, + {"name": "flatback_thickness", "value": 30.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "rib_height", "value": 40.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "rib_width", "value": 8.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "fillet_radius", "value": 5.0, "unit": "mm", "type": "dimension", "source": "template"}, ] elif 'bracket' in parent_lower: return [ - {"name": "thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "height", "value": 30.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "hole_diameter", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"}, + {"name": "thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "height", "value": 30.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension", "source": "template"}, ] elif 'beam' in parent_lower: return [ - {"name": "height", "value": 100.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "web_thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "flange_thickness", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"}, + {"name": "height", "value": 100.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "web_thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "template"}, ] - # Generic expressions + # Generic return [ - {"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "length", "value": 100.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "height", "value": 25.0, "unit": "mm", "type": "dimension", "source": "inferred"}, - {"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension", "source": "inferred"}, + {"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "length", "value": 100.0, "unit": "mm", "type": "dimension", "source": "template"}, + {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "template"}, ] - def _suggest_extractors(self, solver_type: Optional[str]) -> List[Dict[str, Any]]: - """Suggest extractors based on solver type.""" + def _suggest_extractors( + self, + solver_type: Optional[str], + result_files: List[Dict[str, Any]], + op2_analysis: Optional[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """Suggest extractors based on solver type and available data.""" extractors = [ - {"id": "E4", "name": "Mass (BDF)", "description": "Extract mass from BDF file", "always": True}, - {"id": "E5", "name": "Mass (Expression)", "description": "Extract mass from NX expression", "always": True}, + {"id": "E4", "name": "Mass (BDF)", "description": "Extract mass from BDF file", "always": True, "available": True}, + {"id": "E5", "name": "Mass (Expression)", "description": "Extract mass from NX expression", "always": True, "available": True}, ] - if solver_type == 'SOL101': + # Determine availability based on OP2 analysis + has_displacements = op2_analysis.get("has_displacements", False) if op2_analysis else False + has_eigenvectors = op2_analysis.get("has_eigenvectors", False) if op2_analysis else False + has_stress = op2_analysis.get("has_solid_stress", False) or op2_analysis.get("has_plate_stress", False) if op2_analysis else False + has_results = len(result_files) > 0 + + if solver_type == 'SOL101' or has_displacements: extractors.extend([ - {"id": "E1", "name": "Displacement", "description": "Max displacement from static analysis", "always": False}, - {"id": "E3", "name": "Stress", "description": "Von Mises stress from static analysis", "always": False}, - ]) - elif solver_type == 'SOL103': - extractors.extend([ - {"id": "E2", "name": "Frequency", "description": "Natural frequencies from modal analysis", "always": False}, + { + "id": "E1", + "name": "Displacement", + "description": "Max displacement from static analysis", + "always": False, + "available": has_displacements or has_results + }, + { + "id": "E3", + "name": "Stress", + "description": "Von Mises stress from static analysis", + "always": False, + "available": has_stress or has_results + }, ]) - # Check if study appears to be mirror-related - parent_lower = str(self.parent_dir).lower() + if solver_type == 'SOL103' or has_eigenvectors: + extractors.append({ + "id": "E2", + "name": "Frequency", + "description": "Natural frequencies from modal analysis", + "always": False, + "available": has_eigenvectors or has_results + }) + + # Mirror-specific extractors + parent_lower = str(self.study_dir).lower() if 'mirror' in parent_lower or 'wfe' in parent_lower: extractors.extend([ - {"id": "E8", "name": "Zernike Coefficients", "description": "Zernike polynomial coefficients", "always": False}, - {"id": "E9", "name": "Zernike RMS", "description": "RMS wavefront error", "always": False}, - {"id": "E10", "name": "Zernike WFE", "description": "Weighted WFE metric", "always": False}, + {"id": "E8", "name": "Zernike Coefficients", "description": "Zernike polynomial coefficients from OP2", "always": False, "available": has_displacements}, + {"id": "E9", "name": "Zernike CSV", "description": "Zernike from CSV export", "always": False, "available": True}, + {"id": "E10", "name": "Zernike RMS WFE", "description": "RMS wavefront error calculation", "always": False, "available": True}, ]) return extractors diff --git a/atomizer-dashboard/backend/api/services/session_manager.py b/atomizer-dashboard/backend/api/services/session_manager.py index b322a075..37401cb2 100644 --- a/atomizer-dashboard/backend/api/services/session_manager.py +++ b/atomizer-dashboard/backend/api/services/session_manager.py @@ -2,12 +2,15 @@ Session Manager Manages persistent Claude Code sessions with MCP integration. +Fixed for Windows compatibility - uses subprocess.Popen with ThreadPoolExecutor. """ import asyncio import json import os +import subprocess import uuid +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -20,6 +23,9 @@ from .context_builder import ContextBuilder ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools" +# Thread pool for subprocess operations (Windows compatible) +_executor = ThreadPoolExecutor(max_workers=4) + @dataclass class ClaudeSession: @@ -28,13 +34,12 @@ class ClaudeSession: session_id: str mode: Literal["user", "power"] study_id: Optional[str] - process: Optional[asyncio.subprocess.Process] = None created_at: datetime = field(default_factory=datetime.now) last_active: datetime = field(default_factory=datetime.now) def is_alive(self) -> bool: - """Check if the subprocess is still running""" - return self.process is not None and self.process.returncode is None + """Session is always 'alive' - we use stateless CLI calls""" + return True class SessionManager: @@ -45,7 +50,7 @@ class SessionManager: self.store = ConversationStore() self.context_builder = ContextBuilder() self._cleanup_task: Optional[asyncio.Task] = None - self._lock: Optional[asyncio.Lock] = None # Created lazily in async context + self._lock: Optional[asyncio.Lock] = None def _get_lock(self) -> asyncio.Lock: """Get or create the async lock (must be called from async context)""" @@ -55,7 +60,6 @@ class SessionManager: async def start(self): """Start the session manager""" - # Start periodic cleanup of stale sessions self._cleanup_task = asyncio.create_task(self._cleanup_loop()) async def stop(self): @@ -67,9 +71,9 @@ class SessionManager: except asyncio.CancelledError: pass - # Terminate all sessions + # Clean up temp files for session in list(self.sessions.values()): - await self._terminate_session(session) + self._cleanup_session_files(session.session_id) async def create_session( self, @@ -80,22 +84,16 @@ class SessionManager: """ Create or resume a Claude Code session. - Args: - mode: "user" for safe mode, "power" for full access - study_id: Optional study context - resume_session_id: Optional session ID to resume - - Returns: - ClaudeSession object + Note: Sessions are now stateless - we don't spawn persistent processes. + Each message is handled via a one-shot CLI call for Windows compatibility. """ async with self._get_lock(): - # Resume existing session if requested and alive + # Resume existing session if requested if resume_session_id and resume_session_id in self.sessions: session = self.sessions[resume_session_id] - if session.is_alive(): - session.last_active = datetime.now() - self.store.touch_session(session.session_id) - return session + session.last_active = datetime.now() + self.store.touch_session(session.session_id) + return session session_id = resume_session_id or str(uuid.uuid4())[:8] @@ -112,51 +110,11 @@ class SessionManager: with open(mcp_config_path, "w") as f: json.dump(mcp_config, f) - # Build system prompt with context - history = self.store.get_history(session_id) if resume_session_id else [] - system_prompt = self.context_builder.build( - mode=mode, - study_id=study_id, - conversation_history=history, - ) - - # Write system prompt to temp file - prompt_path = ATOMIZER_ROOT / f".claude-prompt-{session_id}.md" - with open(prompt_path, "w") as f: - f.write(system_prompt) - - # Build environment - env = os.environ.copy() - env["ATOMIZER_MODE"] = mode - env["ATOMIZER_ROOT"] = str(ATOMIZER_ROOT) - if study_id: - env["ATOMIZER_STUDY"] = study_id - - # Start Claude Code subprocess - # Note: claude CLI with appropriate flags for JSON streaming - try: - process = await asyncio.create_subprocess_exec( - "claude", - "--print", # Non-interactive mode - "--output-format", "stream-json", - "--mcp-config", str(mcp_config_path), - "--system-prompt", str(prompt_path), - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(ATOMIZER_ROOT), - env=env, - ) - except FileNotFoundError: - # Claude CLI not found - create session without process - # Frontend will get error on first message - process = None - + # Create session object (no subprocess - stateless) session = ClaudeSession( session_id=session_id, mode=mode, study_id=study_id, - process=process, ) self.sessions[session_id] = session @@ -166,19 +124,17 @@ class SessionManager: self, session_id: str, message: str, + canvas_state: Optional[Dict] = None, ) -> AsyncGenerator[Dict, None]: """ Send a message to a session and stream the response. - Uses one-shot Claude CLI calls (claude --print) since the CLI - doesn't support persistent interactive sessions via stdin/stdout. + Uses synchronous subprocess.Popen via ThreadPoolExecutor for Windows compatibility. Args: - session_id: Session ID + session_id: The session ID message: User message - - Yields: - Response chunks (text, tool_calls, errors, done) + canvas_state: Optional canvas state (nodes, edges) from UI """ session = self.sessions.get(session_id) @@ -191,23 +147,20 @@ class SessionManager: # Store user message self.store.add_message(session_id, "user", message) - # Build context with conversation history + # Build context with conversation history AND canvas state history = self.store.get_history(session_id, limit=10) full_prompt = self.context_builder.build( mode=session.mode, study_id=session.study_id, - conversation_history=history[:-1], # Exclude current message + conversation_history=history[:-1], + canvas_state=canvas_state, # Pass canvas state for context ) full_prompt += f"\n\nUser: {message}\n\nRespond helpfully and concisely:" - # Run Claude CLI one-shot - full_response = "" - tool_calls: List[Dict] = [] - - # Build CLI arguments based on mode + # Build CLI arguments cli_args = ["claude", "--print"] - # Ensure MCP config exists for atomizer tools + # Ensure MCP config exists mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json" if not mcp_config_path.exists(): mcp_config = self._build_mcp_config(session.mode) @@ -216,56 +169,61 @@ class SessionManager: cli_args.extend(["--mcp-config", str(mcp_config_path)]) if session.mode == "user": - # User mode: Allow safe operations including report generation - # Allow Write tool for report files (STUDY_REPORT.md, *.md in study dirs) cli_args.extend([ "--allowedTools", "Read Write(**/STUDY_REPORT.md) Write(**/3_results/*.md) Bash(python:*) mcp__atomizer-tools__*" ]) else: - # Power mode: Full access cli_args.append("--dangerously-skip-permissions") - # Pass prompt via stdin (handles long prompts and special characters) cli_args.append("-") # Read from stdin + full_response = "" + tool_calls: List[Dict] = [] + try: - process = await asyncio.create_subprocess_exec( - *cli_args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(ATOMIZER_ROOT), - ) + loop = asyncio.get_event_loop() - # Send prompt via stdin - process.stdin.write(full_prompt.encode()) - await process.stdin.drain() - process.stdin.close() - await process.stdin.wait_closed() + # Run subprocess in thread pool (Windows compatible) + def run_claude(): + try: + process = subprocess.Popen( + cli_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(ATOMIZER_ROOT), + text=True, + encoding='utf-8', + errors='replace', + ) + stdout, stderr = process.communicate(input=full_prompt, timeout=300) + return { + "stdout": stdout, + "stderr": stderr, + "returncode": process.returncode, + } + except subprocess.TimeoutExpired: + process.kill() + return {"error": "Response timeout (5 minutes)"} + except FileNotFoundError: + return {"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"} + except Exception as e: + return {"error": str(e)} - # Stream stdout - buffer = "" - while True: - chunk = await process.stdout.read(100) - if not chunk: - break + result = await loop.run_in_executor(_executor, run_claude) - text = chunk.decode() - full_response += text - yield {"type": "text", "content": text} + if "error" in result: + yield {"type": "error", "message": result["error"]} + else: + full_response = result["stdout"] or "" - await process.wait() + if full_response: + yield {"type": "text", "content": full_response} - if process.returncode != 0: - stderr = await process.stderr.read() - error_msg = stderr.decode() if stderr else "Unknown error" - yield {"type": "error", "message": f"CLI error: {error_msg}"} + if result["returncode"] != 0 and result["stderr"]: + yield {"type": "error", "message": f"CLI error: {result['stderr']}"} - except asyncio.TimeoutError: - yield {"type": "error", "message": "Response timeout"} - except FileNotFoundError: - yield {"type": "error", "message": "Claude CLI not found in PATH"} except Exception as e: yield {"type": "error", "message": str(e)} @@ -285,31 +243,21 @@ class SessionManager: session_id: str, new_mode: Literal["user", "power"], ) -> ClaudeSession: - """ - Switch a session's mode (requires restart). - - Args: - session_id: Session to switch - new_mode: New mode ("user" or "power") - - Returns: - New ClaudeSession with updated mode - """ + """Switch a session's mode""" session = self.sessions.get(session_id) if not session: raise ValueError(f"Session {session_id} not found") - study_id = session.study_id + session.mode = new_mode + self.store.update_session(session_id, mode=new_mode) - # Terminate existing session - await self._terminate_session(session) + # Rebuild MCP config with new mode + mcp_config = self._build_mcp_config(new_mode) + mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json" + with open(mcp_config_path, "w") as f: + json.dump(mcp_config, f) - # Create new session with same ID but different mode - return await self.create_session( - mode=new_mode, - study_id=study_id, - resume_session_id=session_id, - ) + return session async def set_study_context( self, @@ -322,16 +270,6 @@ class SessionManager: session.study_id = study_id self.store.update_session(session_id, study_id=study_id) - # If session is alive, send context update - if session.is_alive() and session.process: - context_update = self.context_builder.build_study_context(study_id) - context_msg = f"[CONTEXT UPDATE] Study changed to: {study_id}\n\n{context_update}" - try: - session.process.stdin.write(f"{context_msg}\n".encode()) - await session.process.stdin.drain() - except Exception: - pass # Ignore errors for context updates - def get_session(self, session_id: str) -> Optional[ClaudeSession]: """Get session by ID""" return self.sessions.get(session_id) @@ -369,20 +307,11 @@ class SessionManager: }, } - async def _terminate_session(self, session: ClaudeSession): - """Terminate a Claude session and clean up""" - if session.process and session.is_alive(): - session.process.terminate() - try: - await asyncio.wait_for(session.process.wait(), timeout=5.0) - except asyncio.TimeoutError: - session.process.kill() - await session.process.wait() - - # Clean up temp files + def _cleanup_session_files(self, session_id: str): + """Clean up temp files for a session""" for pattern in [ - f".claude-mcp-{session.session_id}.json", - f".claude-prompt-{session.session_id}.md", + f".claude-mcp-{session_id}.json", + f".claude-prompt-{session_id}.md", ]: path = ATOMIZER_ROOT / pattern if path.exists(): @@ -391,9 +320,6 @@ class SessionManager: except Exception: pass - # Remove from active sessions - self.sessions.pop(session.session_id, None) - async def _cleanup_loop(self): """Periodically clean up stale sessions""" while True: @@ -404,24 +330,22 @@ class SessionManager: stale = [ sid for sid, session in list(self.sessions.items()) - if (now - session.last_active).total_seconds() > 3600 # 1 hour + if (now - session.last_active).total_seconds() > 3600 ] for sid in stale: - session = self.sessions.get(sid) - if session: - await self._terminate_session(session) + self._cleanup_session_files(sid) + self.sessions.pop(sid, None) - # Also clean up database self.store.cleanup_stale_sessions(max_age_hours=24) except asyncio.CancelledError: break except Exception: - pass # Continue cleanup loop on errors + pass -# Global instance for the application +# Global instance _session_manager: Optional[SessionManager] = None diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx index c36a39a0..83ff6c1b 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/DesignVarNode.tsx @@ -7,7 +7,7 @@ import { DesignVarNodeData } from '../../../lib/canvas/schema'; function DesignVarNodeComponent(props: NodeProps) { const { data } = props; return ( - } iconColor="text-emerald-400"> + } iconColor="text-emerald-400" inputs={0} outputs={1}> {data.expressionName ? ( {data.expressionName} ) : ( diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNode.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNode.tsx index db454ada..06770071 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNode.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNode.tsx @@ -7,7 +7,7 @@ import { ModelNodeData } from '../../../lib/canvas/schema'; function ModelNodeComponent(props: NodeProps) { const { data } = props; return ( - } iconColor="text-blue-400" inputs={0}> + } iconColor="text-blue-400" inputs={1} outputs={1}> {data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'} ); diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx index 6fb27696..c93c999a 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx @@ -1,8 +1,13 @@ /** - * File Browser - Modal for selecting NX model files + * File Browser - Modal for selecting and importing NX model files + * + * Supports three methods: + * 1. Browse existing files in studies folder + * 2. Import from Windows path (paste full path) + * 3. Upload files directly */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { X, Folder, @@ -12,6 +17,12 @@ import { Search, RefreshCw, Home, + Upload, + FolderInput, + FileUp, + CheckCircle, + AlertCircle, + Loader2, } from 'lucide-react'; interface FileBrowserProps { @@ -20,6 +31,7 @@ interface FileBrowserProps { onSelect: (filePath: string, fileType: string) => void; fileTypes?: string[]; initialPath?: string; + studyName?: string; } interface FileEntry { @@ -29,19 +41,57 @@ interface FileEntry { size?: number; } +interface RelatedFile { + name: string; + path: string; + size: number; + type: string; +} + +type TabType = 'browse' | 'import' | 'upload'; + export function FileBrowser({ isOpen, onClose, onSelect, fileTypes = ['.sim', '.prt', '.fem', '.afem'], initialPath = '', + studyName = 'new_study', }: FileBrowserProps) { + // Tab state + const [activeTab, setActiveTab] = useState('browse'); + + // Browse tab state const [currentPath, setCurrentPath] = useState(initialPath); const [files, setFiles] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // Import tab state + const [importPath, setImportPath] = useState(''); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState<{ + valid: boolean; + error?: string; + related_files?: RelatedFile[]; + } | null>(null); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + success: boolean; + imported_files?: { name: string; status: string; path?: string }[]; + } | null>(null); + + // Upload tab state + const [uploadStudyName, setUploadStudyName] = useState(studyName); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState<{ + success: boolean; + uploaded_files?: { name: string; status: string; path?: string }[]; + } | null>(null); + const fileInputRef = useRef(null); + + // Load directory for browse tab const loadDirectory = useCallback(async (path: string) => { setIsLoading(true); setError(null); @@ -67,11 +117,124 @@ export function FileBrowser({ }, [fileTypes]); useEffect(() => { - if (isOpen) { + if (isOpen && activeTab === 'browse') { loadDirectory(currentPath); } - }, [isOpen, currentPath, loadDirectory]); + }, [isOpen, currentPath, loadDirectory, activeTab]); + // Validate external path + const validatePath = async () => { + if (!importPath.trim()) return; + + setValidating(true); + setValidationResult(null); + setImportResult(null); + + try { + const res = await fetch( + `/api/files/validate-path?path=${encodeURIComponent(importPath.trim())}` + ); + const data = await res.json(); + setValidationResult(data); + } catch (e) { + setValidationResult({ valid: false, error: 'Failed to validate path' }); + } finally { + setValidating(false); + } + }; + + // Import from path + const handleImport = async () => { + if (!validationResult?.valid) return; + + setImporting(true); + setImportResult(null); + + try { + const res = await fetch('/api/files/import-from-path', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_path: importPath.trim(), + study_name: uploadStudyName, + copy_related: true, + }), + }); + const data = await res.json(); + + if (data.success) { + setImportResult(data); + // Find the main file that was imported + const mainFile = data.imported_files?.find( + (f: { name: string; status: string; path?: string }) => + f.status === 'imported' || f.status === 'skipped' + ); + if (mainFile?.path) { + // Auto-select the imported file + const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase(); + onSelect(mainFile.path, ext); + setTimeout(onClose, 1500); + } + } else { + setImportResult({ success: false }); + } + } catch (e) { + console.error('Import failed:', e); + setImportResult({ success: false }); + } finally { + setImporting(false); + } + }; + + // Handle file upload + const handleFileUpload = async (e: React.ChangeEvent) => { + const selectedFiles = e.target.files; + if (!selectedFiles || selectedFiles.length === 0) return; + + setUploading(true); + setUploadResult(null); + + const formData = new FormData(); + for (let i = 0; i < selectedFiles.length; i++) { + formData.append('files', selectedFiles[i]); + } + + try { + const res = await fetch( + `/api/files/upload?study_name=${encodeURIComponent(uploadStudyName)}`, + { + method: 'POST', + body: formData, + } + ); + const data = await res.json(); + + if (data.success) { + setUploadResult(data); + // Find the first uploaded file + const mainFile = data.uploaded_files?.find( + (f: { name: string; status: string; path?: string }) => f.status === 'uploaded' + ); + if (mainFile?.path) { + const ext = '.' + mainFile.name.split('.').pop()?.toLowerCase(); + onSelect(mainFile.path, ext); + setTimeout(onClose, 1500); + } + } else { + setUploadResult({ success: false }); + } + } catch (e) { + console.error('Upload failed:', e); + setUploadResult({ success: false }); + } finally { + setUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + // Browse tab handlers const handleSelect = (file: FileEntry) => { if (file.isDirectory) { setCurrentPath(file.path); @@ -117,124 +280,407 @@ export function FileBrowser({ - {/* Search */} -
-
- - setSearchTerm(e.target.value)} - className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg - text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500" - /> -
-
- Looking for: - {fileTypes.map((t) => ( - - {t} - - ))} -
-
- - {/* Path breadcrumb */} -
+ {/* Tabs */} +
+ + - {pathParts.map((part, i) => ( - - - - - ))}
- {/* File list */} -
- {isLoading ? ( -
- - Loading... -
- ) : error ? ( -
- {error} -
- ) : filteredFiles.length === 0 ? ( -
- {searchTerm ? 'No matching files found' : 'No model files in this directory'} -
- ) : ( -
- {/* Show parent directory link if not at root */} - {currentPath && ( + {/* Tab Content */} +
+ {/* Browse Tab */} + {activeTab === 'browse' && ( + <> + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg + text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500" + /> +
+
+ Looking for: + {fileTypes.map((t) => ( + + {t} + + ))} +
+
+ + {/* Path breadcrumb */} +
+ {pathParts.map((part, i) => ( + + + + + ))} +
+ + {/* File list */} +
+ {isLoading ? ( +
+ + Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : filteredFiles.length === 0 ? ( +
+ {searchTerm ? 'No matching files found' : 'No model files in this directory'} +
+ ) : ( +
+ {currentPath && ( + + )} + + {filteredFiles.map((file) => ( + + ))} +
+ )} +
+ + )} + + {/* Import Tab */} + {activeTab === 'import' && ( +
+
+ +

+ Paste the full path to your NX model file (e.g., C:\Models\bracket_sim1.sim) +

+
+ { + setImportPath(e.target.value); + setValidationResult(null); + setImportResult(null); + }} + placeholder="C:\path\to\model.sim" + className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg + text-white placeholder-dark-500 font-mono text-sm + focus:outline-none focus:border-primary-500" + /> + +
+
+ + {/* Validation Result */} + {validationResult && ( +
+ {validationResult.valid ? ( + <> +
+ + Path validated +
+ {validationResult.related_files && validationResult.related_files.length > 0 && ( +
+

Related files to import:

+
+ {validationResult.related_files.map((f) => ( +
+ + {f.name} + {f.type} +
+ ))} +
+
+ )} + + ) : ( +
+ + {validationResult.error} +
+ )} +
)} - {filteredFiles.map((file) => ( + {/* Study Name for Import */} + {validationResult?.valid && ( +
+ + setUploadStudyName(e.target.value)} + placeholder="study_name" + className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg + text-white placeholder-dark-500 text-sm + focus:outline-none focus:border-primary-500" + /> +
+ )} + + {/* Import Button */} + {validationResult?.valid && ( - ))} + )} + + {/* Import Result */} + {importResult && ( +
+ {importResult.success ? ( + <> +
+ + Import successful! +
+ {importResult.imported_files && ( +
+ {importResult.imported_files.map((f) => ( +
+ + {f.status === 'imported' ? '✓' : '○'} + + {f.name} + {f.status} +
+ ))} +
+ )} + + ) : ( +
+ + Import failed +
+ )} +
+ )} +
+ )} + + {/* Upload Tab */} + {activeTab === 'upload' && ( +
+
+ + setUploadStudyName(e.target.value)} + placeholder="study_name" + className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg + text-white placeholder-dark-500 text-sm + focus:outline-none focus:border-primary-500" + /> +
+ + {/* Upload Area */} +
fileInputRef.current?.click()} + className="border-2 border-dashed border-dark-600 rounded-xl p-8 + hover:border-primary-500/50 hover:bg-dark-800/50 + transition-colors cursor-pointer" + > +
+ +

+ {uploading ? 'Uploading...' : 'Click to select files'} +

+

+ or drag and drop NX model files +

+

+ Supported: .prt, .sim, .fem, .afem +

+
+ +
+ + {/* Upload Progress/Result */} + {uploading && ( +
+ + Uploading files... +
+ )} + + {uploadResult && ( +
+ {uploadResult.success ? ( + <> +
+ + Upload successful! +
+ {uploadResult.uploaded_files && ( +
+ {uploadResult.uploaded_files.map((f) => ( +
+ + {f.status === 'uploaded' ? '✓' : '✗'} + + {f.name} + {f.status} +
+ ))} +
+ )} + + ) : ( +
+ + Upload failed +
+ )} +
+ )}
)}
{/* Footer */}
- + {activeTab === 'browse' && ( + + )} + {activeTab !== 'browse' &&
} +
+ + +
+ + {/* Validation Toast */} + {showValidation && ( +
+ setShowValidation(false)} + /> +
+ )} +
+
+ + {/* Right: Config Panel or Chat */} + {(selectedNode || showChat) && ( +
+ {selectedNode && !showChat ? ( + + ) : ( + setShowChat(false)} /> + )} +
+ )} +
+ ); +} + +export function AtomizerCanvas() { + return ( + + + + ); +} +``` + +## T2.2 - Update CanvasView.tsx for Full Screen Layout + +**File:** `atomizer-dashboard/frontend/src/pages/CanvasView.tsx` + +```tsx +import { useState } from 'react'; +import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; +import { TemplateSelector } from '../components/canvas/panels/TemplateSelector'; +import { ConfigImporter } from '../components/canvas/panels/ConfigImporter'; +import { useCanvasStore } from '../hooks/useCanvasStore'; +import { + LayoutTemplate, + FileInput, + Trash2, + FolderOpen, +} from 'lucide-react'; + +export function CanvasView() { + const [showTemplates, setShowTemplates] = useState(false); + const [showImporter, setShowImporter] = useState(false); + const { clear, nodes } = useCanvasStore(); + + return ( +
+ {/* Compact Header */} +
+
+

Canvas Builder

+ + {nodes.length} node{nodes.length !== 1 ? 's' : ''} + +
+
+ + +
+ +
+
+ + {/* Canvas - Takes all remaining space */} +
+ +
+ + {/* Modals */} + {showTemplates && ( + setShowTemplates(false)} /> + )} + {showImporter && ( + setShowImporter(false)} /> + )} +
+ ); +} +``` + +--- + +# PHASE 3: Auto-Load from Optimization Config + +When a study is loaded, auto-populate the canvas from its optimization_config.json. + +## T3.1 - Add Backend Endpoint for Study Config + +**File:** `atomizer-dashboard/backend/api/routes/studies.py` + +Add this endpoint (find the router and add): + +```python +@router.get("/{study_path:path}/config") +async def get_study_config(study_path: str): + """Load optimization_config.json from a study directory.""" + import json + from pathlib import Path + + # Resolve study path + studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies" + config_path = studies_root / study_path / "optimization_config.json" + + if not config_path.exists(): + # Try with 1_config subdirectory + config_path = studies_root / study_path / "1_config" / "optimization_config.json" + + if not config_path.exists(): + raise HTTPException(status_code=404, detail=f"Config not found for study: {study_path}") + + try: + with open(config_path, 'r') as f: + config = json.load(f) + return config + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") +``` + +## T3.2 - Add Backend Endpoint to List Studies + +```python +@router.get("/") +async def list_studies(): + """List all available studies with their status.""" + from pathlib import Path + import json + + studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies" + studies = [] + + for category in studies_root.iterdir(): + if not category.is_dir() or category.name.startswith('.'): + continue + for study_dir in category.iterdir(): + if not study_dir.is_dir(): + continue + + # Find config + config_path = study_dir / "optimization_config.json" + if not config_path.exists(): + config_path = study_dir / "1_config" / "optimization_config.json" + + # Find database + db_path = study_dir / "3_results" / "study.db" + if not db_path.exists(): + db_path = study_dir / "2_results" / "study.db" + + trial_count = 0 + if db_path.exists(): + try: + import sqlite3 + conn = sqlite3.connect(str(db_path)) + cursor = conn.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'") + trial_count = cursor.fetchone()[0] + conn.close() + except: + pass + + studies.append({ + "path": f"{category.name}/{study_dir.name}", + "name": study_dir.name, + "category": category.name, + "has_config": config_path.exists(), + "trial_count": trial_count, + }) + + return {"studies": studies} +``` + +## T3.3 - Update useCanvasStore to Load from Config + +**File:** `atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts` + +Add/update the `loadFromConfig` function: + +```typescript +loadFromConfig: (config: OptimizationConfig) => { + const nodes: Node[] = []; + const edges: Edge[] = []; + let nodeId = 0; + const getId = () => `node_${++nodeId}`; + + // Layout positions + const colX = [50, 250, 450, 650, 850]; + let row = 0; + const getY = () => 50 + (row++) * 120; + + // Model node + if (config.nx_model?.prt_path || config.nx_model?.sim_path) { + const modelId = getId(); + nodes.push({ + id: modelId, + type: 'model', + position: { x: colX[0], y: 100 }, + data: { + type: 'model', + label: 'Model', + configured: true, + filePath: config.nx_model.sim_path || config.nx_model.prt_path, + fileType: config.nx_model.sim_path ? 'sim' : 'prt', + }, + }); + } + + // Solver node + if (config.solver) { + const solverId = getId(); + nodes.push({ + id: solverId, + type: 'solver', + position: { x: colX[1], y: 100 }, + data: { + type: 'solver', + label: 'Solver', + configured: true, + solverType: `SOL${config.solver.solution_type}`, + }, + }); + } + + // Design variables + row = 0; + config.design_variables?.forEach((dv, i) => { + const dvId = getId(); + nodes.push({ + id: dvId, + type: 'designVar', + position: { x: colX[0], y: 250 + i * 100 }, + data: { + type: 'designVar', + label: dv.name, + configured: true, + expressionName: dv.nx_expression || dv.name, + minValue: dv.lower_bound, + maxValue: dv.upper_bound, + unit: dv.unit, + }, + }); + }); + + // Extractors from objectives + const extractorIds: Record = {}; + config.objectives?.forEach((obj, i) => { + if (!extractorIds[obj.extractor_id]) { + const extId = getId(); + extractorIds[obj.extractor_id] = extId; + nodes.push({ + id: extId, + type: 'extractor', + position: { x: colX[2], y: 100 + i * 100 }, + data: { + type: 'extractor', + label: obj.extractor_id, + configured: true, + extractorId: obj.extractor_id, + extractorName: obj.name, + }, + }); + } + }); + + // Objectives + config.objectives?.forEach((obj, i) => { + const objId = getId(); + nodes.push({ + id: objId, + type: 'objective', + position: { x: colX[3], y: 100 + i * 100 }, + data: { + type: 'objective', + label: obj.name, + configured: true, + name: obj.name, + direction: obj.direction, + weight: obj.weight, + }, + }); + + // Connect extractor to objective + if (extractorIds[obj.extractor_id]) { + edges.push({ + id: `e_${extractorIds[obj.extractor_id]}_${objId}`, + source: extractorIds[obj.extractor_id], + target: objId, + }); + } + }); + + // Constraints + config.constraints?.forEach((con, i) => { + const conId = getId(); + nodes.push({ + id: conId, + type: 'constraint', + position: { x: colX[3], y: 300 + i * 100 }, + data: { + type: 'constraint', + label: con.name, + configured: true, + name: con.name, + operator: con.type === 'upper' ? '<=' : '>=', + value: con.upper_bound ?? con.lower_bound, + }, + }); + }); + + // Algorithm + if (config.optimization) { + const algoId = getId(); + nodes.push({ + id: algoId, + type: 'algorithm', + position: { x: colX[4], y: 150 }, + data: { + type: 'algorithm', + label: 'Algorithm', + configured: true, + method: config.optimization.sampler || 'TPE', + maxTrials: config.optimization.n_trials || 100, + }, + }); + } + + // Surrogate + if (config.surrogate?.enabled) { + const surId = getId(); + nodes.push({ + id: surId, + type: 'surrogate', + position: { x: colX[4], y: 300 }, + data: { + type: 'surrogate', + label: 'Surrogate', + configured: true, + enabled: true, + modelType: config.surrogate.type || 'MLP', + minTrials: config.surrogate.min_trials || 20, + }, + }); + } + + set({ + nodes, + edges, + selectedNode: null, + validation: { valid: false, errors: [], warnings: [] }, + }); + nodeIdCounter = nodeId; +}, +``` + +## T3.4 - Update ConfigImporter with Study Browser + +**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/ConfigImporter.tsx` + +```tsx +import { useState, useEffect } from 'react'; +import { useCanvasStore } from '../../../hooks/useCanvasStore'; +import { + X, + Upload, + FileCode, + FolderOpen, + Search, + Check, + Loader2, + AlertCircle, +} from 'lucide-react'; + +interface Study { + path: string; + name: string; + category: string; + has_config: boolean; + trial_count: number; +} + +interface ConfigImporterProps { + onClose: () => void; +} + +export function ConfigImporter({ onClose }: ConfigImporterProps) { + const [activeTab, setActiveTab] = useState<'file' | 'paste' | 'study'>('study'); + const [jsonInput, setJsonInput] = useState(''); + const [studies, setStudies] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedStudy, setSelectedStudy] = useState(null); + + const { loadFromConfig } = useCanvasStore(); + + // Load studies on mount + useEffect(() => { + loadStudies(); + }, []); + + const loadStudies = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/studies/'); + if (!res.ok) throw new Error('Failed to load studies'); + const data = await res.json(); + setStudies(data.studies || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load studies'); + } finally { + setLoading(false); + } + }; + + const loadStudyConfig = async (studyPath: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/studies/${encodeURIComponent(studyPath)}/config`); + if (!res.ok) throw new Error('Config not found'); + const config = await res.json(); + loadFromConfig(config); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load config'); + } finally { + setLoading(false); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const config = JSON.parse(event.target?.result as string); + loadFromConfig(config); + onClose(); + } catch { + setError('Invalid JSON file'); + } + }; + reader.readAsText(file); + }; + + const handlePasteImport = () => { + try { + const config = JSON.parse(jsonInput); + loadFromConfig(config); + onClose(); + } catch { + setError('Invalid JSON'); + } + }; + + const filteredStudies = studies.filter(s => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) || + s.category.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const tabs = [ + { id: 'study', label: 'Load Study', icon: FolderOpen }, + { id: 'file', label: 'Upload File', icon: Upload }, + { id: 'paste', label: 'Paste JSON', icon: FileCode }, + ] as const; + + return ( +
+
+ {/* Header */} +
+

Import Configuration

+ +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {error && ( +
+ + {error} +
+ )} + + {activeTab === 'study' && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search studies..." + className="w-full pl-9 pr-3 py-2 bg-dark-800 border border-dark-600 rounded-lg + text-white placeholder-dark-500 text-sm + focus:border-primary-500 focus:ring-1 focus:ring-primary-500" + /> +
+ + {/* Study List */} +
+ {loading ? ( +
+ + Loading studies... +
+ ) : filteredStudies.length === 0 ? ( +
+ No studies found +
+ ) : ( + filteredStudies.map((study) => ( + + )) + )} +
+
+ )} + + {activeTab === 'file' && ( +
+ +
+ )} + + {activeTab === 'paste' && ( +
+