10 Commits

Author SHA1 Message Date
Antoine
96b196de58 feat: Add Zernike GNN surrogate module and M1 mirror V12/V13 studies
This commit introduces the GNN-based surrogate for Zernike mirror optimization
and the M1 mirror study progression from V12 (GNN validation) to V13 (pure NSGA-II).

## GNN Surrogate Module (optimization_engine/gnn/)

New module for Graph Neural Network surrogate prediction of mirror deformations:

- `polar_graph.py`: PolarMirrorGraph - fixed 3000-node polar grid structure
- `zernike_gnn.py`: ZernikeGNN with design-conditioned message passing
- `differentiable_zernike.py`: GPU-accelerated Zernike fitting and objectives
- `train_zernike_gnn.py`: ZernikeGNNTrainer with multi-task loss
- `gnn_optimizer.py`: ZernikeGNNOptimizer for turbo mode (~900k trials/hour)
- `extract_displacement_field.py`: OP2 to HDF5 field extraction
- `backfill_field_data.py`: Extract fields from existing FEA trials

Key innovation: Design-conditioned convolutions that modulate message passing
based on structural design parameters, enabling accurate field prediction.

## M1 Mirror Studies

### V12: GNN Field Prediction + FEA Validation
- Zernike GNN trained on V10/V11 FEA data (238 samples)
- Turbo mode: 5000 GNN predictions → top candidates → FEA validation
- Calibration workflow for GNN-to-FEA error correction
- Scripts: run_gnn_turbo.py, validate_gnn_best.py, compute_full_calibration.py

### V13: Pure NSGA-II FEA (Ground Truth)
- Seeds 217 FEA trials from V11+V12
- Pure multi-objective NSGA-II without any surrogate
- Establishes ground-truth Pareto front for GNN accuracy evaluation
- Narrowed blank_backface_angle range to [4.0, 5.0]

## Documentation Updates

- SYS_14: Added Zernike GNN section with architecture diagrams
- CLAUDE.md: Added GNN module reference and quick start
- V13 README: Study documentation with seeding strategy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:44:04 -05:00
Antoine
c6f39bfd6c docs: Update protocol docs and method selector improvements
- SYS_12: Add extractor library updates
- SYS_15: Add method selector documentation updates
- method_selector.py: Minor improvements to method selection logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:10:45 -05:00
Antoine
0e04457539 feat: Implement Agentic Architecture for robust session workflows
Phase 1 - Session Bootstrap:
- Add .claude/ATOMIZER_CONTEXT.md as single entry point for new sessions
- Add study state detection and task routing

Phase 2 - Code Deduplication:
- Add optimization_engine/base_runner.py (ConfigDrivenRunner)
- Add optimization_engine/generic_surrogate.py (ConfigDrivenSurrogate)
- Add optimization_engine/study_state.py for study detection
- Add optimization_engine/templates/ with registry and templates
- Studies now require ~50 lines instead of ~300

Phase 3 - Skill Consolidation:
- Add YAML frontmatter metadata to all skills (versioning, dependencies)
- Consolidate create-study.md into core/study-creation-core.md
- Update 00_BOOTSTRAP.md, 01_CHEATSHEET.md, 02_CONTEXT_LOADER.md

Phase 4 - Self-Expanding Knowledge:
- Add optimization_engine/auto_doc.py for auto-generating documentation
- Generate docs/generated/EXTRACTORS.md (27 extractors documented)
- Generate docs/generated/TEMPLATES.md (6 templates)
- Generate docs/generated/EXTRACTOR_CHEATSHEET.md

Phase 5 - Subagent Implementation:
- Add .claude/commands/study-builder.md (create studies)
- Add .claude/commands/nx-expert.md (NX Open API)
- Add .claude/commands/protocol-auditor.md (config validation)
- Add .claude/commands/results-analyzer.md (results analysis)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 14:52:25 -05:00
Antoine
6cf12d9344 feat: Add NN Quality Assessor with relative accuracy thresholds
The Method Selector now uses relative accuracy thresholds to assess
NN suitability by comparing NN error to problem variability (CV ratio).

NNQualityAssessor features:
- Physics-based objective classification (linear, smooth, nonlinear, chaotic)
- CV ratio computation: nn_error / coefficient_of_variation
- Turbo suitability score based on relative thresholds
- Data collection from validation_report.json, turbo_report.json, and study.db

Quality thresholds by objective type:
- Linear (mass, volume): max 2% error, CV ratio < 0.5
- Smooth (frequency): max 5% error, CV ratio < 1.0
- Nonlinear (stress, stiffness): max 10% error, CV ratio < 2.0
- Chaotic (contact, buckling): max 20% error, CV ratio < 3.0

CLI output now includes:
- Per-objective NN quality table with error, CV, ratio, and quality indicator
- Turbo suitability and hybrid suitability percentages
- Warnings when NN error exceeds physics-based thresholds

Updated SYS_15_METHOD_SELECTOR.md to v2.0 with full NN Quality Assessment documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 06:38:25 -05:00
Antoine
3e9488d9f0 feat: Add Adaptive Method Selector for intelligent optimization strategy
The AMS analyzes optimization problems and recommends the best method:
- ProblemProfiler: Static analysis of config (dimensions, objectives, constraints)
- EarlyMetricsCollector: Dynamic analysis from FEA trials (smoothness, correlations)
- AdaptiveMethodSelector: Rule-based scoring for method recommendations
- RuntimeAdvisor: Mid-run monitoring for method pivots

Key features:
- Analyzes problem characteristics (n_variables, n_objectives, constraints)
- Computes response smoothness and variable sensitivity from trial data
- Recommends TURBO, HYBRID_LOOP, PURE_FEA, or GNN_FIELD
- Provides confidence scores and suggested parameters
- CLI: python -m optimization_engine.method_selector <config> [db]

Documentation:
- Add SYS_15_METHOD_SELECTOR.md protocol
- Update CLAUDE.md with new system protocol reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 05:51:49 -05:00
Antoine
602560c46a feat: Add MLP surrogate with Turbo Mode for 100x faster optimization
Neural Acceleration (MLP Surrogate):
- Add run_nn_optimization.py with hybrid FEA/NN workflow
- MLP architecture: 4-layer (64->128->128->64) with BatchNorm/Dropout
- Three workflow modes:
  - --all: Sequential export->train->optimize->validate
  - --hybrid-loop: Iterative Train->NN->Validate->Retrain cycle
  - --turbo: Aggressive single-best validation (RECOMMENDED)
- Turbo mode: 5000 NN trials + 50 FEA validations in ~12 minutes
- Separate nn_study.db to avoid overloading dashboard

Performance Results (bracket_pareto_3obj study):
- NN prediction errors: mass 1-5%, stress 1-4%, stiffness 5-15%
- Found minimum mass designs at boundary (angle~30deg, thick~30mm)
- 100x speedup vs pure FEA exploration

Protocol Operating System:
- Add .claude/skills/ with Bootstrap, Cheatsheet, Context Loader
- Add docs/protocols/ with operations (OP_01-06) and system (SYS_10-14)
- Update SYS_14_NEURAL_ACCELERATION.md with MLP Turbo Mode docs

NX Automation:
- Add optimization_engine/hooks/ for NX CAD/CAE automation
- Add study_wizard.py for guided study creation
- Fix FEM mesh update: load idealized part before UpdateFemodel()

New Study:
- bracket_pareto_3obj: 3-objective Pareto (mass, stress, stiffness)
- 167 FEA trials + 5000 NN trials completed
- Demonstrates full hybrid workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 20:01:59 -05:00
Antoine
0cb2808c44 feat: Add Phase 2 & 3 physics extractors for multi-physics optimization
Phase 2 - Structural Analysis:
- extract_principal_stress: σ1, σ2, σ3 principal stresses from OP2
- extract_strain_energy: Element and total strain energy
- extract_spc_forces: Reaction forces at boundary conditions

Phase 3 - Multi-Physics:
- extract_temperature: Nodal temperatures from thermal OP2 (SOL 153/159)
- extract_temperature_gradient: Thermal gradient approximation
- extract_heat_flux: Element heat flux from thermal analysis
- extract_modal_mass: Modal effective mass from F06 (SOL 103)
- get_first_frequency: Convenience function for first natural frequency

Documentation:
- Updated SYS_12_EXTRACTOR_LIBRARY.md with E12-E18 specifications
- Updated NX_OPEN_AUTOMATION_ROADMAP.md marking Phase 3 complete
- Added test_phase3_extractors.py for validation

All extractors follow consistent API pattern returning Dict with
success, data, and error fields for robust error handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 13:40:14 -05:00
Antoine
5fb94fdf01 feat: Add Analysis page, run comparison, notifications, and config editor
Dashboard enhancements:
- Add Analysis page with tabs: Overview, Parameters, Pareto, Correlations, Constraints, Surrogate, Runs
- Add PlotlyCorrelationHeatmap for parameter-objective correlation analysis
- Add PlotlyFeasibilityChart for constraint satisfaction visualization
- Add PlotlySurrogateQuality for FEA vs NN prediction comparison
- Add PlotlyRunComparison for comparing optimization runs within a study

Real-time improvements:
- Replace watchdog file-watching with SQLite database polling for better Windows reliability
- Add DatabasePoller class with 2-second polling interval
- Enhanced WebSocket messages: trial_completed, new_best, pareto_update, progress

Desktop notifications:
- Add useNotifications hook using Web Notifications API
- Add NotificationSettings toggle component
- Notify users when new best solutions are found

Config editor:
- Add PUT /studies/{study_id}/config endpoint with auto-backup
- Add ConfigEditor modal with tabs: General, Variables, Objectives, Settings, JSON
- Prevents editing while optimization is running

Enhanced Pareto visualization:
- Add dark mode styling with transparent backgrounds
- Add stats bar showing Pareto, FEA, NN, and infeasible counts
- Add Pareto front connecting line for 2D view
- Add table showing top 10 Pareto-optimal solutions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:57:20 -05:00
Antoine
5c660ff270 feat: Add session management and global Claude terminal
Phase 1 - Accurate study status detection:
- Add is_optimization_running() to check for active processes
- Add get_accurate_study_status() with proper status logic
- Status now: not_started, running, paused, completed
- Add "paused" status styling (orange) to Home page

Phase 2 - Global Claude terminal:
- Create ClaudeTerminalContext for app-level state
- Create GlobalClaudeTerminal floating component
- Terminal persists across page navigation
- Shows green indicator when connected
- Remove inline terminal from Dashboard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 12:56:34 -05:00
Antoine
fb2d06236a feat: Improve dashboard layout and Claude terminal context
- Reorganize dashboard: control panel on top, charts stacked vertically
- Add Set Context button to Claude terminal for study awareness
- Add conda environment instructions to CLAUDE.md
- Fix STUDY_REPORT.md location in generate-report.md skill
- Claude terminal now sends study context with skills reminder

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 20:59:31 -05:00
150 changed files with 55616 additions and 3388 deletions

371
.claude/ATOMIZER_CONTEXT.md Normal file
View File

@@ -0,0 +1,371 @@
# Atomizer Session Context
<!--
ATOMIZER CONTEXT LOADER v1.0
This file is the SINGLE SOURCE OF TRUTH for new Claude sessions.
Load this FIRST on every new session, then route to specific protocols.
-->
## What is Atomizer?
**Atomizer** is an LLM-first FEA (Finite Element Analysis) optimization framework. Users describe optimization problems in natural language, and Claude orchestrates the entire workflow: model introspection, config generation, optimization execution, and results analysis.
**Philosophy**: Talk, don't click. Engineers describe what they want; AI handles the rest.
---
## Session Initialization Checklist
On EVERY new session, perform these steps:
### Step 1: Identify Working Directory
```
If in: c:\Users\Antoine\Atomizer\ → Project root (full capabilities)
If in: c:\Users\Antoine\Atomizer\studies\* → Inside a study (load study context)
If elsewhere: → Limited context (warn user)
```
### Step 2: Detect Study Context
If working directory contains `optimization_config.json`:
1. Read the config to understand the study
2. Check `2_results/study.db` for optimization status
3. Summarize study state to user
**Python utility for study detection**:
```bash
# Get study state for current directory
python -m optimization_engine.study_state .
# Get all studies in Atomizer
python -c "from optimization_engine.study_state import get_all_studies; from pathlib import Path; [print(f'{s[\"study_name\"]}: {s[\"status\"]}') for s in get_all_studies(Path('.'))]"
```
### Step 3: Route to Task Protocol
Use keyword matching to load appropriate context:
| User Intent | Keywords | Load Protocol | Action |
|-------------|----------|---------------|--------|
| Create study | "create", "new", "set up", "optimize" | OP_01 + SYS_12 | Launch study builder |
| Run optimization | "run", "start", "execute", "trials" | OP_02 + SYS_15 | Execute optimization |
| Check progress | "status", "progress", "how many" | OP_03 | Query study.db |
| Analyze results | "results", "best", "Pareto", "analyze" | OP_04 | Generate analysis |
| Neural acceleration | "neural", "surrogate", "turbo", "NN" | SYS_14 + SYS_15 | Method selection |
| NX/CAD help | "NX", "model", "mesh", "expression" | MCP + nx-docs | Use Siemens MCP |
| Troubleshoot | "error", "failed", "fix", "debug" | OP_06 | Diagnose issues |
---
## Quick Reference
### Core Commands
```bash
# Optimization workflow
python run_optimization.py --discover # 1 trial - model introspection
python run_optimization.py --validate # 1 trial - verify pipeline
python run_optimization.py --test # 3 trials - quick sanity check
python run_optimization.py --run --trials 50 # Full optimization
python run_optimization.py --resume # Continue existing study
# Neural acceleration
python run_nn_optimization.py --turbo --nn-trials 5000 # Fast NN exploration
python -m optimization_engine.method_selector config.json study.db # Get recommendation
# Dashboard
cd atomizer-dashboard && npm run dev # Start at http://localhost:3003
```
### Study Structure (100% standardized)
```
study_name/
├── optimization_config.json # Problem definition
├── run_optimization.py # FEA optimization script
├── run_nn_optimization.py # Neural acceleration (optional)
├── 1_setup/
│ └── model/
│ ├── Model.prt # NX part file
│ ├── Model_sim1.sim # NX simulation
│ └── Model_fem1.fem # FEM definition
└── 2_results/
├── study.db # Optuna database
├── optimization.log # Logs
└── turbo_report.json # NN results (if run)
```
### Available Extractors (SYS_12)
| ID | Physics | Function | Notes |
|----|---------|----------|-------|
| E1 | Displacement | `extract_displacement()` | mm |
| E2 | Frequency | `extract_frequency()` | Hz |
| E3 | Von Mises Stress | `extract_solid_stress()` | **Specify element_type!** |
| E4 | BDF Mass | `extract_mass_from_bdf()` | kg |
| E5 | CAD Mass | `extract_mass_from_expression()` | kg |
| E8-10 | Zernike WFE | `extract_zernike_*()` | nm (mirrors) |
| E12-14 | Phase 2 | Principal stress, strain energy, SPC forces |
| E15-18 | Phase 3 | Temperature, heat flux, modal mass |
**Critical**: For stress extraction, specify element type:
- Shell (CQUAD4): `element_type='cquad4'`
- Solid (CTETRA): `element_type='ctetra'`
---
## Protocol System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Layer 0: BOOTSTRAP (.claude/skills/00_BOOTSTRAP.md) │
│ Purpose: Task routing, quick reference │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: OPERATIONS (docs/protocols/operations/OP_*.md) │
│ OP_01: Create Study OP_02: Run Optimization │
│ OP_03: Monitor OP_04: Analyze Results │
│ OP_05: Export Data OP_06: Troubleshoot │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2: SYSTEM (docs/protocols/system/SYS_*.md) │
│ SYS_10: IMSO (single-obj) SYS_11: Multi-objective │
│ SYS_12: Extractors SYS_13: Dashboard │
│ SYS_14: Neural Accel SYS_15: Method Selector │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3: EXTENSIONS (docs/protocols/extensions/EXT_*.md) │
│ EXT_01: Create Extractor EXT_02: Create Hook │
│ EXT_03: Create Protocol EXT_04: Create Skill │
└─────────────────────────────────────────────────────────────────┘
```
---
## Subagent Routing
For complex tasks, Claude should spawn specialized subagents:
| Task | Subagent Type | Context to Load |
|------|---------------|-----------------|
| Create study from description | `general-purpose` | core/study-creation-core.md, SYS_12 |
| Explore codebase | `Explore` | (built-in) |
| Plan architecture | `Plan` | (built-in) |
| NX API lookup | `general-purpose` | Use MCP siemens-docs tools |
---
## Environment Setup
**CRITICAL**: Always use the `atomizer` conda environment:
```bash
conda activate atomizer
python run_optimization.py
```
**DO NOT**:
- Install packages with pip/conda (everything is installed)
- Create new virtual environments
- Use system Python
**NX Open Requirements**:
- NX 2506 installed at `C:\Program Files\Siemens\NX2506\`
- Use `run_journal.exe` for NX automation
---
## Template Registry
Available study templates for quick creation:
| Template | Objectives | Extractors | Example Study |
|----------|------------|------------|---------------|
| `multi_objective_structural` | mass, stress, stiffness | E1, E3, E4 | bracket_pareto_3obj |
| `frequency_optimization` | frequency, mass | E2, E4 | uav_arm_optimization |
| `mirror_wavefront` | Zernike RMS | E8-E10 | m1_mirror_zernike |
| `shell_structural` | mass, stress | E1, E3, E4 | beam_pareto_4var |
| `thermal_structural` | temperature, stress | E3, E15 | (template only) |
**Python utility for templates**:
```bash
# List all templates
python -m optimization_engine.templates
# Get template details in code
from optimization_engine.templates import get_template, suggest_template
template = suggest_template(n_objectives=2, physics_type="structural")
```
---
## Auto-Documentation Protocol
When Claude creates/modifies extractors or protocols:
1. **Code change** → Update `optimization_engine/extractors/__init__.py`
2. **Doc update** → Update `SYS_12_EXTRACTOR_LIBRARY.md`
3. **Quick ref** → Update `.claude/skills/01_CHEATSHEET.md`
4. **Commit** → Use structured message: `feat: Add E{N} {name} extractor`
---
## Key Principles
1. **Conversation first** - Don't ask user to edit JSON manually
2. **Validate everything** - Catch errors before FEA runs
3. **Explain decisions** - Say why you chose a sampler/protocol
4. **NEVER modify master files** - Copy NX files to study directory
5. **ALWAYS reuse code** - Check extractors before writing new code
6. **Proactive documentation** - Update docs after code changes
---
## Base Classes (Phase 2 - Code Deduplication)
New studies should use these base classes instead of duplicating code:
### ConfigDrivenRunner (FEA Optimization)
```python
# run_optimization.py - Now just ~30 lines instead of ~300
from optimization_engine.base_runner import ConfigDrivenRunner
runner = ConfigDrivenRunner(__file__)
runner.run() # Handles --discover, --validate, --test, --run
```
### ConfigDrivenSurrogate (Neural Acceleration)
```python
# run_nn_optimization.py - Now just ~30 lines instead of ~600
from optimization_engine.generic_surrogate import ConfigDrivenSurrogate
surrogate = ConfigDrivenSurrogate(__file__)
surrogate.run() # Handles --train, --turbo, --all
```
**Templates**: `optimization_engine/templates/run_*_template.py`
---
## Skill Registry (Phase 3 - Consolidated Skills)
All skills now have YAML frontmatter with metadata for versioning and dependency tracking.
| Skill ID | Name | Type | Version | Location |
|----------|------|------|---------|----------|
| SKILL_000 | Bootstrap | bootstrap | 2.0 | `.claude/skills/00_BOOTSTRAP.md` |
| SKILL_001 | Cheatsheet | reference | 2.0 | `.claude/skills/01_CHEATSHEET.md` |
| SKILL_002 | Context Loader | loader | 2.0 | `.claude/skills/02_CONTEXT_LOADER.md` |
| SKILL_CORE_001 | Study Creation Core | core | 2.4 | `.claude/skills/core/study-creation-core.md` |
### Deprecated Skills
| Old File | Reason | Replacement |
|----------|--------|-------------|
| `create-study.md` | Duplicate of core skill | `core/study-creation-core.md` |
### Skill Metadata Format
All skills use YAML frontmatter:
```yaml
---
skill_id: SKILL_XXX
version: X.X
last_updated: YYYY-MM-DD
type: bootstrap|reference|loader|core|module
code_dependencies:
- path/to/code.py
requires_skills:
- SKILL_YYY
replaces: old-skill.md # if applicable
---
```
---
## Subagent Commands (Phase 5 - Specialized Agents)
Atomizer provides specialized subagent commands for complex tasks:
| Command | Purpose | When to Use |
|---------|---------|-------------|
| `/study-builder` | Create new optimization studies | "create study", "set up optimization" |
| `/nx-expert` | NX Open API help, model automation | "how to in NX", "update mesh" |
| `/protocol-auditor` | Validate configs and code quality | "validate config", "check study" |
| `/results-analyzer` | Analyze optimization results | "analyze results", "best solution" |
### Command Files
```
.claude/commands/
├── study-builder.md # Create studies from descriptions
├── nx-expert.md # NX Open / Simcenter expertise
├── protocol-auditor.md # Config and code validation
├── results-analyzer.md # Results analysis and reporting
└── dashboard.md # Dashboard control
```
### Subagent Invocation Pattern
```python
# Master agent delegates to specialized subagent
Task(
subagent_type='general-purpose',
prompt='''
Load context from .claude/commands/study-builder.md
User request: "{user's request}"
Follow the workflow in the command file.
''',
description='Study builder task'
)
```
---
## Auto-Documentation (Phase 4 - Self-Expanding Knowledge)
Atomizer can auto-generate documentation from code:
```bash
# Generate all documentation
python -m optimization_engine.auto_doc all
# Generate only extractor docs
python -m optimization_engine.auto_doc extractors
# Generate only template docs
python -m optimization_engine.auto_doc templates
```
**Generated Files**:
- `docs/generated/EXTRACTORS.md` - Full extractor reference (auto-generated)
- `docs/generated/EXTRACTOR_CHEATSHEET.md` - Quick reference table
- `docs/generated/TEMPLATES.md` - Study templates reference
**When to Run Auto-Doc**:
1. After adding a new extractor
2. After modifying template registry
3. Before major releases
---
## Version Info
| Component | Version | Last Updated |
|-----------|---------|--------------|
| ATOMIZER_CONTEXT | 1.5 | 2025-12-07 |
| BaseOptimizationRunner | 1.0 | 2025-12-07 |
| GenericSurrogate | 1.0 | 2025-12-07 |
| Study State Detector | 1.0 | 2025-12-07 |
| Template Registry | 1.0 | 2025-12-07 |
| Extractor Library | 1.3 | 2025-12-07 |
| Method Selector | 2.1 | 2025-12-07 |
| Protocol System | 2.0 | 2025-12-06 |
| Skill System | 2.0 | 2025-12-07 |
| Auto-Doc Generator | 1.0 | 2025-12-07 |
| Subagent Commands | 1.0 | 2025-12-07 |
---
*Atomizer: Where engineers talk, AI optimizes.*

View File

@@ -0,0 +1,93 @@
# NX Expert Subagent
You are a specialized NX Open / Simcenter expert agent. Your task is to help with NX CAD/CAE automation, model manipulation, and API lookups.
## Available MCP Tools
Use these Siemens documentation tools:
- `mcp__siemens-docs__nxopen_get_class` - Get NX Open Python class docs (Session, Part, etc.)
- `mcp__siemens-docs__nxopen_get_index` - Get class lists, functions, hierarchy
- `mcp__siemens-docs__nxopen_fetch_page` - Fetch any NX Open reference page
- `mcp__siemens-docs__siemens_docs_fetch` - Fetch general Siemens docs
- `mcp__siemens-docs__siemens_auth_status` - Check auth status
## Your Capabilities
1. **API Lookup**: Find correct NX Open method signatures
2. **Expression Management**: Query/modify NX expressions
3. **Geometry Queries**: Get mass properties, bounding boxes, etc.
4. **FEM Operations**: Mesh updates, solver configuration
5. **Automation Scripts**: Write NX journals for automation
## Common Tasks
### Get Expression Values
```python
from optimization_engine.hooks.nx_cad import expression_manager
result = expression_manager.get_expressions("path/to/model.prt")
```
### Get Mass Properties
```python
from optimization_engine.hooks.nx_cad import geometry_query
result = geometry_query.get_mass_properties("path/to/model.prt")
```
### Update FEM Mesh
The mesh must be updated after expression changes:
1. Load the idealized part first
2. Call UpdateFemodel()
3. Save and solve
### Run NX Journal
```bash
"C:\Program Files\Siemens\NX2506\NXBIN\run_journal.exe" "script.py" -args "arg1" "arg2"
```
## NX Open Key Classes
| Class | Purpose | Common Methods |
|-------|---------|----------------|
| `Session` | Application entry point | `GetSession()`, `Parts` |
| `Part` | Part file operations | `Expressions`, `SaveAs()` |
| `BasePart` | Base for Part/Assembly | `FullPath`, `Name` |
| `Expression` | Parametric expression | `Name`, `Value`, `RightHandSide` |
| `CAE.FemPart` | FEM model | `UpdateFemodel()` |
| `CAE.SimPart` | Simulation | `SimSimulation` |
## Nastran Element Types
| Element | Description | Stress Extractor Setting |
|---------|-------------|-------------------------|
| CTETRA | 4/10 node solid | `element_type='ctetra'` |
| CHEXA | 8/20 node solid | `element_type='chexa'` |
| CQUAD4 | 4-node shell | `element_type='cquad4'` |
| CTRIA3 | 3-node shell | `element_type='ctria3'` |
## Output Format
When answering API questions:
```
## NX Open API: {ClassName}.{MethodName}
**Signature**: `method_name(param1: Type, param2: Type) -> ReturnType`
**Description**: {what it does}
**Example**:
```python
# Example usage
session = NXOpen.Session.GetSession()
result = session.{method_name}(...)
```
**Notes**: {any caveats or tips}
```
## Critical Rules
1. **Always check MCP tools first** for API questions
2. **NX 2506** is the installed version
3. **Python 3.x** syntax for all code
4. **run_journal.exe** for external automation
5. **Never modify master files** - always work on copies

View File

@@ -0,0 +1,116 @@
# Protocol Auditor Subagent
You are a specialized Atomizer Protocol Auditor agent. Your task is to validate configurations, check code quality, and ensure studies follow best practices.
## Your Capabilities
1. **Config Validation**: Check optimization_config.json structure and values
2. **Extractor Verification**: Ensure correct extractors are used for element types
3. **Path Validation**: Verify all file paths exist and are accessible
4. **Code Quality**: Check scripts follow patterns from base classes
5. **Documentation Check**: Verify study has required documentation
## Validation Checks
### Config Validation
```python
# Required fields
required = ['study_name', 'design_variables', 'objectives', 'solver_settings']
# Design variable structure
for var in config['design_variables']:
assert 'name' in var # or 'parameter'
assert 'min' in var or 'bounds' in var
assert 'max' in var or 'bounds' in var
# Objective structure
for obj in config['objectives']:
assert 'name' in obj
assert 'direction' in obj or 'goal' in obj # minimize/maximize
```
### Extractor Compatibility
| Element Type | Compatible Extractors | Notes |
|--------------|----------------------|-------|
| CTETRA/CHEXA | E1, E3, E4, E12-14 | Solid elements |
| CQUAD4/CTRIA3 | E1, E3, E4 | Shell: specify `element_type='cquad4'` |
| Any | E2 | Frequency (SOL 103 only) |
| Mirror shells | E8-E10 | Zernike (optical) |
### Path Validation
```python
paths_to_check = [
config['solver_settings']['simulation_file'],
config['solver_settings'].get('part_file'),
study_dir / '1_setup' / 'model'
]
```
## Audit Report Format
```markdown
# Audit Report: {study_name}
## Summary
- Status: PASS / WARN / FAIL
- Issues Found: {count}
- Warnings: {count}
## Config Validation
- [x] Required fields present
- [x] Design variables valid
- [ ] Objective extractors compatible (WARNING: ...)
## File Validation
- [x] Simulation file exists
- [x] Model directory structure correct
- [ ] OP2 output path writable
## Code Quality
- [x] Uses ConfigDrivenRunner
- [x] No duplicate code
- [ ] Missing type hints (minor)
## Recommendations
1. {recommendation 1}
2. {recommendation 2}
```
## Common Issues
### Issue: Wrong element_type for stress extraction
**Symptom**: Stress extraction returns 0 or fails
**Fix**: Specify `element_type='cquad4'` for shell elements
### Issue: Config format mismatch
**Symptom**: KeyError in ConfigNormalizer
**Fix**: Use either old format (parameter/bounds/goal) or new format (name/min/max/direction)
### Issue: OP2 file not found
**Symptom**: Extractor fails with FileNotFoundError
**Fix**: Check solver ran successfully, verify output path
## Audit Commands
```bash
# Validate a study configuration
python -c "
from optimization_engine.base_runner import ConfigNormalizer
import json
with open('optimization_config.json') as f:
config = json.load(f)
normalizer = ConfigNormalizer()
normalized = normalizer.normalize(config)
print('Config valid!')
"
# Check method recommendation
python -m optimization_engine.method_selector optimization_config.json 2_results/study.db
```
## Critical Rules
1. **Be thorough** - Check every aspect of the configuration
2. **Be specific** - Give exact file paths and line numbers for issues
3. **Be actionable** - Every issue should have a clear fix
4. **Prioritize** - Critical issues first, then warnings, then suggestions

View File

@@ -0,0 +1,132 @@
# Results Analyzer Subagent
You are a specialized Atomizer Results Analyzer agent. Your task is to analyze optimization results, generate insights, and create reports.
## Your Capabilities
1. **Database Queries**: Query Optuna study.db for trial results
2. **Pareto Analysis**: Identify Pareto-optimal solutions
3. **Trend Analysis**: Identify optimization convergence patterns
4. **Report Generation**: Create STUDY_REPORT.md with findings
5. **Visualization Suggestions**: Recommend plots and dashboards
## Data Sources
### Study Database (SQLite)
```python
import optuna
# Load study
study = optuna.load_study(
study_name="study_name",
storage="sqlite:///2_results/study.db"
)
# Get all trials
trials = study.trials
# Get best trial(s)
best_trial = study.best_trial # Single objective
best_trials = study.best_trials # Multi-objective (Pareto)
```
### Turbo Report (JSON)
```python
import json
with open('2_results/turbo_report.json') as f:
turbo = json.load(f)
# Contains: nn_trials, fea_validations, best_solutions, timing
```
### Validation Report (JSON)
```python
with open('2_results/validation_report.json') as f:
validation = json.load(f)
# Contains: per-objective errors, recommendations
```
## Analysis Types
### Single Objective
- Best value found
- Convergence curve
- Parameter importance
- Recommended design
### Multi-Objective (Pareto)
- Pareto front size
- Hypervolume indicator
- Trade-off analysis
- Representative solutions
### Neural Surrogate
- NN vs FEA accuracy
- Per-objective error rates
- Turbo mode effectiveness
- Retrain impact
## Report Format
```markdown
# Optimization Report: {study_name}
## Executive Summary
- **Best Solution**: {values}
- **Total Trials**: {count} FEA + {count} NN
- **Optimization Time**: {duration}
## Results
### Pareto Front (if multi-objective)
| Rank | {obj1} | {obj2} | {obj3} | {var1} | {var2} |
|------|--------|--------|--------|--------|--------|
| 1 | ... | ... | ... | ... | ... |
### Best Single Solution
| Parameter | Value | Unit |
|-----------|-------|------|
| {var1} | {val} | {unit}|
### Convergence
- Trials to 90% optimal: {n}
- Final improvement rate: {rate}%
## Neural Surrogate Performance (if applicable)
| Objective | NN Error | CV Ratio | Quality |
|-----------|----------|----------|---------|
| mass | 2.1% | 0.4 | Good |
| stress | 5.3% | 1.2 | Fair |
## Recommendations
1. {recommendation}
2. {recommendation}
## Next Steps
- [ ] Validate top 3 solutions with full FEA
- [ ] Consider refining search around best region
- [ ] Export results for manufacturing
```
## Query Examples
```python
# Get top 10 by objective
trials_sorted = sorted(study.trials,
key=lambda t: t.values[0] if t.values else float('inf'))[:10]
# Get Pareto front
pareto_trials = [t for t in study.best_trials]
# Calculate statistics
import numpy as np
values = [t.values[0] for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
print(f"Mean: {np.mean(values):.3f}, Std: {np.std(values):.3f}")
```
## Critical Rules
1. **Only analyze completed trials** - Check `trial.state == COMPLETE`
2. **Handle NaN/None values** - Some trials may have failed
3. **Use appropriate metrics** - Hypervolume for multi-obj, best value for single
4. **Include uncertainty** - Report standard deviations where appropriate
5. **Be actionable** - Every insight should lead to a decision

View File

@@ -0,0 +1,73 @@
# Study Builder Subagent
You are a specialized Atomizer Study Builder agent. Your task is to create a complete optimization study from the user's description.
## Context Loading
Load these files first:
1. `.claude/skills/core/study-creation-core.md` - Core study creation patterns
2. `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` - Available extractors
3. `optimization_engine/templates/registry.json` - Study templates
## Your Capabilities
1. **Model Introspection**: Analyze NX .prt/.sim files to discover expressions, mesh types
2. **Config Generation**: Create optimization_config.json with proper structure
3. **Script Generation**: Create run_optimization.py using ConfigDrivenRunner
4. **Template Selection**: Choose appropriate template based on problem type
## Workflow
1. **Gather Requirements**
- What is the model file path (.prt, .sim)?
- What are the design variables (expressions to vary)?
- What objectives to optimize (mass, stress, frequency, etc.)?
- Any constraints?
2. **Introspect Model** (if available)
```python
from optimization_engine.hooks.nx_cad.model_introspection import introspect_study
info = introspect_study("path/to/study/")
```
3. **Select Template**
- Multi-objective structural → `multi_objective_structural`
- Frequency optimization → `frequency_optimization`
- Mass minimization → `single_objective_mass`
- Mirror wavefront → `mirror_wavefront`
4. **Generate Config** following the schema in study-creation-core.md
5. **Generate Scripts** using templates from:
- `optimization_engine/templates/run_optimization_template.py`
- `optimization_engine/templates/run_nn_optimization_template.py`
## Output Format
Return a structured report:
```
## Study Created: {study_name}
### Files Generated
- optimization_config.json
- run_optimization.py
- run_nn_optimization.py (if applicable)
### Configuration Summary
- Design Variables: {count}
- Objectives: {list}
- Constraints: {list}
- Recommended Trials: {number}
### Next Steps
1. Run `python run_optimization.py --discover` to validate model
2. Run `python run_optimization.py --validate` to test pipeline
3. Run `python run_optimization.py --run` to start optimization
```
## Critical Rules
1. **NEVER copy code from existing studies** - Use templates and base classes
2. **ALWAYS use ConfigDrivenRunner** - No custom objective functions
3. **ALWAYS validate paths** before generating config
4. **Use element_type='auto'** unless explicitly specified

View File

@@ -0,0 +1,215 @@
---
skill_id: SKILL_000
version: 2.0
last_updated: 2025-12-07
type: bootstrap
code_dependencies: []
requires_skills: []
---
# Atomizer LLM Bootstrap
**Version**: 2.0
**Updated**: 2025-12-07
**Purpose**: First file any LLM session reads. Provides instant orientation and task routing.
---
## Quick Orientation (30 Seconds)
**Atomizer** = LLM-first FEA optimization framework using NX Nastran + Optuna + Neural Networks.
**Your Role**: Help users set up, run, and analyze structural optimization studies through conversation.
**Core Philosophy**: "Talk, don't click." Users describe what they want; you configure and execute.
---
## Task Classification Tree
When a user request arrives, classify it:
```
User Request
├─► CREATE something?
│ ├─ "new study", "set up", "create", "optimize this"
│ └─► Load: OP_01_CREATE_STUDY.md + core/study-creation-core.md
├─► RUN something?
│ ├─ "start", "run", "execute", "begin optimization"
│ └─► Load: OP_02_RUN_OPTIMIZATION.md
├─► CHECK status?
│ ├─ "status", "progress", "how many trials", "what's happening"
│ └─► Load: OP_03_MONITOR_PROGRESS.md
├─► ANALYZE results?
│ ├─ "results", "best design", "compare", "pareto"
│ └─► Load: OP_04_ANALYZE_RESULTS.md
├─► DEBUG/FIX error?
│ ├─ "error", "failed", "not working", "crashed"
│ └─► Load: OP_06_TROUBLESHOOT.md
├─► CONFIGURE settings?
│ ├─ "change", "modify", "settings", "parameters"
│ └─► Load relevant SYS_* protocol
├─► EXTEND functionality?
│ ├─ "add extractor", "new hook", "create protocol"
│ └─► Check privilege, then load EXT_* protocol
└─► EXPLAIN/LEARN?
├─ "what is", "how does", "explain"
└─► Load relevant SYS_* protocol for reference
```
---
## Protocol Routing Table
| User Intent | Keywords | Protocol | Skill to Load | Privilege |
|-------------|----------|----------|---------------|-----------|
| Create study | "new", "set up", "create", "optimize" | OP_01 | **core/study-creation-core.md** | user |
| Run optimization | "start", "run", "execute", "begin" | OP_02 | - | user |
| Monitor progress | "status", "progress", "trials", "check" | OP_03 | - | user |
| Analyze results | "results", "best", "compare", "pareto" | OP_04 | - | user |
| Export training data | "export", "training data", "neural" | OP_05 | modules/neural-acceleration.md | user |
| Debug issues | "error", "failed", "not working", "help" | OP_06 | - | user |
| Understand IMSO | "protocol 10", "IMSO", "adaptive" | SYS_10 | - | user |
| Multi-objective | "pareto", "NSGA", "multi-objective" | SYS_11 | - | user |
| Extractors | "extractor", "displacement", "stress" | SYS_12 | modules/extractors-catalog.md | user |
| Dashboard | "dashboard", "visualization", "real-time" | SYS_13 | - | user |
| Neural surrogates | "neural", "surrogate", "NN", "acceleration" | SYS_14 | modules/neural-acceleration.md | user |
| Add extractor | "create extractor", "new physics" | EXT_01 | - | power_user |
| Add hook | "create hook", "lifecycle", "callback" | EXT_02 | - | power_user |
| Add protocol | "create protocol", "new protocol" | EXT_03 | - | admin |
| Add skill | "create skill", "new skill" | EXT_04 | - | admin |
---
## Role Detection
Determine user's privilege level:
| Role | How to Detect | Can Do | Cannot Do |
|------|---------------|--------|-----------|
| **user** | Default for all sessions | Run studies, monitor, analyze, configure | Create extractors, modify protocols |
| **power_user** | User states they're a developer, or session context indicates | Create extractors, add hooks | Create protocols, modify skills |
| **admin** | Explicit declaration, admin config present | Full access | - |
**Default**: Assume `user` unless explicitly told otherwise.
---
## Context Loading Rules
After classifying the task, load context in this order:
### 1. Always Loaded (via CLAUDE.md)
- This file (00_BOOTSTRAP.md)
- Python environment rules
- Code reuse protocol
### 2. Load Per Task Type
See `02_CONTEXT_LOADER.md` for complete loading rules.
**Quick Reference**:
```
CREATE_STUDY → core/study-creation-core.md (PRIMARY)
→ SYS_12_EXTRACTOR_LIBRARY.md (extractor reference)
→ modules/zernike-optimization.md (if telescope/mirror)
→ modules/neural-acceleration.md (if >50 trials)
RUN_OPTIMIZATION → OP_02_RUN_OPTIMIZATION.md
→ SYS_15_METHOD_SELECTOR.md (method recommendation)
→ SYS_14_NEURAL_ACCELERATION.md (if neural/turbo)
DEBUG → OP_06_TROUBLESHOOT.md
→ Relevant SYS_* based on error type
```
---
## Execution Framework
For ANY task, follow this pattern:
```
1. ANNOUNCE → State what you're about to do
2. VALIDATE → Check prerequisites are met
3. EXECUTE → Perform the action
4. VERIFY → Confirm success
5. REPORT → Summarize what was done
6. SUGGEST → Offer logical next steps
```
See `PROTOCOL_EXECUTION.md` for detailed execution rules.
---
## Emergency Quick Paths
### "I just want to run an optimization"
1. Do you have a `.prt` and `.sim` file? → Yes: OP_01 → OP_02
2. Getting errors? → OP_06
3. Want to see progress? → OP_03
### "Something broke"
1. Read the error message
2. Load OP_06_TROUBLESHOOT.md
3. Follow diagnostic flowchart
### "What did my optimization find?"
1. Load OP_04_ANALYZE_RESULTS.md
2. Query the study database
3. Generate report
---
## Protocol Directory Map
```
docs/protocols/
├── operations/ # Layer 2: How-to guides
│ ├── OP_01_CREATE_STUDY.md
│ ├── OP_02_RUN_OPTIMIZATION.md
│ ├── OP_03_MONITOR_PROGRESS.md
│ ├── OP_04_ANALYZE_RESULTS.md
│ ├── OP_05_EXPORT_TRAINING_DATA.md
│ └── OP_06_TROUBLESHOOT.md
├── system/ # Layer 3: Core specifications
│ ├── SYS_10_IMSO.md
│ ├── SYS_11_MULTI_OBJECTIVE.md
│ ├── SYS_12_EXTRACTOR_LIBRARY.md
│ ├── SYS_13_DASHBOARD_TRACKING.md
│ └── SYS_14_NEURAL_ACCELERATION.md
└── extensions/ # Layer 4: Extensibility guides
├── EXT_01_CREATE_EXTRACTOR.md
├── EXT_02_CREATE_HOOK.md
├── EXT_03_CREATE_PROTOCOL.md
├── EXT_04_CREATE_SKILL.md
└── templates/
```
---
## Key Constraints (Always Apply)
1. **Python Environment**: Always use `conda activate atomizer`
2. **Never modify master files**: Copy NX files to study working directory first
3. **Code reuse**: Check `optimization_engine/extractors/` before writing new extraction code
4. **Validation**: Always validate config before running optimization
5. **Documentation**: Every study needs README.md and STUDY_REPORT.md
---
## Next Steps After Bootstrap
1. If you know the task type → Go to relevant OP_* or SYS_* protocol
2. If unclear → Ask user clarifying question
3. If complex task → Read `01_CHEATSHEET.md` for quick reference
4. If need detailed loading rules → Read `02_CONTEXT_LOADER.md`

View File

@@ -0,0 +1,243 @@
---
skill_id: SKILL_001
version: 2.0
last_updated: 2025-12-07
type: reference
code_dependencies:
- optimization_engine/extractors/__init__.py
- optimization_engine/method_selector.py
requires_skills:
- SKILL_000
---
# Atomizer Quick Reference Cheatsheet
**Version**: 2.0
**Updated**: 2025-12-07
**Purpose**: Rapid lookup for common operations. "I want X → Use Y"
---
## Task → Protocol Quick Lookup
| I want to... | Use Protocol | Key Command/Action |
|--------------|--------------|-------------------|
| Create a new optimization study | OP_01 | Generate `optimization_config.json` + `run_optimization.py` |
| Run an optimization | OP_02 | `conda activate atomizer && python run_optimization.py` |
| Check optimization progress | OP_03 | Query `study.db` or check dashboard at `localhost:3000` |
| See best results | OP_04 | `optuna-dashboard sqlite:///study.db` or dashboard |
| Export neural training data | OP_05 | `python run_optimization.py --export-training` |
| Fix an error | OP_06 | Read error log → follow diagnostic tree |
| Add custom physics extractor | EXT_01 | Create in `optimization_engine/extractors/` |
| Add lifecycle hook | EXT_02 | Create in `optimization_engine/plugins/` |
---
## Extractor Quick Reference
| Physics | Extractor | Function Call |
|---------|-----------|---------------|
| Max displacement | E1 | `extract_displacement(op2_file, subcase=1)` |
| Natural frequency | E2 | `extract_frequency(op2_file, subcase=1, mode_number=1)` |
| Von Mises stress | E3 | `extract_solid_stress(op2_file, subcase=1, element_type='cquad4')` |
| BDF mass | E4 | `extract_mass_from_bdf(bdf_file)` |
| CAD expression mass | E5 | `extract_mass_from_expression(prt_file, expression_name='p173')` |
| Field data | E6 | `FieldDataExtractor(field_file, result_column, aggregation)` |
| Stiffness (k=F/δ) | E7 | `StiffnessCalculator(...)` |
| Zernike WFE | E8 | `extract_zernike_from_op2(op2_file, bdf_file, subcase)` |
| Zernike relative | E9 | `extract_zernike_relative_rms(op2_file, bdf_file, target, ref)` |
| Zernike builder | E10 | `ZernikeObjectiveBuilder(op2_finder)` |
| Part mass + material | E11 | `extract_part_mass_material(prt_file)` → mass, volume, material |
**Full details**: See `SYS_12_EXTRACTOR_LIBRARY.md` or `modules/extractors-catalog.md`
---
## Protocol Selection Guide
### Single Objective Optimization
```
Question: Do you have ONE goal to minimize/maximize?
├─ Yes, simple problem (smooth, <10 params)
│ └─► Protocol 10 + CMA-ES or GP-BO sampler
├─ Yes, complex problem (noisy, many params)
│ └─► Protocol 10 + TPE sampler
└─ Not sure about problem characteristics?
└─► Protocol 10 with adaptive characterization (default)
```
### Multi-Objective Optimization
```
Question: Do you have 2-3 competing goals?
├─ Yes (e.g., minimize mass AND minimize stress)
│ └─► Protocol 11 + NSGA-II sampler
└─ Pareto front needed?
└─► Protocol 11 (returns best_trials, not best_trial)
```
### Neural Network Acceleration
```
Question: Do you need >50 trials OR surrogate model?
├─ Yes
│ └─► Protocol 14 (configure surrogate_settings in config)
└─ Training data export needed?
└─► OP_05_EXPORT_TRAINING_DATA.md
```
---
## Configuration Quick Reference
### optimization_config.json Structure
```json
{
"study_name": "my_study",
"design_variables": [
{"name": "thickness", "min": 1.0, "max": 10.0, "unit": "mm"}
],
"objectives": [
{"name": "mass", "goal": "minimize", "unit": "kg"}
],
"constraints": [
{"name": "max_stress", "type": "<=", "threshold": 250, "unit": "MPa"}
],
"optimization_settings": {
"protocol": "protocol_10_single_objective",
"sampler": "TPESampler",
"n_trials": 50
},
"simulation": {
"model_file": "model.prt",
"sim_file": "model.sim",
"solver": "nastran"
}
}
```
### Sampler Quick Selection
| Sampler | Use When | Protocol |
|---------|----------|----------|
| `TPESampler` | Default, robust to noise | P10 |
| `CMAESSampler` | Smooth, unimodal problems | P10 |
| `GPSampler` | Expensive FEA, few trials | P10 |
| `NSGAIISampler` | Multi-objective (2-3 goals) | P11 |
| `RandomSampler` | Characterization phase only | P10 |
---
## Study File Structure
```
studies/{study_name}/
├── 1_setup/
│ ├── model/ # NX files (.prt, .sim, .fem)
│ └── optimization_config.json
├── 2_results/
│ ├── study.db # Optuna SQLite database
│ ├── optimizer_state.json # Real-time state (P13)
│ └── trial_logs/
├── README.md # MANDATORY: Engineering blueprint
├── STUDY_REPORT.md # MANDATORY: Results tracking
└── run_optimization.py # Entrypoint script
```
---
## Common Commands
```bash
# Activate environment (ALWAYS FIRST)
conda activate atomizer
# Run optimization
python run_optimization.py
# Run with specific trial count
python run_optimization.py --n-trials 100
# Resume interrupted optimization
python run_optimization.py --resume
# Export training data for neural network
python run_optimization.py --export-training
# View results in Optuna dashboard
optuna-dashboard sqlite:///2_results/study.db
# Check study status
python -c "import optuna; s=optuna.load_study('my_study', 'sqlite:///2_results/study.db'); print(f'Trials: {len(s.trials)}')"
```
---
## Error Quick Fixes
| Error | Likely Cause | Quick Fix |
|-------|--------------|-----------|
| "No module named optuna" | Wrong environment | `conda activate atomizer` |
| "NX session timeout" | Model too complex | Increase `timeout` in config |
| "OP2 file not found" | Solve failed | Check NX log for errors |
| "No feasible solutions" | Constraints too tight | Relax constraint thresholds |
| "NSGA-II requires >1 objective" | Wrong protocol | Use P10 for single-objective |
| "Expression not found" | Wrong parameter name | Verify expression names in NX |
| **All trials identical results** | **Missing `*_i.prt`** | **Copy idealized part to study folder!** |
**Full troubleshooting**: See `OP_06_TROUBLESHOOT.md`
---
## CRITICAL: NX FEM Mesh Update
**If all optimization trials produce identical results, the mesh is NOT updating!**
### Required Files for Mesh Updates
```
studies/{study}/1_setup/model/
├── Model.prt # Geometry
├── Model_fem1_i.prt # Idealized part ← MUST EXIST!
├── Model_fem1.fem # FEM
└── Model_sim1.sim # Simulation
```
### Why It Matters
The `*_i.prt` (idealized part) MUST be:
1. **Present** in the study folder
2. **Loaded** before `UpdateFemodel()` (already implemented in `solve_simulation.py`)
Without it, `UpdateFemodel()` runs but the mesh doesn't change!
---
## Privilege Levels
| Level | Can Create Studies | Can Add Extractors | Can Add Protocols |
|-------|-------------------|-------------------|------------------|
| user | ✓ | ✗ | ✗ |
| power_user | ✓ | ✓ | ✗ |
| admin | ✓ | ✓ | ✓ |
---
## Dashboard URLs
| Service | URL | Purpose |
|---------|-----|---------|
| Atomizer Dashboard | `http://localhost:3000` | Real-time optimization monitoring |
| Optuna Dashboard | `http://localhost:8080` | Trial history, parameter importance |
| API Backend | `http://localhost:5000` | REST API for dashboard |
---
## Protocol Numbers Reference
| # | Name | Purpose |
|---|------|---------|
| 10 | IMSO | Intelligent Multi-Strategy Optimization (adaptive) |
| 11 | Multi-Objective | NSGA-II for Pareto optimization |
| 12 | - | (Reserved) |
| 13 | Dashboard | Real-time tracking and visualization |
| 14 | Neural | Surrogate model acceleration |

View File

@@ -0,0 +1,323 @@
---
skill_id: SKILL_002
version: 2.0
last_updated: 2025-12-07
type: loader
code_dependencies: []
requires_skills:
- SKILL_000
---
# Atomizer Context Loader
**Version**: 2.0
**Updated**: 2025-12-07
**Purpose**: Define what documentation to load based on task type. Ensures LLM sessions have exactly the context needed.
---
## Context Loading Philosophy
1. **Minimal by default**: Don't load everything; load what's needed
2. **Expand on demand**: Load additional modules when signals detected
3. **Single source of truth**: Each concept defined in ONE place
4. **Layer progression**: Bootstrap → Operations → System → Extensions
---
## Task-Based Loading Rules
### CREATE_STUDY
**Trigger Keywords**: "new", "set up", "create", "optimize", "study"
**Always Load**:
```
.claude/skills/core/study-creation-core.md (SKILL_CORE_001)
```
**Load If**:
| Condition | Load |
|-----------|------|
| User asks about extractors | `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` |
| Telescope/mirror/optics mentioned | `modules/zernike-optimization.md` |
| >50 trials OR "neural" OR "surrogate" | `docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md` |
| Multi-objective (2+ goals) | `docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md` |
| Method selection needed | `docs/protocols/system/SYS_15_METHOD_SELECTOR.md` |
**Example Context Stack**:
```
# Simple bracket optimization
core/study-creation-core.md
SYS_12_EXTRACTOR_LIBRARY.md
# Mirror optimization with neural acceleration
core/study-creation-core.md
modules/zernike-optimization.md
SYS_14_NEURAL_ACCELERATION.md
SYS_15_METHOD_SELECTOR.md
```
---
### RUN_OPTIMIZATION
**Trigger Keywords**: "start", "run", "execute", "begin", "launch"
**Always Load**:
```
docs/protocols/operations/OP_02_RUN_OPTIMIZATION.md
```
**Load If**:
| Condition | Load |
|-----------|------|
| "adaptive" OR "characterization" | `docs/protocols/system/SYS_10_IMSO.md` |
| "dashboard" OR "real-time" | `docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md` |
| "resume" OR "continue" | OP_02 has resume section |
| Errors occur | `docs/protocols/operations/OP_06_TROUBLESHOOT.md` |
---
### MONITOR_PROGRESS
**Trigger Keywords**: "status", "progress", "how many", "trials", "check"
**Always Load**:
```
docs/protocols/operations/OP_03_MONITOR_PROGRESS.md
```
**Load If**:
| Condition | Load |
|-----------|------|
| Dashboard questions | `docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md` |
| Pareto/multi-objective | `docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md` |
---
### ANALYZE_RESULTS
**Trigger Keywords**: "results", "best", "compare", "pareto", "report"
**Always Load**:
```
docs/protocols/operations/OP_04_ANALYZE_RESULTS.md
```
**Load If**:
| Condition | Load |
|-----------|------|
| Multi-objective/Pareto | `docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md` |
| Surrogate accuracy | `docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md` |
---
### EXPORT_TRAINING_DATA
**Trigger Keywords**: "export", "training data", "neural network data"
**Always Load**:
```
docs/protocols/operations/OP_05_EXPORT_TRAINING_DATA.md
modules/neural-acceleration.md
```
---
### TROUBLESHOOT
**Trigger Keywords**: "error", "failed", "not working", "crashed", "help"
**Always Load**:
```
docs/protocols/operations/OP_06_TROUBLESHOOT.md
```
**Load If**:
| Error Type | Load |
|------------|------|
| NX/solve errors | NX solver section of core skill |
| Extractor errors | `modules/extractors-catalog.md` |
| Dashboard errors | `docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md` |
| Neural errors | `docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md` |
---
### UNDERSTAND_PROTOCOL
**Trigger Keywords**: "what is", "how does", "explain", "protocol"
**Load Based on Topic**:
| Topic | Load |
|-------|------|
| Protocol 10 / IMSO / adaptive | `docs/protocols/system/SYS_10_IMSO.md` |
| Protocol 11 / multi-objective / NSGA | `docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md` |
| Extractors / physics extraction | `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md` |
| Protocol 13 / dashboard / real-time | `docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md` |
| Protocol 14 / neural / surrogate | `docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md` |
---
### EXTEND_FUNCTIONALITY
**Trigger Keywords**: "create extractor", "add hook", "new protocol", "extend"
**Requires**: Privilege check first (see 00_BOOTSTRAP.md)
| Extension Type | Load | Privilege |
|----------------|------|-----------|
| New extractor | `docs/protocols/extensions/EXT_01_CREATE_EXTRACTOR.md` | power_user |
| New hook | `docs/protocols/extensions/EXT_02_CREATE_HOOK.md` | power_user |
| New protocol | `docs/protocols/extensions/EXT_03_CREATE_PROTOCOL.md` | admin |
| New skill | `docs/protocols/extensions/EXT_04_CREATE_SKILL.md` | admin |
**Always Load for Extractors**:
```
modules/nx-docs-lookup.md # NX API documentation via MCP
```
---
### NX_DEVELOPMENT
**Trigger Keywords**: "NX Open", "NXOpen", "NX API", "Simcenter", "Nastran card", "NX script"
**Always Load**:
```
modules/nx-docs-lookup.md
```
**MCP Tools Available**:
| Tool | Purpose |
|------|---------|
| `siemens_docs_search` | Search NX Open, Simcenter, Teamcenter docs |
| `siemens_docs_fetch` | Fetch specific documentation page |
| `siemens_auth_status` | Check Siemens SSO session status |
| `siemens_login` | Re-authenticate if session expired |
**Use When**:
- Building new extractors that use NX Open APIs
- Debugging NX automation errors
- Looking up Nastran card formats
- Finding correct method signatures
---
## Signal Detection Patterns
Use these patterns to detect when to load additional modules:
### Zernike/Mirror Detection
```
Signals: "mirror", "telescope", "wavefront", "WFE", "Zernike",
"RMS", "polishing", "optical", "M1", "surface error"
Action: Load modules/zernike-optimization.md
```
### Neural Acceleration Detection
```
Signals: "neural", "surrogate", "NN", "machine learning",
"acceleration", ">50 trials", "fast", "GNN"
Action: Load modules/neural-acceleration.md
```
### Multi-Objective Detection
```
Signals: Two or more objectives with different goals,
"pareto", "tradeoff", "NSGA", "multi-objective",
"minimize X AND maximize Y"
Action: Load SYS_11_MULTI_OBJECTIVE.md
```
### High-Complexity Detection
```
Signals: >10 design variables, "complex", "many parameters",
"adaptive", "characterization", "landscape"
Action: Load SYS_10_IMSO.md
```
### NX Open / Simcenter Detection
```
Signals: "NX Open", "NXOpen", "NX API", "FemPart", "CAE.",
"Nastran", "CQUAD", "CTRIA", "MAT1", "PSHELL",
"mesh", "solver", "OP2", "BDF", "Simcenter"
Action: Load modules/nx-docs-lookup.md
Use MCP tools: siemens_docs_search, siemens_docs_fetch
```
---
## Context Stack Examples
### Example 1: Simple Bracket Optimization
```
User: "Help me optimize my bracket for minimum weight"
Load Stack:
1. core/study-creation-core.md # Core study creation logic
```
### Example 2: Telescope Mirror with Neural
```
User: "I need to optimize my M1 mirror's wavefront error with 200 trials"
Load Stack:
1. core/study-creation-core.md # Core study creation
2. modules/zernike-optimization.md # Zernike-specific patterns
3. SYS_14_NEURAL_ACCELERATION.md # Neural acceleration for 200 trials
4. SYS_15_METHOD_SELECTOR.md # Method recommendation
```
### Example 3: Multi-Objective Structural
```
User: "Minimize mass AND maximize stiffness for my beam"
Load Stack:
1. core/study-creation-core.md # Core study creation
2. SYS_11_MULTI_OBJECTIVE.md # Multi-objective protocol
```
### Example 4: Debug Session
```
User: "My optimization failed with NX timeout error"
Load Stack:
1. OP_06_TROUBLESHOOT.md # Troubleshooting guide
```
### Example 5: Create Custom Extractor
```
User: "I need to extract thermal gradients from my results"
Load Stack:
1. EXT_01_CREATE_EXTRACTOR.md # Extractor creation guide
2. SYS_12_EXTRACTOR_LIBRARY.md # Reference existing patterns
```
---
## Loading Priority Order
When multiple modules could apply, load in this order:
1. **Core skill** (always first for creation tasks)
2. **Primary operation protocol** (OP_*)
3. **Required system protocols** (SYS_*)
4. **Optional modules** (modules/*)
5. **Extension protocols** (EXT_*) - only if extending
---
## Anti-Patterns (Don't Do)
1. **Don't load everything**: Only load what's needed for the task
2. **Don't load extensions for users**: Check privilege first
3. **Don't skip core skill**: For study creation, always load core first
4. **Don't mix incompatible protocols**: P10 (single-obj) vs P11 (multi-obj)
5. **Don't load deprecated docs**: Only use docs/protocols/* structure

View File

@@ -0,0 +1,398 @@
# Developer Documentation Skill
**Version**: 1.0
**Purpose**: Self-documenting system for Atomizer development. Use this skill to systematically document new features, protocols, extractors, and changes.
---
## Overview
This skill enables **automatic documentation maintenance** during development. When you develop new features, use these commands to keep documentation in sync with code.
---
## Quick Commands for Developers
### Document New Feature
**Tell Claude**:
```
"Document the new {feature} I just added"
```
Claude will:
1. Analyze the code changes
2. Determine which docs need updating
3. Update protocol files
4. Update CLAUDE.md if needed
5. Bump version numbers
6. Create changelog entry
### Document New Extractor
**Tell Claude**:
```
"I created a new extractor: extract_thermal.py. Document it."
```
Claude will:
1. Read the extractor code
2. Add entry to SYS_12_EXTRACTOR_LIBRARY.md
3. Add to extractors-catalog.md module
4. Update __init__.py exports
5. Create test file template
### Document Protocol Change
**Tell Claude**:
```
"I modified Protocol 10 to add {feature}. Update docs."
```
Claude will:
1. Read the code changes
2. Update SYS_10_IMSO.md
3. Bump version number
4. Add to Version History
5. Update cross-references
### Full Documentation Audit
**Tell Claude**:
```
"Audit documentation for {component/study/protocol}"
```
Claude will:
1. Check all related docs
2. Identify stale content
3. Flag missing documentation
4. Suggest updates
---
## Documentation Workflow
### When You Add Code
```
┌─────────────────────────────────────────────────┐
│ 1. WRITE CODE │
│ - New extractor, hook, or feature │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 2. TELL CLAUDE │
│ "Document the new {feature} I added" │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 3. CLAUDE UPDATES │
│ - Protocol files │
│ - Skill modules │
│ - Version numbers │
│ - Cross-references │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 4. REVIEW & COMMIT │
│ - Review changes │
│ - Commit code + docs together │
└─────────────────────────────────────────────────┘
```
---
## Documentation Update Rules
### File → Document Mapping
| If You Change... | Update These Docs |
|------------------|-------------------|
| `optimization_engine/extractors/*` | SYS_12, extractors-catalog.md |
| `optimization_engine/intelligent_optimizer.py` | SYS_10_IMSO.md |
| `optimization_engine/plugins/*` | EXT_02_CREATE_HOOK.md |
| `atomizer-dashboard/*` | SYS_13_DASHBOARD_TRACKING.md |
| `atomizer-field/*` | SYS_14_NEURAL_ACCELERATION.md |
| Any multi-objective code | SYS_11_MULTI_OBJECTIVE.md |
| Study creation workflow | OP_01_CREATE_STUDY.md |
| Run workflow | OP_02_RUN_OPTIMIZATION.md |
### Version Bumping Rules
| Change Type | Version Bump | Example |
|-------------|--------------|---------|
| Bug fix | Patch (+0.0.1) | 1.0.0 → 1.0.1 |
| New feature (backwards compatible) | Minor (+0.1.0) | 1.0.0 → 1.1.0 |
| Breaking change | Major (+1.0.0) | 1.0.0 → 2.0.0 |
### Required Updates for New Extractor
1. **SYS_12_EXTRACTOR_LIBRARY.md**:
- Add to Quick Reference table (assign E{N} ID)
- Add detailed section with code example
2. **skills/modules/extractors-catalog.md** (when created):
- Add entry with copy-paste code snippet
3. **optimization_engine/extractors/__init__.py**:
- Add import and export
4. **Tests**:
- Create `tests/test_extract_{name}.py`
### Required Updates for New Protocol
1. **docs/protocols/system/SYS_{N}_{NAME}.md**:
- Create full protocol document
2. **docs/protocols/README.md**:
- Add to navigation tables
3. **.claude/skills/01_CHEATSHEET.md**:
- Add to quick lookup table
4. **.claude/skills/02_CONTEXT_LOADER.md**:
- Add loading rules
5. **CLAUDE.md**:
- Add reference if major feature
---
## Self-Documentation Commands
### "Document this change"
Claude analyzes recent changes and updates relevant docs.
**Input**: Description of what you changed
**Output**: Updated protocol files, version bumps, changelog
### "Create protocol for {feature}"
Claude creates a new protocol document following the template.
**Input**: Feature name and description
**Output**: New SYS_* or EXT_* document
### "Verify documentation for {component}"
Claude checks that docs match code.
**Input**: Component name
**Output**: List of discrepancies and suggested fixes
### "Generate changelog since {date/commit}"
Claude creates a changelog from git history.
**Input**: Date or commit reference
**Output**: Formatted changelog
---
## Protocol Document Template
When creating new protocols, use this structure:
```markdown
# {LAYER}_{NUMBER}_{NAME}.md
<!--
PROTOCOL: {Full Name}
LAYER: {Operations|System|Extensions}
VERSION: 1.0
STATUS: Active
LAST_UPDATED: {YYYY-MM-DD}
PRIVILEGE: {user|power_user|admin}
LOAD_WITH: [{dependencies}]
-->
## Overview
{1-3 sentence description}
## When to Use
| Trigger | Action |
|---------|--------|
## Quick Reference
{Tables, key parameters}
## Detailed Specification
{Full content}
## Examples
{Working examples}
## Troubleshooting
| Symptom | Cause | Solution |
## Cross-References
- Depends On: []
- Used By: []
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | {DATE} | Initial release |
```
---
## Changelog Format
When updating protocols, add to Version History:
```markdown
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.2.0 | 2025-12-05 | Added thermal gradient support |
| 1.1.0 | 2025-12-01 | Improved error handling |
| 1.0.0 | 2025-11-20 | Initial release |
```
---
## Integration with Git
### Commit Message Format
When documenting:
```
docs: Update SYS_12 with thermal extractor
- Added E11 thermal gradient extractor
- Updated Quick Reference table
- Added code example in Detailed section
- Bumped version to 1.1.0
```
### Pre-Commit Checklist
Before committing code changes:
- [ ] Related docs updated
- [ ] Version numbers bumped
- [ ] Cross-references valid
- [ ] Examples tested
- [ ] CLAUDE.md updated if major feature
---
## Example: Document New Extractor
**You say**:
```
"I just created extract_thermal_gradient.py in optimization_engine/extractors/.
It extracts thermal gradients from temperature fields. Document it."
```
**Claude does**:
1. Reads `extract_thermal_gradient.py`
2. Updates `SYS_12_EXTRACTOR_LIBRARY.md`:
```markdown
| E11 | Thermal Gradient | `extract_thermal_gradient()` | .op2 | K/mm |
```
3. Adds detailed section with code example
4. Updates `__init__.py`:
```python
from .extract_thermal_gradient import extract_thermal_gradient
__all__ = [..., 'extract_thermal_gradient']
```
5. Bumps SYS_12 version: 1.0 → 1.1
6. Reports what was updated
---
## Example: Document Protocol Change
**You say**:
```
"I added a new stopping criterion to Protocol 10 based on improvement rate.
Update the documentation."
```
**Claude does**:
1. Reads relevant code changes
2. Updates `SYS_10_IMSO.md`:
- Adds to Configuration section
- Updates Architecture diagram if needed
- Adds to Quick Reference
3. Bumps version: 2.1 → 2.2
4. Adds Version History entry:
```markdown
| 2.2 | 2025-12-05 | Added improvement rate stopping criterion |
```
5. Updates cross-references if needed
---
## Keeping Docs in Sync
### Daily Development
```
Morning: Start coding
├─► Write new feature
├─► Test feature
├─► "Claude, document the {feature} I just added"
└─► Commit code + docs together
```
### Weekly Audit
```
Friday:
├─► "Claude, audit documentation for recent changes"
├─► Review flagged issues
└─► Fix any stale documentation
```
### Release Preparation
```
Before release:
├─► "Claude, generate changelog since last release"
├─► "Claude, verify all protocol versions are consistent"
└─► Final review and version bump
```
---
## Summary
**To keep documentation in sync**:
1. **After coding**: Tell Claude what you changed
2. **Be specific**: "I added X to Y" works better than "update docs"
3. **Commit together**: Code and docs in same commit
4. **Regular audits**: Weekly check for stale docs
**Claude handles**:
- Finding which docs need updates
- Following the template structure
- Version bumping
- Cross-reference updates
- Changelog generation
**You handle**:
- Telling Claude what changed
- Reviewing Claude's updates
- Final commit

View File

@@ -0,0 +1,361 @@
# Protocol Execution Framework (PEF)
**Version**: 1.0
**Purpose**: Meta-protocol defining how LLM sessions execute Atomizer protocols. The "protocol for using protocols."
---
## Core Execution Pattern
For ANY task, follow this 6-step pattern:
```
┌─────────────────────────────────────────────────────────────┐
│ 1. ANNOUNCE │
│ State what you're about to do in plain language │
│ "I'll create an optimization study for your bracket..." │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. VALIDATE │
│ Check prerequisites are met │
│ - Required files exist? │
│ - Environment ready? │
│ - User has confirmed understanding? │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. EXECUTE │
│ Perform the action following protocol steps │
│ - Load required context per 02_CONTEXT_LOADER.md │
│ - Follow protocol step-by-step │
│ - Handle errors with OP_06 patterns │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. VERIFY │
│ Confirm success │
│ - Files created correctly? │
│ - No errors in output? │
│ - Results make sense? │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. REPORT │
│ Summarize what was done │
│ - List files created/modified │
│ - Show key results │
│ - Note any warnings │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 6. SUGGEST │
│ Offer logical next steps │
│ - What should user do next? │
│ - Related operations available? │
│ - Dashboard URL if relevant? │
└─────────────────────────────────────────────────────────────┘
```
---
## Task Classification Rules
Before executing, classify the user's request:
### Step 1: Identify Task Category
```python
TASK_CATEGORIES = {
"CREATE": {
"keywords": ["new", "create", "set up", "optimize", "study", "build"],
"protocol": "OP_01_CREATE_STUDY",
"privilege": "user"
},
"RUN": {
"keywords": ["start", "run", "execute", "begin", "launch"],
"protocol": "OP_02_RUN_OPTIMIZATION",
"privilege": "user"
},
"MONITOR": {
"keywords": ["status", "progress", "check", "how many", "trials"],
"protocol": "OP_03_MONITOR_PROGRESS",
"privilege": "user"
},
"ANALYZE": {
"keywords": ["results", "best", "compare", "pareto", "report"],
"protocol": "OP_04_ANALYZE_RESULTS",
"privilege": "user"
},
"EXPORT": {
"keywords": ["export", "training data", "neural data"],
"protocol": "OP_05_EXPORT_TRAINING_DATA",
"privilege": "user"
},
"DEBUG": {
"keywords": ["error", "failed", "not working", "crashed", "help"],
"protocol": "OP_06_TROUBLESHOOT",
"privilege": "user"
},
"EXTEND": {
"keywords": ["add extractor", "create hook", "new protocol"],
"protocol": "EXT_*",
"privilege": "power_user+"
}
}
```
### Step 2: Check Privilege
```python
def check_privilege(task_category, user_role):
required = TASK_CATEGORIES[task_category]["privilege"]
privilege_hierarchy = ["user", "power_user", "admin"]
if privilege_hierarchy.index(user_role) >= privilege_hierarchy.index(required):
return True
else:
# Inform user they need higher privilege
return False
```
### Step 3: Load Context
Follow rules in `02_CONTEXT_LOADER.md` to load appropriate documentation.
---
## Validation Checkpoints
Before executing any protocol step, validate:
### Pre-Study Creation
- [ ] Model files exist (`.prt`, `.sim`)
- [ ] Working directory is writable
- [ ] User has described objectives clearly
- [ ] Conda environment is atomizer
### Pre-Run
- [ ] `optimization_config.json` exists and is valid
- [ ] `run_optimization.py` exists
- [ ] Model files copied to `1_setup/model/`
- [ ] No conflicting process running
### Pre-Analysis
- [ ] `study.db` exists with completed trials
- [ ] No optimization currently running
### Pre-Extension (power_user+)
- [ ] User has confirmed their role
- [ ] Extension doesn't duplicate existing functionality
- [ ] Tests can be written for new code
---
## Error Recovery Protocol
When something fails during execution:
### Step 1: Identify Failure Point
```
Which step failed?
├─ File creation? → Check permissions, disk space
├─ NX solve? → Check NX log, timeout, expressions
├─ Extraction? → Check OP2 exists, subcase correct
├─ Database? → Check SQLite file, trial count
└─ Unknown? → Capture full error, check OP_06
```
### Step 2: Attempt Recovery
```python
RECOVERY_ACTIONS = {
"file_permission": "Check directory permissions, try different location",
"nx_timeout": "Increase timeout in config, simplify model",
"nx_expression_error": "Verify expression names match NX model",
"op2_missing": "Check NX solve completed successfully",
"extractor_error": "Verify correct subcase and element types",
"database_locked": "Wait for other process to finish, or kill stale process",
}
```
### Step 3: Escalate if Needed
If recovery fails:
1. Log the error with full context
2. Inform user of the issue
3. Suggest manual intervention if appropriate
4. Offer to retry after user fixes underlying issue
---
## Protocol Combination Rules
Some protocols work together, others conflict:
### Valid Combinations
```
OP_01 + SYS_10 # Create study with IMSO
OP_01 + SYS_11 # Create multi-objective study
OP_01 + SYS_14 # Create study with neural acceleration
OP_02 + SYS_13 # Run with dashboard tracking
OP_04 + SYS_11 # Analyze multi-objective results
```
### Invalid Combinations
```
SYS_10 + SYS_11 # Single-obj IMSO with multi-obj NSGA (pick one)
TPESampler + SYS_11 # TPE is single-objective; use NSGAIISampler
EXT_* without privilege # Extensions require power_user or admin
```
### Automatic Protocol Inference
```
If objectives.length == 1:
→ Use Protocol 10 (single-objective)
→ Sampler: TPE, CMA-ES, or GP
If objectives.length > 1:
→ Use Protocol 11 (multi-objective)
→ Sampler: NSGA-II (mandatory)
If n_trials > 50 OR surrogate_settings present:
→ Add Protocol 14 (neural acceleration)
```
---
## Execution Logging
During execution, maintain awareness of:
### Session State
```python
session_state = {
"current_study": None, # Active study name
"loaded_protocols": [], # Protocols currently loaded
"completed_steps": [], # Steps completed this session
"pending_actions": [], # Actions waiting for user
"last_error": None, # Most recent error if any
}
```
### User Communication
- Always explain what you're doing
- Show progress for long operations
- Warn before destructive actions
- Confirm before expensive operations (many trials)
---
## Confirmation Requirements
Some actions require explicit user confirmation:
### Always Confirm
- [ ] Deleting files or studies
- [ ] Overwriting existing study
- [ ] Running >100 trials
- [ ] Modifying master NX files (FORBIDDEN - but confirm user understands)
- [ ] Creating extension (power_user+)
### Confirm If Uncertain
- [ ] Ambiguous objective (minimize or maximize?)
- [ ] Multiple possible extractors
- [ ] Complex multi-solution setup
### No Confirmation Needed
- [ ] Creating new study in empty directory
- [ ] Running validation checks
- [ ] Reading/analyzing results
- [ ] Checking status
---
## Output Format Standards
When reporting results:
### Study Creation Output
```
Created study: {study_name}
Files generated:
- studies/{study_name}/1_setup/optimization_config.json
- studies/{study_name}/run_optimization.py
- studies/{study_name}/README.md
- studies/{study_name}/STUDY_REPORT.md
Configuration:
- Design variables: {count}
- Objectives: {list}
- Constraints: {list}
- Protocol: {protocol}
- Trials: {n_trials}
Next steps:
1. Copy your NX files to studies/{study_name}/1_setup/model/
2. Run: conda activate atomizer && python run_optimization.py
3. Monitor: http://localhost:3000
```
### Run Status Output
```
Study: {study_name}
Status: {running|completed|failed}
Trials: {completed}/{total}
Best value: {value} ({objective_name})
Elapsed: {time}
Dashboard: http://localhost:3000
```
### Error Output
```
Error: {error_type}
Message: {error_message}
Location: {file}:{line}
Diagnosis:
{explanation}
Recovery:
{steps to fix}
Reference: OP_06_TROUBLESHOOT.md
```
---
## Quality Checklist
Before considering any task complete:
### For Study Creation
- [ ] `optimization_config.json` validates successfully
- [ ] `run_optimization.py` has no syntax errors
- [ ] `README.md` has all 11 required sections
- [ ] `STUDY_REPORT.md` template created
- [ ] No code duplication (used extractors from library)
### For Execution
- [ ] Optimization started without errors
- [ ] Dashboard shows real-time updates (if enabled)
- [ ] Trials are progressing
### For Analysis
- [ ] Best result(s) identified
- [ ] Constraints satisfied
- [ ] Report generated if requested
### For Extensions
- [ ] New code added to correct location
- [ ] `__init__.py` updated with exports
- [ ] Documentation updated
- [ ] Tests written (or noted as TODO)

View File

@@ -1,7 +1,7 @@
# Analyze Model Skill
**Last Updated**: November 25, 2025
**Version**: 1.0 - Model Analysis and Feature Extraction
**Last Updated**: December 6, 2025
**Version**: 2.0 - Added Comprehensive Model Introspection
You are helping the user understand their NX model's structure and identify optimization opportunities.
@@ -11,7 +11,8 @@ Extract and present information about an NX model to help the user:
1. Identify available parametric expressions (potential design variables)
2. Understand the simulation setup (analysis types, boundary conditions)
3. Discover material properties
4. Recommend optimization strategies based on model characteristics
4. Identify extractable results from OP2 files
5. Recommend optimization strategies based on model characteristics
## Triggers
@@ -20,28 +21,107 @@ Extract and present information about an NX model to help the user:
- "show me the expressions"
- "look at my NX model"
- "what parameters are available"
- "introspect my model"
- "what results are available"
## Prerequisites
- User must provide path to NX model files (.prt, .sim, .fem)
- NX must be available on the system (configured in config.py)
- Model files must be valid NX format
- User must provide path to NX model files (.prt, .sim, .fem) or study directory
- NX must be available on the system for part/sim introspection
- OP2 introspection works without NX (pure Python)
## Information Gathering
Ask these questions if not already provided:
1. **Model Location**:
- "Where is your NX model? (path to .prt file)"
- "Where is your NX model? (path to .prt file or study directory)"
- Default: Look in `studies/*/1_setup/model/`
2. **Analysis Interest**:
- "What type of optimization are you considering?" (optional)
- This helps focus the analysis on relevant aspects
---
## MANDATORY: Model Introspection
**ALWAYS use the introspection module for comprehensive model analysis:**
```python
from optimization_engine.hooks.nx_cad.model_introspection import (
introspect_part,
introspect_simulation,
introspect_op2,
introspect_study
)
# Option 1: Introspect entire study directory (recommended)
study_info = introspect_study("studies/my_study/")
# Option 2: Introspect individual files
part_info = introspect_part("path/to/model.prt")
sim_info = introspect_simulation("path/to/model.sim")
op2_info = introspect_op2("path/to/results.op2")
```
### What Introspection Extracts
| Source | Information Extracted |
|--------|----------------------|
| `.prt` | Expressions (count, values, types), bodies, mass, material, features |
| `.sim` | Solutions, boundary conditions, loads, materials, mesh info, output requests |
| `.op2` | Available results (displacement, stress, strain, SPC forces, etc.), subcases |
### Introspection Report Generation
**MANDATORY**: Generate `MODEL_INTROSPECTION.md` for every study:
```python
# Generate and save introspection report
study_info = introspect_study(study_dir)
# Create markdown report
report = generate_introspection_report(study_info)
with open(study_dir / "MODEL_INTROSPECTION.md", "w") as f:
f.write(report)
```
---
## Execution Steps
### Step 1: Validate Model Files
### Step 1: Run Comprehensive Introspection
**Use the introspection module (MANDATORY)**:
```python
from optimization_engine.hooks.nx_cad.model_introspection import introspect_study
# Introspect the entire study
result = introspect_study("studies/my_study/")
if result["success"]:
# Part information
for part in result["data"]["parts"]:
print(f"Part: {part['file']}")
print(f" Expressions: {part['data'].get('expression_count', 0)}")
print(f" Bodies: {part['data'].get('body_count', 0)}")
# Simulation information
for sim in result["data"]["simulations"]:
print(f"Simulation: {sim['file']}")
print(f" Solutions: {sim['data'].get('solution_count', 0)}")
# OP2 results
for op2 in result["data"]["results"]:
print(f"OP2: {op2['file']}")
available = op2['data'].get('available_results', {})
print(f" Displacement: {available.get('displacement', False)}")
print(f" Stress: {available.get('stress', False)}")
```
### Step 2: Validate Model Files
Check that required files exist:
@@ -95,26 +175,21 @@ def validate_model_files(model_path: Path) -> dict:
return result
```
### Step 2: Extract Expressions
### Step 3: Extract Expressions (via Introspection)
Use NX Python API to extract all parametric expressions:
The introspection module extracts expressions automatically:
```python
# This requires running a journal inside NX
# Use the expression extractor from optimization_engine
from optimization_engine.hooks.nx_cad.model_introspection import introspect_part
from optimization_engine.extractors.expression_extractor import extract_all_expressions
expressions = extract_all_expressions(prt_file)
# Returns: [{'name': 'thickness', 'value': 2.0, 'unit': 'mm', 'formula': None}, ...]
result = introspect_part("path/to/model.prt")
if result["success"]:
expressions = result["data"].get("expressions", [])
for expr in expressions:
print(f" {expr['name']}: {expr['value']} {expr.get('unit', '')}")
```
**Manual Extraction Method** (if NX API not available):
1. Read the .prt file header for expression metadata
2. Look for common parameter naming patterns
3. Ask user to provide expression names from NX
### Step 3: Classify Expressions
### Step 4: Classify Expressions
Categorize expressions by likely purpose:
@@ -154,53 +229,121 @@ Based on analysis, recommend:
## Output Format
Present analysis in structured format:
Present analysis using the **MODEL_INTROSPECTION.md** format:
```markdown
# Model Introspection Report
**Study**: {study_name}
**Generated**: {date}
**Introspection Version**: 1.0
---
## 1. Files Discovered
| Type | File | Status |
|------|------|--------|
| Part (.prt) | {prt_file} | ✓ Found |
| Simulation (.sim) | {sim_file} | ✓ Found |
| FEM (.fem) | {fem_file} | ✓ Found |
| Results (.op2) | {op2_file} | ✓ Found |
---
## 2. Part Information
### Expressions (Potential Design Variables)
| Name | Value | Unit | Type | Optimization Candidate |
|------|-------|------|------|------------------------|
| thickness | 2.0 | mm | User | ✓ High |
| hole_diameter | 10.0 | mm | User | ✓ High |
| p173_mass | 0.125 | kg | Reference | Read-only |
### Mass Properties
| Property | Value | Unit |
|----------|-------|------|
| Mass | 0.125 | kg |
| Material | Aluminum 6061-T6 | - |
---
## 3. Simulation Information
### Solutions
| Solution | Type | Nastran SOL | Status |
|----------|------|-------------|--------|
| Solution 1 | Static | SOL 101 | ✓ Active |
| Solution 2 | Modal | SOL 103 | ✓ Active |
### Boundary Conditions
| Name | Type | Applied To |
|------|------|------------|
| Fixed_Root | SPC | Face_1 |
### Loads
| Name | Type | Magnitude | Direction |
|------|------|-----------|-----------|
| Tip_Force | FORCE | 500 N | -Z |
---
## 4. Available Results (from OP2)
| Result Type | Available | Subcases |
|-------------|-----------|----------|
| Displacement | ✓ | 1 |
| SPC Forces | ✓ | 1 |
| Stress (CHEXA) | ✓ | 1 |
| Stress (CPENTA) | ✓ | 1 |
| Strain Energy | ✗ | - |
| Frequencies | ✓ | 2 |
---
## 5. Optimization Recommendations
### Suggested Objectives
| Objective | Extractor | Source |
|-----------|-----------|--------|
| Minimize mass | E4: `extract_mass_from_bdf` | .dat |
| Maximize stiffness | E1: `extract_displacement` → k=F/δ | .op2 |
### Suggested Constraints
| Constraint | Type | Threshold | Extractor |
|------------|------|-----------|-----------|
| Max stress | less_than | 250 MPa | E3: `extract_solid_stress` |
### Recommended Protocol
- **Protocol 11 (Multi-Objective NSGA-II)** - Multiple competing objectives
- Multi-Solution: **Yes** (static + modal)
---
*Ready to create optimization study? Say "create study" to proceed.*
```
MODEL ANALYSIS REPORT
=====================
Model: {model_name}
Location: {model_path}
### Saving the Report
FILES FOUND
-----------
✓ Part file: {prt_file}
✓ Simulation: {sim_file}
✓ FEM mesh: {fem_file}
**MANDATORY**: Save the introspection report to the study directory:
PARAMETRIC EXPRESSIONS
----------------------
| Name | Current Value | Unit | Category | Optimization Candidate |
|------|---------------|------|----------|----------------------|
| thickness | 2.0 | mm | Structural | ✓ High |
| hole_diameter | 10.0 | mm | Geometric | ✓ High |
| fillet_radius | 3.0 | mm | Geometric | ✓ Medium |
| length | 100.0 | mm | Dimensional | ? Check constraints |
```python
from pathlib import Path
SIMULATION SETUP
----------------
Analysis Types: Static (SOL 101), Modal (SOL 103)
Material: Aluminum 6061-T6 (E=68.9 GPa, ρ=2700 kg/m³)
Loads:
- Force: 500 N at tip
- Constraint: Fixed at root
RECOMMENDATIONS
---------------
Suggested Objectives:
- Minimize mass (extract from p173 expression or FEM)
- Maximize first natural frequency
Suggested Constraints:
- Max von Mises stress < 276 MPa (Al 6061 yield)
- Max displacement < {user to specify}
Recommended Protocol: Protocol 11 (Multi-Objective NSGA-II)
- Reason: Multiple competing objectives (mass vs frequency)
Ready to create optimization study? Say "create study" to proceed.
```
def save_introspection_report(study_dir: Path, report_content: str):
"""Save MODEL_INTROSPECTION.md to study directory."""
report_path = study_dir / "MODEL_INTROSPECTION.md"
with open(report_path, 'w') as f:
f.write(report_content)
print(f"Saved introspection report: {report_path}")
## Error Handling

View File

@@ -0,0 +1,751 @@
---
skill_id: SKILL_CORE_001
version: 2.4
last_updated: 2025-12-07
type: core
code_dependencies:
- optimization_engine/base_runner.py
- optimization_engine/extractors/__init__.py
- optimization_engine/templates/registry.json
requires_skills: []
replaces: create-study.md
---
# Study Creation Core Skill
**Version**: 2.4
**Updated**: 2025-12-07
**Type**: Core Skill
You are helping the user create a complete Atomizer optimization study from a natural language description.
**CRITICAL**: This skill is your SINGLE SOURCE OF TRUTH. DO NOT improvise or look at other studies for patterns. Use ONLY the patterns documented here and in the loaded modules.
---
## Module Loading
This core skill is always loaded. Additional modules are loaded based on context:
| Module | Load When | Path |
|--------|-----------|------|
| **extractors-catalog** | Always (for reference) | `modules/extractors-catalog.md` |
| **zernike-optimization** | "telescope", "mirror", "optical", "wavefront" | `modules/zernike-optimization.md` |
| **neural-acceleration** | >50 trials, "neural", "surrogate", "fast" | `modules/neural-acceleration.md` |
---
## MANDATORY: Model Introspection at Study Creation
**ALWAYS run introspection when creating a study or when user asks:**
```python
from optimization_engine.hooks.nx_cad.model_introspection import (
introspect_part,
introspect_simulation,
introspect_op2,
introspect_study
)
# Introspect entire study directory (recommended)
study_info = introspect_study("studies/my_study/")
# Or introspect individual files
part_info = introspect_part("path/to/model.prt")
sim_info = introspect_simulation("path/to/model.sim")
op2_info = introspect_op2("path/to/results.op2")
```
### Introspection Extracts
| Source | Information |
|--------|-------------|
| `.prt` | Expressions (count, values, types), bodies, mass, material, features |
| `.sim` | Solutions, boundary conditions, loads, materials, mesh info, output requests |
| `.op2` | Available results (displacement, stress, strain, SPC forces, etc.), subcases |
### Generate Introspection Report
**MANDATORY**: Save `MODEL_INTROSPECTION.md` to study directory at creation:
```python
# After introspection, generate and save report
study_info = introspect_study(study_dir)
# Generate markdown report and save to studies/{study_name}/MODEL_INTROSPECTION.md
```
---
## MANDATORY DOCUMENTATION CHECKLIST
**EVERY study MUST have these files. A study is NOT complete without them:**
| File | Purpose | When Created |
|------|---------|--------------|
| `MODEL_INTROSPECTION.md` | **Model Analysis** - Expressions, solutions, available results | At study creation |
| `README.md` | **Engineering Blueprint** - Full mathematical formulation | At study creation |
| `STUDY_REPORT.md` | **Results Tracking** - Progress, best designs, recommendations | At study creation (template) |
**README.md Requirements (11 sections)**:
1. Engineering Problem (objective, physical system)
2. Mathematical Formulation (objectives, design variables, constraints with LaTeX)
3. Optimization Algorithm (config, properties, return format)
4. Simulation Pipeline (trial execution flow diagram)
5. Result Extraction Methods (extractor details, code snippets)
6. Neural Acceleration (surrogate config, expected performance)
7. Study File Structure (directory tree)
8. Results Location (output files)
9. Quick Start (commands)
10. Configuration Reference (config.json mapping)
11. References
**FAILURE MODE**: If you create a study without MODEL_INTROSPECTION.md, README.md, and STUDY_REPORT.md, the study is incomplete.
---
## PR.3 NXSolver Interface
**Module**: `optimization_engine.nx_solver`
```python
from optimization_engine.nx_solver import NXSolver
nx_solver = NXSolver(
nastran_version="2412", # NX version
timeout=600, # Max solve time (seconds)
use_journal=True, # Use journal mode (recommended)
enable_session_management=True,
study_name="my_study"
)
```
**Main Method - `run_simulation()`**:
```python
result = nx_solver.run_simulation(
sim_file=sim_file, # Path to .sim file
working_dir=model_dir, # Working directory
expression_updates=design_vars, # Dict: {'param_name': value}
solution_name=None, # None = solve ALL solutions
cleanup=True # Remove temp files after
)
# Returns:
# {
# 'success': bool,
# 'op2_file': Path,
# 'log_file': Path,
# 'elapsed_time': float,
# 'errors': list,
# 'solution_name': str
# }
```
**CRITICAL**: For multi-solution workflows (static + modal), set `solution_name=None`.
---
## PR.4 Sampler Configurations
| Sampler | Use Case | Import | Config |
|---------|----------|--------|--------|
| **NSGAIISampler** | Multi-objective (2-3 objectives) | `from optuna.samplers import NSGAIISampler` | `NSGAIISampler(population_size=20, mutation_prob=0.1, crossover_prob=0.9, seed=42)` |
| **TPESampler** | Single-objective | `from optuna.samplers import TPESampler` | `TPESampler(seed=42)` |
| **CmaEsSampler** | Single-objective, continuous | `from optuna.samplers import CmaEsSampler` | `CmaEsSampler(seed=42)` |
---
## PR.5 Study Creation Patterns
**Multi-Objective (NSGA-II)**:
```python
study = optuna.create_study(
study_name=study_name,
storage=f"sqlite:///{results_dir / 'study.db'}",
sampler=NSGAIISampler(population_size=20, seed=42),
directions=['minimize', 'maximize'], # [obj1_dir, obj2_dir]
load_if_exists=True
)
```
**Single-Objective (TPE)**:
```python
study = optuna.create_study(
study_name=study_name,
storage=f"sqlite:///{results_dir / 'study.db'}",
sampler=TPESampler(seed=42),
direction='minimize', # or 'maximize'
load_if_exists=True
)
```
---
## PR.6 Objective Function Return Formats
**Multi-Objective** (directions=['minimize', 'minimize']):
```python
def objective(trial) -> Tuple[float, float]:
# ... extraction ...
return (obj1, obj2) # Both positive, framework handles direction
```
**Multi-Objective with maximize** (directions=['maximize', 'minimize']):
```python
def objective(trial) -> Tuple[float, float]:
# ... extraction ...
return (-stiffness, mass) # -stiffness so minimize → maximize
```
**Single-Objective**:
```python
def objective(trial) -> float:
# ... extraction ...
return objective_value
```
---
## PR.7 Hook System
**Available Hook Points** (from `optimization_engine.plugins.hooks`):
| Hook Point | When | Context Keys |
|------------|------|--------------|
| `PRE_MESH` | Before meshing | `trial_number, design_variables, sim_file` |
| `POST_MESH` | After mesh | `trial_number, design_variables, sim_file` |
| `PRE_SOLVE` | Before solve | `trial_number, design_variables, sim_file, working_dir` |
| `POST_SOLVE` | After solve | `trial_number, design_variables, op2_file, working_dir` |
| `POST_EXTRACTION` | After extraction | `trial_number, design_variables, results, working_dir` |
| `POST_CALCULATION` | After calculations | `trial_number, objectives, constraints, feasible` |
| `CUSTOM_OBJECTIVE` | Custom objectives | `trial_number, design_variables, extracted_results` |
See [EXT_02_CREATE_HOOK](../../docs/protocols/extensions/EXT_02_CREATE_HOOK.md) for creating custom hooks.
---
## PR.8 Structured Logging (MANDATORY)
**Always use structured logging**:
```python
from optimization_engine.logger import get_logger
logger = get_logger(study_name, study_dir=results_dir)
# Study lifecycle
logger.study_start(study_name, n_trials, "NSGAIISampler")
logger.study_complete(study_name, total_trials, successful_trials)
# Trial lifecycle
logger.trial_start(trial.number, design_vars)
logger.trial_complete(trial.number, objectives_dict, constraints_dict, feasible)
logger.trial_failed(trial.number, error_message)
# General logging
logger.info("message")
logger.warning("message")
logger.error("message", exc_info=True)
```
---
## Study Structure
```
studies/{study_name}/
├── 1_setup/ # INPUT: Configuration & Model
│ ├── model/ # WORKING COPY of NX Files
│ │ ├── {Model}.prt # Parametric part
│ │ ├── {Model}_sim1.sim # Simulation setup
│ │ └── *.dat, *.op2, *.f06 # Solver outputs
│ ├── optimization_config.json # Study configuration
│ └── workflow_config.json # Workflow metadata
├── 2_results/ # OUTPUT: Results
│ ├── study.db # Optuna SQLite database
│ └── optimization_history.json # Trial history
├── run_optimization.py # Main entry point
├── reset_study.py # Database reset
├── README.md # Engineering blueprint
└── STUDY_REPORT.md # Results report template
```
---
## CRITICAL: Model File Protection
**NEVER modify the user's original/master model files.** Always work on copies.
```python
import shutil
from pathlib import Path
def setup_working_copy(source_dir: Path, model_dir: Path, file_patterns: list):
"""Copy model files from user's source to study working directory."""
model_dir.mkdir(parents=True, exist_ok=True)
for pattern in file_patterns:
for src_file in source_dir.glob(pattern):
dst_file = model_dir / src_file.name
if not dst_file.exists():
shutil.copy2(src_file, dst_file)
```
---
## Interactive Discovery Process
### Step 1: Problem Understanding
**Ask clarifying questions**:
- "What component are you optimizing?"
- "What do you want to optimize?" (minimize/maximize)
- "What limits must be satisfied?" (constraints)
- "What parameters can be changed?" (design variables)
- "Where are your NX files?"
### Step 2: Protocol Selection
| Scenario | Protocol | Sampler |
|----------|----------|---------|
| Single objective + constraints | Protocol 10 | TPE/CMA-ES |
| 2-3 objectives | Protocol 11 | NSGA-II |
| >50 trials, need speed | Protocol 14 | + Neural |
### Step 3: Extractor Mapping
Map user needs to extractors from [extractors-catalog module](../modules/extractors-catalog.md):
| Need | Extractor |
|------|-----------|
| Displacement | E1: `extract_displacement` |
| Stress | E3: `extract_solid_stress` |
| Frequency | E2: `extract_frequency` |
| Mass (FEM) | E4: `extract_mass_from_bdf` |
| Mass (CAD) | E5: `extract_mass_from_expression` |
### Step 4: Multi-Solution Detection
If user needs BOTH:
- Static results (stress, displacement)
- Modal results (frequency)
Then set `solution_name=None` to solve ALL solutions.
---
## File Generation
### 1. optimization_config.json
```json
{
"study_name": "{study_name}",
"description": "{concise description}",
"optimization_settings": {
"protocol": "protocol_11_multi_objective",
"n_trials": 30,
"sampler": "NSGAIISampler",
"timeout_per_trial": 600
},
"design_variables": [
{
"parameter": "{nx_expression_name}",
"bounds": [min, max],
"description": "{what this controls}"
}
],
"objectives": [
{
"name": "{objective_name}",
"goal": "minimize",
"weight": 1.0,
"description": "{what this measures}"
}
],
"constraints": [
{
"name": "{constraint_name}",
"type": "less_than",
"threshold": value,
"description": "{engineering justification}"
}
],
"simulation": {
"model_file": "{Model}.prt",
"sim_file": "{Model}_sim1.sim",
"solver": "nastran"
}
}
```
### 2. run_optimization.py Template
```python
"""
{Study Name} Optimization
{Brief description}
"""
from pathlib import Path
import sys
import json
import argparse
from typing import Tuple
project_root = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(project_root))
import optuna
from optuna.samplers import NSGAIISampler # or TPESampler
from optimization_engine.nx_solver import NXSolver
from optimization_engine.logger import get_logger
# Import extractors - USE ONLY FROM extractors-catalog module
from optimization_engine.extractors.extract_displacement import extract_displacement
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
def load_config(config_file: Path) -> dict:
with open(config_file, 'r') as f:
return json.load(f)
def objective(trial: optuna.Trial, config: dict, nx_solver: NXSolver,
model_dir: Path, logger) -> Tuple[float, float]:
"""Multi-objective function. Returns (obj1, obj2)."""
# 1. Sample design variables
design_vars = {}
for var in config['design_variables']:
param_name = var['parameter']
bounds = var['bounds']
design_vars[param_name] = trial.suggest_float(param_name, bounds[0], bounds[1])
logger.trial_start(trial.number, design_vars)
try:
# 2. Run simulation
sim_file = model_dir / config['simulation']['sim_file']
result = nx_solver.run_simulation(
sim_file=sim_file,
working_dir=model_dir,
expression_updates=design_vars,
solution_name=None, # Solve ALL solutions
cleanup=True
)
if not result['success']:
logger.trial_failed(trial.number, f"Simulation failed")
return (float('inf'), float('inf'))
op2_file = result['op2_file']
# 3. Extract results
disp_result = extract_displacement(op2_file, subcase=1)
max_displacement = disp_result['max_displacement']
dat_file = model_dir / config['simulation'].get('dat_file', 'model.dat')
mass_kg = extract_mass_from_bdf(str(dat_file))
# 4. Calculate objectives
applied_force = 1000.0 # N
stiffness = applied_force / max(abs(max_displacement), 1e-6)
# 5. Set trial attributes
trial.set_user_attr('stiffness', stiffness)
trial.set_user_attr('mass', mass_kg)
objectives = {'stiffness': stiffness, 'mass': mass_kg}
logger.trial_complete(trial.number, objectives, {}, True)
return (-stiffness, mass_kg) # Negate stiffness to maximize
except Exception as e:
logger.trial_failed(trial.number, str(e))
return (float('inf'), float('inf'))
def main():
parser = argparse.ArgumentParser(description='{Study Name} Optimization')
stage_group = parser.add_mutually_exclusive_group()
stage_group.add_argument('--discover', action='store_true')
stage_group.add_argument('--validate', action='store_true')
stage_group.add_argument('--test', action='store_true')
stage_group.add_argument('--train', action='store_true')
stage_group.add_argument('--run', action='store_true')
parser.add_argument('--trials', type=int, default=100)
parser.add_argument('--resume', action='store_true')
parser.add_argument('--enable-nn', action='store_true')
args = parser.parse_args()
study_dir = Path(__file__).parent
config_path = study_dir / "1_setup" / "optimization_config.json"
model_dir = study_dir / "1_setup" / "model"
results_dir = study_dir / "2_results"
results_dir.mkdir(exist_ok=True)
study_name = "{study_name}"
logger = get_logger(study_name, study_dir=results_dir)
config = load_config(config_path)
nx_solver = NXSolver()
storage = f"sqlite:///{results_dir / 'study.db'}"
sampler = NSGAIISampler(population_size=20, seed=42)
logger.study_start(study_name, args.trials, "NSGAIISampler")
if args.resume:
study = optuna.load_study(study_name=study_name, storage=storage, sampler=sampler)
else:
study = optuna.create_study(
study_name=study_name,
storage=storage,
sampler=sampler,
directions=['minimize', 'minimize'],
load_if_exists=True
)
study.optimize(
lambda trial: objective(trial, config, nx_solver, model_dir, logger),
n_trials=args.trials,
show_progress_bar=True
)
n_successful = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
logger.study_complete(study_name, len(study.trials), n_successful)
if __name__ == "__main__":
main()
```
### 3. reset_study.py
```python
"""Reset {study_name} optimization study by deleting database."""
import optuna
from pathlib import Path
study_dir = Path(__file__).parent
storage = f"sqlite:///{study_dir / '2_results' / 'study.db'}"
study_name = "{study_name}"
try:
optuna.delete_study(study_name=study_name, storage=storage)
print(f"[OK] Deleted study: {study_name}")
except KeyError:
print(f"[WARNING] Study '{study_name}' not found")
except Exception as e:
print(f"[ERROR] Error: {e}")
```
---
## Common Patterns
### Pattern 1: Mass Minimization with Constraints
```
Objective: Minimize mass
Constraints: Stress < limit, Displacement < limit
Protocol: Protocol 10 (single-objective TPE)
Extractors: E4/E5, E3, E1
Multi-Solution: No (static only)
```
### Pattern 2: Mass vs Stiffness Trade-off
```
Objectives: Minimize mass, Maximize stiffness
Constraints: Stress < limit
Protocol: Protocol 11 (multi-objective NSGA-II)
Extractors: E4/E5, E1 (for stiffness = F/δ), E3
Multi-Solution: No (static only)
```
### Pattern 3: Mass vs Frequency Trade-off
```
Objectives: Minimize mass, Maximize frequency
Constraints: Stress < limit, Displacement < limit
Protocol: Protocol 11 (multi-objective NSGA-II)
Extractors: E4/E5, E2, E3, E1
Multi-Solution: Yes (static + modal)
```
---
## Validation Integration
### Pre-Flight Check
```python
def preflight_check():
"""Validate study setup before running."""
from optimization_engine.validators import validate_study
result = validate_study(STUDY_NAME)
if not result.is_ready_to_run:
print("[X] Study validation failed!")
print(result)
sys.exit(1)
print("[OK] Pre-flight check passed!")
return True
```
### Validation Checklist
- [ ] All design variables have valid bounds (min < max)
- [ ] All objectives have proper extraction methods
- [ ] All constraints have thresholds defined
- [ ] Protocol matches objective count
- [ ] Part file (.prt) exists in model directory
- [ ] Simulation file (.sim) exists
---
## Output Format
After completing study creation, provide:
**Summary Table**:
```
Study Created: {study_name}
Protocol: {protocol}
Objectives: {list}
Constraints: {list}
Design Variables: {list}
Multi-Solution: {Yes/No}
```
**File Checklist**:
```
✓ studies/{study_name}/1_setup/optimization_config.json
✓ studies/{study_name}/1_setup/workflow_config.json
✓ studies/{study_name}/run_optimization.py
✓ studies/{study_name}/reset_study.py
✓ studies/{study_name}/MODEL_INTROSPECTION.md # MANDATORY - Model analysis
✓ studies/{study_name}/README.md
✓ studies/{study_name}/STUDY_REPORT.md
```
**Next Steps**:
```
1. Place your NX files in studies/{study_name}/1_setup/model/
2. Test with: python run_optimization.py --test
3. Monitor: http://localhost:3003
4. Full run: python run_optimization.py --run --trials {n_trials}
```
---
## Critical Reminders
1. **Multi-Objective Return Format**: Return tuple with positive values, use `directions` for semantics
2. **Multi-Solution**: Set `solution_name=None` for static + modal workflows
3. **Always use centralized extractors** from `optimization_engine/extractors/`
4. **Never modify master model files** - always work on copies
5. **Structured logging is mandatory** - use `get_logger()`
---
## Assembly FEM (AFEM) Workflow
For complex assemblies with `.afm` files, the update sequence is critical:
```
.prt (geometry) → _fem1.fem (component mesh) → .afm (assembly mesh) → .sim (solution)
```
### The 4-Step Update Process
1. **Update Expressions in Geometry (.prt)**
- Open part, update expressions, DoUpdate(), Save
2. **Update ALL Linked Geometry Parts** (CRITICAL!)
- Open each linked part, DoUpdate(), Save
- **Skipping this causes corrupt results ("billion nm" RMS)**
3. **Update Component FEMs (.fem)**
- UpdateFemodel() regenerates mesh from updated geometry
4. **Update Assembly FEM (.afm)**
- UpdateFemodel(), merge coincident nodes at interfaces
### Assembly Configuration
```json
{
"nx_settings": {
"expression_part": "M1_Blank",
"component_fems": ["M1_Blank_fem1.fem", "M1_Support_fem1.fem"],
"afm_file": "ASSY_M1_assyfem1.afm"
}
}
```
---
## Multi-Solution Solve Protocol
When simulation has multiple solutions (static + modal), use `SolveAllSolutions` API:
### Critical: Foreground Mode Required
```python
# WRONG - Returns immediately, async
theCAESimSolveManager.SolveChainOfSolutions(
psolutions1,
SolveMode.Background # Returns before complete!
)
# CORRECT - Waits for completion
theCAESimSolveManager.SolveAllSolutions(
SolveOption.Solve,
SetupCheckOption.CompleteCheckAndOutputErrors,
SolveMode.Foreground, # Blocks until complete
False
)
```
### When to Use
- `solution_name=None` passed to `NXSolver.run_simulation()`
- Multiple solutions that must all complete
- Multi-objective requiring results from different analysis types
### Solution Monitor Control
Solution monitor is automatically disabled when solving multiple solutions to prevent window pile-up:
```python
propertyTable.SetBooleanPropertyValue("solution monitor", False)
```
### Verification
After solve, verify:
- Both `.dat` files written (one per solution)
- Both `.op2` files created with updated timestamps
- Results are unique per trial (frequency values vary)
---
## Cross-References
- **Operations Protocol**: [OP_01_CREATE_STUDY](../../docs/protocols/operations/OP_01_CREATE_STUDY.md)
- **Extractors Module**: [extractors-catalog](../modules/extractors-catalog.md)
- **Zernike Module**: [zernike-optimization](../modules/zernike-optimization.md)
- **Neural Module**: [neural-acceleration](../modules/neural-acceleration.md)
- **System Protocols**: [SYS_10_IMSO](../../docs/protocols/system/SYS_10_IMSO.md), [SYS_11_MULTI_OBJECTIVE](../../docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md)

View File

@@ -0,0 +1,402 @@
# Create Study Wizard Skill
**Version**: 3.0 - StudyWizard Integration
**Last Updated**: 2025-12-06
You are helping the user create a complete Atomizer optimization study using the powerful `StudyWizard` class.
---
## Quick Reference
```python
from optimization_engine.study_wizard import StudyWizard, create_study, list_extractors
# Option 1: One-liner for simple studies
create_study(
study_name="my_study",
description="Optimize bracket for stiffness",
prt_file="path/to/model.prt",
design_variables=[
{"parameter": "thickness", "bounds": [5, 20], "units": "mm"}
],
objectives=[
{"name": "stiffness", "goal": "maximize", "extractor": "extract_displacement"}
],
constraints=[
{"name": "mass", "type": "less_than", "threshold": 0.5, "extractor": "extract_mass_from_bdf", "units": "kg"}
]
)
# Option 2: Step-by-step with full control
wizard = StudyWizard("my_study", "Optimize bracket")
wizard.set_model_files("path/to/model.prt")
wizard.introspect() # Discover expressions, solutions
wizard.add_design_variable("thickness", bounds=(5, 20), units="mm")
wizard.add_objective("mass", goal="minimize", extractor="extract_mass_from_bdf")
wizard.add_constraint("stress", type="less_than", threshold=250, extractor="extract_solid_stress", units="MPa")
wizard.generate()
```
---
## Trigger Phrases
Use this skill when user says:
- "create study", "new study", "set up study", "create optimization"
- "optimize my [part/model/bracket/component]"
- "help me minimize [mass/weight/cost]"
- "help me maximize [stiffness/strength/frequency]"
- "I want to find the best [design/parameters]"
---
## Workflow Steps
### Step 1: Gather Requirements
Ask the user (if not already provided):
1. **Model files**: "Where is your NX model? (path to .prt file)"
2. **Optimization goal**: "What do you want to optimize?"
- Minimize mass/weight
- Maximize stiffness
- Target a specific frequency
- Multi-objective trade-off
3. **Constraints**: "What limits must be respected?"
- Max stress < yield/safety factor
- Max displacement < tolerance
- Mass budget
### Step 2: Introspect Model
```python
from optimization_engine.study_wizard import StudyWizard
wizard = StudyWizard("study_name", "Description")
wizard.set_model_files("path/to/model.prt")
result = wizard.introspect()
# Show user what was found
print(f"Found {len(result.expressions)} expressions:")
for expr in result.expressions[:10]:
print(f" {expr['name']}: {expr.get('value', 'N/A')}")
print(f"\nFound {len(result.solutions)} solutions:")
for sol in result.solutions:
print(f" {sol['name']}")
# Suggest design variables
suggestions = result.suggest_design_variables()
for s in suggestions:
print(f" {s['name']}: {s['current_value']} -> bounds {s['suggested_bounds']}")
```
### Step 3: Configure Study
```python
# Add design variables from introspection suggestions
for dv in selected_design_variables:
wizard.add_design_variable(
parameter=dv['name'],
bounds=dv['bounds'],
units=dv.get('units', ''),
description=dv.get('description', '')
)
# Add objectives
wizard.add_objective(
name="mass",
goal="minimize",
extractor="extract_mass_from_bdf",
description="Minimize total bracket mass"
)
wizard.add_objective(
name="stiffness",
goal="maximize",
extractor="extract_displacement",
params={"invert_for_stiffness": True},
description="Maximize structural stiffness"
)
# Add constraints
wizard.add_constraint(
name="max_stress",
constraint_type="less_than",
threshold=250,
extractor="extract_solid_stress",
units="MPa",
description="Keep stress below yield/4"
)
# Set protocol based on objectives
if len(wizard.objectives) > 1:
wizard.set_protocol("protocol_11_multi") # NSGA-II
else:
wizard.set_protocol("protocol_10_single") # TPE
wizard.set_trials(100)
```
### Step 4: Generate Study
```python
files = wizard.generate()
print("Study generated successfully!")
print(f"Location: {wizard.study_dir}")
print("\nNext steps:")
print(" 1. cd", wizard.study_dir)
print(" 2. python run_optimization.py --discover")
print(" 3. python run_optimization.py --validate")
print(" 4. python run_optimization.py --run --trials 100")
```
---
## Available Extractors
| Extractor | What it extracts | Input | Output |
|-----------|------------------|-------|--------|
| `extract_mass_from_bdf` | Total mass | .dat/.bdf | kg |
| `extract_part_mass` | CAD mass | .prt | kg |
| `extract_displacement` | Max displacement | .op2 | mm |
| `extract_solid_stress` | Von Mises stress | .op2 | MPa |
| `extract_principal_stress` | Principal stresses | .op2 | MPa |
| `extract_strain_energy` | Strain energy | .op2 | J |
| `extract_spc_forces` | Reaction forces | .op2 | N |
| `extract_frequency` | Natural frequencies | .op2 | Hz |
| `get_first_frequency` | First mode frequency | .f06 | Hz |
| `extract_temperature` | Nodal temperatures | .op2 | K/°C |
| `extract_modal_mass` | Modal effective mass | .f06 | kg |
| `extract_zernike_from_op2` | Zernike WFE | .op2+.bdf | nm |
**List all extractors programmatically**:
```python
from optimization_engine.study_wizard import list_extractors
for name, info in list_extractors().items():
print(f"{name}: {info['description']}")
```
---
## Common Optimization Patterns
### Pattern 1: Minimize Mass with Stress Constraint
```python
create_study(
study_name="lightweight_bracket",
description="Minimize mass while keeping stress below yield",
prt_file="Bracket.prt",
design_variables=[
{"parameter": "wall_thickness", "bounds": [2, 10], "units": "mm"},
{"parameter": "rib_count", "bounds": [2, 8], "units": "count"}
],
objectives=[
{"name": "mass", "goal": "minimize", "extractor": "extract_mass_from_bdf"}
],
constraints=[
{"name": "stress", "type": "less_than", "threshold": 250,
"extractor": "extract_solid_stress", "units": "MPa"}
],
protocol="protocol_10_single"
)
```
### Pattern 2: Multi-Objective Stiffness vs Mass
```python
create_study(
study_name="pareto_bracket",
description="Trade-off between stiffness and mass",
prt_file="Bracket.prt",
design_variables=[
{"parameter": "thickness", "bounds": [5, 25], "units": "mm"},
{"parameter": "support_angle", "bounds": [20, 70], "units": "degrees"}
],
objectives=[
{"name": "stiffness", "goal": "maximize", "extractor": "extract_displacement"},
{"name": "mass", "goal": "minimize", "extractor": "extract_mass_from_bdf"}
],
constraints=[
{"name": "mass_limit", "type": "less_than", "threshold": 0.5,
"extractor": "extract_mass_from_bdf", "units": "kg"}
],
protocol="protocol_11_multi",
n_trials=150
)
```
### Pattern 3: Frequency-Targeted Modal Optimization
```python
create_study(
study_name="modal_bracket",
description="Tune first natural frequency to target",
prt_file="Bracket.prt",
design_variables=[
{"parameter": "thickness", "bounds": [3, 15], "units": "mm"},
{"parameter": "length", "bounds": [50, 150], "units": "mm"}
],
objectives=[
{"name": "frequency_error", "goal": "minimize",
"extractor": "get_first_frequency",
"params": {"target": 100}} # Target 100 Hz
],
constraints=[
{"name": "mass", "type": "less_than", "threshold": 0.3,
"extractor": "extract_mass_from_bdf", "units": "kg"}
]
)
```
### Pattern 4: Thermal Optimization
```python
create_study(
study_name="heat_sink",
description="Minimize max temperature",
prt_file="HeatSink.prt",
design_variables=[
{"parameter": "fin_height", "bounds": [10, 50], "units": "mm"},
{"parameter": "fin_count", "bounds": [5, 20], "units": "count"}
],
objectives=[
{"name": "max_temp", "goal": "minimize", "extractor": "get_max_temperature"}
],
constraints=[
{"name": "mass", "type": "less_than", "threshold": 0.2,
"extractor": "extract_mass_from_bdf", "units": "kg"}
]
)
```
---
## Protocol Selection Guide
| Scenario | Protocol | Sampler |
|----------|----------|---------|
| Single objective | `protocol_10_single` | TPESampler |
| Multiple objectives (Pareto) | `protocol_11_multi` | NSGAIISampler |
| Smooth design space | `protocol_10_single` | CmaEsSampler |
| Discrete variables | `protocol_10_single` | TPESampler |
---
## Files Generated
The wizard generates a complete study structure:
```
studies/{study_name}/
├── 1_setup/
│ ├── model/ # NX model files (copied)
│ ├── optimization_config.json
│ └── workflow_config.json
├── 2_results/ # Created on first run
├── run_optimization.py # Main script with staged workflow
├── reset_study.py # Reset results
├── README.md # Engineering documentation
├── STUDY_REPORT.md # Results tracking template
└── MODEL_INTROSPECTION.md # Model analysis report
```
---
## Staged Workflow
After generation, guide user through staged validation:
```bash
# Stage 1: Discover model outputs
python run_optimization.py --discover
# Stage 2: Validate with single trial
python run_optimization.py --validate
# Stage 3: Integration test (3 trials)
python run_optimization.py --test
# Stage 4: Full optimization
python run_optimization.py --run --trials 100
# Resume if interrupted
python run_optimization.py --run --trials 50 --resume
```
---
## Error Handling
### Common Issues and Solutions
| Error | Cause | Solution |
|-------|-------|----------|
| "Part file not found" | Wrong path | Use absolute path or relative to studies/ |
| "No expressions found" | Part not parametric | Add expressions in NX Modeling |
| "Simulation failed" | NX not running | Start NX before running |
| "Extractor not found" | Typo in name | Use `list_extractors()` to see valid names |
---
## Example Conversation
**User**: "I want to optimize my bracket for minimum weight while keeping stress low"
**Claude**: "I'll help you create a weight optimization study. Let me gather some information:
1. Where is your NX model file?
2. What's the maximum allowable stress (e.g., yield/4)?
3. Are there specific parameters you want to vary (thickness, angles, etc.)?"
**User**: "The model is at studies/my_bracket/model/Bracket.prt. Max stress should be 250 MPa. I want to vary the wall_thickness and support_angle."
**Claude**: "I'll create the study and introspect your model to confirm the parameters exist."
```python
from optimization_engine.study_wizard import StudyWizard
wizard = StudyWizard(
study_name="my_bracket_optimization",
description="Minimize bracket mass with stress constraint"
)
wizard.set_model_files("studies/my_bracket/model/Bracket.prt")
# Introspect to verify parameters
result = wizard.introspect()
print(f"Found expressions: {result.get_expression_names()}")
# Configure
wizard.add_design_variable("wall_thickness", bounds=(2, 15), units="mm")
wizard.add_design_variable("support_angle", bounds=(20, 60), units="degrees")
wizard.add_objective("mass", goal="minimize", extractor="extract_mass_from_bdf")
wizard.add_constraint("stress", constraint_type="less_than", threshold=250,
extractor="extract_solid_stress", units="MPa")
wizard.set_trials(100)
files = wizard.generate()
```
**Claude**: "Study created! Here's what I generated:
- `studies/my_bracket_optimization/run_optimization.py`
- `studies/my_bracket_optimization/README.md`
- Configuration files in `1_setup/`
Next steps:
1. `cd studies/my_bracket_optimization`
2. `python run_optimization.py --discover`
3. `python run_optimization.py --validate`
4. `python run_optimization.py --run --trials 100`"
---
## Cross-References
- **Extractor Library**: `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md`
- **Protocol 10 (IMSO)**: `docs/protocols/system/SYS_10_IMSO.md`
- **Protocol 11 (Multi-Objective)**: `docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md`
- **StudyWizard Source**: `optimization_engine/study_wizard.py`

File diff suppressed because it is too large Load Diff

View File

@@ -394,7 +394,7 @@ Would you like me to:
When a STUDY_REPORT.md file is generated, it can be viewed directly in the Atomizer Dashboard:
1. **Save report to**: `studies/{study_name}/2_results/STUDY_REPORT.md`
1. **Save report to**: `studies/{study_name}/STUDY_REPORT.md` (study root folder)
2. **View in dashboard**: Click "Study Report" button on the dashboard
3. **Features**:
- Full markdown rendering with proper typography

View File

@@ -0,0 +1,325 @@
# Guided Study Creation Wizard
**Version**: 1.0
**Purpose**: Interactive conversational wizard for creating new optimization studies from scratch.
---
## Overview
This skill provides a step-by-step guided experience for users who want to create a new optimization study. It asks focused questions to gather requirements, then generates the complete study configuration.
---
## Wizard Flow
### Phase 1: Understanding the Problem (Discovery)
Start with open-ended questions to understand what the user wants to optimize:
**Opening Prompt:**
```
I'll help you set up a new optimization study. Let's start with the basics:
1. **What are you trying to optimize?**
- Describe the physical system (e.g., "a telescope mirror", "a UAV arm", "a bracket")
2. **What's your goal?**
- Minimize weight? Maximize stiffness? Minimize stress? Multiple objectives?
3. **Do you have an NX model ready?**
- If yes, where is it located?
- If no, we can discuss what's needed
```
### Phase 2: Model Analysis (If NX model provided)
If user provides a model path:
1. **Check the model exists**
```python
# Verify path
model_path = Path(user_provided_path)
if model_path.exists():
# Proceed with analysis
else:
# Ask for correct path
```
2. **Extract expressions (design parameters)**
- List all NX expressions that could be design variables
- Ask user to confirm which ones to optimize
3. **Identify simulation setup**
- What solution types are present? (static, modal, buckling)
- What results are available?
### Phase 3: Define Objectives & Constraints
Ask focused questions:
```
Based on your model, I can see these results are available:
- Displacement (from static solution)
- Von Mises stress (from static solution)
- Natural frequency (from modal solution)
- Mass (from geometry)
**Questions:**
1. **Primary Objective** - What do you want to minimize/maximize?
Examples: "minimize tip displacement", "minimize mass"
2. **Secondary Objectives** (optional) - Any other goals?
Examples: "also minimize stress", "maximize first frequency"
3. **Constraints** - What limits must be respected?
Examples: "stress < 200 MPa", "frequency > 50 Hz", "mass < 2 kg"
```
### Phase 4: Define Design Space
For each design variable identified:
```
For parameter `{param_name}` (current value: {current_value}):
- **Minimum value**: (default: -20% of current)
- **Maximum value**: (default: +20% of current)
- **Type**: continuous or discrete?
```
### Phase 5: Optimization Settings
```
**Optimization Configuration:**
1. **Number of trials**: How thorough should the search be?
- Quick exploration: 50-100 trials
- Standard: 100-200 trials
- Thorough: 200-500 trials
- With neural acceleration: 500+ trials
2. **Protocol Selection** (I'll recommend based on your setup):
- Single objective → Protocol 10 (IMSO)
- Multi-objective (2-3 goals) → Protocol 11 (NSGA-II)
- Large-scale with NN → Protocol 12 (Hybrid)
3. **Neural Network Acceleration**:
- Enable if n_trials > 100 and you want faster iterations
```
### Phase 6: Summary & Confirmation
Present the complete configuration for user approval:
```
## Study Configuration Summary
**Study Name**: {study_name}
**Location**: studies/{study_name}/
**Model**: {model_path}
**Design Variables** ({n_vars} parameters):
| Parameter | Min | Max | Type |
|-----------|-----|-----|------|
| {name1} | {min1} | {max1} | continuous |
| ... | ... | ... | ... |
**Objectives**:
- {objective1}: {direction1}
- {objective2}: {direction2} (if multi-objective)
**Constraints**:
- {constraint1}
- {constraint2}
**Settings**:
- Protocol: {protocol}
- Trials: {n_trials}
- Sampler: {sampler}
- Neural Acceleration: {enabled/disabled}
---
Does this look correct?
- Type "yes" to generate the study files
- Type "change X" to modify a specific setting
- Type "start over" to begin again
```
### Phase 7: Generation
Once confirmed, generate:
1. Create study directory structure
2. Copy model files to working directory
3. Generate `optimization_config.json`
4. Generate `run_optimization.py`
5. Validate everything works
```
✓ Study created successfully!
**Next Steps:**
1. Review the generated files in studies/{study_name}/
2. Run a quick validation: `python run_optimization.py --validate`
3. Start optimization: `python run_optimization.py --start`
Or just tell me "start the optimization" and I'll handle it!
```
---
## Question Templates
### For Understanding Goals
- "What problem are you trying to solve?"
- "What makes a 'good' design for your application?"
- "Are there any hard limits that must not be exceeded?"
- "Is this a weight reduction study, a performance study, or both?"
### For Design Variables
- "Which dimensions or parameters should I vary?"
- "Are there any parameters that must stay fixed?"
- "What are reasonable bounds for {parameter}?"
- "Should {parameter} be continuous or discrete (specific values only)?"
### For Constraints
- "What's the maximum stress this component can handle?"
- "Is there a minimum stiffness requirement?"
- "Are there weight limits?"
- "What frequency should the structure avoid (resonance concerns)?"
### For Optimization Settings
- "How much time can you allocate to this study?"
- "Do you need a quick exploration or thorough optimization?"
- "Is this a preliminary study or final optimization?"
---
## Default Configurations by Use Case
### Structural Weight Minimization
```json
{
"objectives": [
{"name": "mass", "direction": "minimize", "target": null}
],
"constraints": [
{"name": "max_stress", "type": "<=", "value": 200e6, "unit": "Pa"},
{"name": "max_displacement", "type": "<=", "value": 0.001, "unit": "m"}
],
"n_trials": 150,
"sampler": "TPE"
}
```
### Multi-Objective (Weight vs Performance)
```json
{
"objectives": [
{"name": "mass", "direction": "minimize"},
{"name": "max_displacement", "direction": "minimize"}
],
"n_trials": 200,
"sampler": "NSGA-II"
}
```
### Modal Optimization (Frequency Tuning)
```json
{
"objectives": [
{"name": "first_frequency", "direction": "maximize"}
],
"constraints": [
{"name": "mass", "type": "<=", "value": 5.0, "unit": "kg"}
],
"n_trials": 150,
"sampler": "TPE"
}
```
### Telescope Mirror (Zernike WFE)
```json
{
"objectives": [
{"name": "filtered_rms", "direction": "minimize", "unit": "nm"}
],
"constraints": [
{"name": "mass", "type": "<=", "value": null}
],
"extractor": "ZernikeExtractor",
"n_trials": 200,
"sampler": "NSGA-II"
}
```
---
## Error Handling
### Model Not Found
```
I couldn't find a model at that path. Let's verify:
- Current directory: {cwd}
- You specified: {user_path}
Could you check the path and try again?
Tip: Use an absolute path like "C:/Users/.../model.prt"
```
### No Expressions Found
```
I couldn't find any parametric expressions in this model.
For optimization, we need parameters defined as NX expressions.
Would you like me to explain how to add expressions to your model?
```
### Invalid Constraint
```
That constraint doesn't match any available results.
Available results from your model:
- {result1}
- {result2}
Which of these would you like to constrain?
```
---
## Integration with Dashboard
When running from the Atomizer dashboard with a connected Claude terminal:
1. **No study selected** → Offer to create a new study
2. **Study selected** → Use that study's context, offer to modify or run
The dashboard will display the study once created, showing real-time progress.
---
## Quick Commands
For users who know what they want:
- `create study {name} from {model_path}` - Skip to model analysis
- `quick setup` - Use all defaults, just confirm
- `copy study {existing} as {new}` - Clone an existing study as starting point
---
## Remember
- **Be conversational** - This is a wizard, not a form
- **Offer sensible defaults** - Don't make users specify everything
- **Validate as you go** - Catch issues early
- **Explain decisions** - Say why you recommend certain settings
- **Keep it focused** - One question at a time, don't overwhelm

View File

@@ -0,0 +1,289 @@
# Extractors Catalog Module
**Last Updated**: December 5, 2025
**Version**: 1.0
**Type**: Optional Module
This module documents all available extractors in the Atomizer framework. Load this when the user asks about result extraction or needs to understand what extractors are available.
---
## When to Load
- User asks "what extractors are available?"
- User needs to extract results from OP2/BDF files
- Setting up a new study with custom extraction needs
- Debugging extraction issues
---
## PR.1 Extractor Catalog
| ID | Extractor | Module | Function | Input | Output | Returns |
|----|-----------|--------|----------|-------|--------|---------|
| E1 | **Displacement** | `optimization_engine.extractors.extract_displacement` | `extract_displacement(op2_file, subcase=1)` | `.op2` | mm | `{'max_displacement': float, 'max_disp_node': int, 'max_disp_x/y/z': float}` |
| E2 | **Frequency** | `optimization_engine.extractors.extract_frequency` | `extract_frequency(op2_file, subcase=1, mode_number=1)` | `.op2` | Hz | `{'frequency': float, 'mode_number': int, 'eigenvalue': float, 'all_frequencies': list}` |
| E3 | **Von Mises Stress** | `optimization_engine.extractors.extract_von_mises_stress` | `extract_solid_stress(op2_file, subcase=1, element_type='cquad4')` | `.op2` | MPa | `{'max_von_mises': float, 'max_stress_element': int}` |
| E4 | **BDF Mass** | `optimization_engine.extractors.bdf_mass_extractor` | `extract_mass_from_bdf(bdf_file)` | `.dat`/`.bdf` | kg | `float` (mass in kg) |
| E5 | **CAD Expression Mass** | `optimization_engine.extractors.extract_mass_from_expression` | `extract_mass_from_expression(prt_file, expression_name='p173')` | `.prt` + `_temp_mass.txt` | kg | `float` (mass in kg) |
| E6 | **Field Data** | `optimization_engine.extractors.field_data_extractor` | `FieldDataExtractor(field_file, result_column, aggregation)` | `.fld`/`.csv` | varies | `{'value': float, 'stats': dict}` |
| E7 | **Stiffness** | `optimization_engine.extractors.stiffness_calculator` | `StiffnessCalculator(field_file, op2_file, force_component, displacement_component)` | `.fld` + `.op2` | N/mm | `{'stiffness': float, 'displacement': float, 'force': float}` |
| E11 | **Part Mass & Material** | `optimization_engine.extractors.extract_part_mass_material` | `extract_part_mass_material(prt_file)` | `.prt` | kg + dict | `{'mass_kg': float, 'volume_mm3': float, 'material': {'name': str}, ...}` |
**For Zernike extractors (E8-E10)**, see the [zernike-optimization module](./zernike-optimization.md).
---
## PR.2 Extractor Code Snippets (COPY-PASTE)
### E1: Displacement Extraction
```python
from optimization_engine.extractors.extract_displacement import extract_displacement
disp_result = extract_displacement(op2_file, subcase=1)
max_displacement = disp_result['max_displacement'] # mm
max_node = disp_result['max_disp_node'] # Node ID
```
**Return Dictionary**:
```python
{
'max_displacement': 0.523, # Maximum magnitude (mm)
'max_disp_node': 1234, # Node ID with max displacement
'max_disp_x': 0.123, # X component at max node
'max_disp_y': 0.456, # Y component at max node
'max_disp_z': 0.234 # Z component at max node
}
```
### E2: Frequency Extraction
```python
from optimization_engine.extractors.extract_frequency import extract_frequency
# Get first mode frequency
freq_result = extract_frequency(op2_file, subcase=1, mode_number=1)
frequency = freq_result['frequency'] # Hz
# Get all frequencies
all_freqs = freq_result['all_frequencies'] # List of all mode frequencies
```
**Return Dictionary**:
```python
{
'frequency': 125.4, # Requested mode frequency (Hz)
'mode_number': 1, # Mode number requested
'eigenvalue': 6.21e5, # Eigenvalue (rad/s)^2
'all_frequencies': [125.4, 234.5, 389.2, ...] # All mode frequencies
}
```
### E3: Stress Extraction
```python
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
# For shell elements (CQUAD4, CTRIA3)
stress_result = extract_solid_stress(op2_file, subcase=1, element_type='cquad4')
# For solid elements (CTETRA, CHEXA)
stress_result = extract_solid_stress(op2_file, subcase=1, element_type='ctetra')
max_stress = stress_result['max_von_mises'] # MPa
```
**Return Dictionary**:
```python
{
'max_von_mises': 187.5, # Maximum von Mises stress (MPa)
'max_stress_element': 5678, # Element ID with max stress
'mean_stress': 45.2, # Mean stress across all elements
'stress_distribution': {...} # Optional: full distribution data
}
```
### E4: BDF Mass Extraction
```python
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
mass_kg = extract_mass_from_bdf(str(dat_file)) # kg
```
**Note**: Calculates mass from element properties and material density in the BDF/DAT file.
### E5: CAD Expression Mass
```python
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
mass_kg = extract_mass_from_expression(model_file, expression_name="p173") # kg
```
**Note**: Requires `_temp_mass.txt` to be written by solve journal. The expression name is the NX expression that contains the mass value.
### E6: Field Data Extraction
```python
from optimization_engine.extractors.field_data_extractor import FieldDataExtractor
# Create extractor
extractor = FieldDataExtractor(
field_file="results.fld",
result_column="Temperature",
aggregation="max" # or "min", "mean", "sum"
)
result = extractor.extract()
value = result['value'] # Aggregated value
stats = result['stats'] # Full statistics
```
### E7: Stiffness Calculation
```python
# Simple stiffness from displacement (most common)
applied_force = 1000.0 # N - MUST MATCH YOUR MODEL'S APPLIED LOAD
stiffness = applied_force / max(abs(max_displacement), 1e-6) # N/mm
# Or using StiffnessCalculator for complex cases
from optimization_engine.extractors.stiffness_calculator import StiffnessCalculator
calc = StiffnessCalculator(
field_file="displacement.fld",
op2_file="results.op2",
force_component="Fz",
displacement_component="Tz"
)
result = calc.calculate()
stiffness = result['stiffness'] # N/mm
```
### E11: Part Mass & Material Extraction
```python
from optimization_engine.extractors import extract_part_mass_material, extract_part_mass
# Full extraction with all properties
result = extract_part_mass_material(prt_file)
mass_kg = result['mass_kg'] # kg
volume = result['volume_mm3'] # mm³
area = result['surface_area_mm2'] # mm²
cog = result['center_of_gravity_mm'] # [x, y, z] mm
material = result['material']['name'] # e.g., "Aluminum_2014"
# Simple mass-only extraction
mass_kg = extract_part_mass(prt_file) # kg
```
**Return Dictionary**:
```python
{
'mass_kg': 0.1098, # Mass in kg
'mass_g': 109.84, # Mass in grams
'volume_mm3': 39311.99, # Volume in mm³
'surface_area_mm2': 10876.71, # Surface area in mm²
'center_of_gravity_mm': [0, 42.3, 39.6], # CoG in mm
'material': {
'name': 'Aluminum_2014', # Material name (or None)
'density': None, # Density if available
'density_unit': 'kg/mm^3'
},
'num_bodies': 1 # Number of solid bodies
}
```
**Prerequisites**: Run the NX journal first to create the temp file:
```bash
run_journal.exe nx_journals/extract_part_mass_material.py -args model.prt
```
---
## Extractor Selection Guide
| Need | Extractor | When to Use |
|------|-----------|-------------|
| Max deflection | E1 | Static analysis displacement check |
| Natural frequency | E2 | Modal analysis, resonance avoidance |
| Peak stress | E3 | Strength validation, fatigue life |
| FEM mass | E4 | When mass is from mesh elements |
| CAD mass | E5 | When mass is from NX expression |
| Temperature/Custom | E6 | Thermal or custom field results |
| k = F/δ | E7 | Stiffness maximization |
| Wavefront error | E8-E10 | Telescope/mirror optimization |
| Part mass + material | E11 | Direct from .prt file with material info |
---
## Engineering Result Types
| Result Type | Nastran SOL | Output File | Extractor |
|-------------|-------------|-------------|-----------|
| Static Stress | SOL 101 | `.op2` | E3: `extract_solid_stress` |
| Displacement | SOL 101 | `.op2` | E1: `extract_displacement` |
| Natural Frequency | SOL 103 | `.op2` | E2: `extract_frequency` |
| Buckling Load | SOL 105 | `.op2` | `extract_buckling` |
| Modal Shapes | SOL 103 | `.op2` | `extract_mode_shapes` |
| Mass | - | `.dat`/`.bdf` | E4: `bdf_mass_extractor` |
| Stiffness | SOL 101 | `.fld` + `.op2` | E7: `stiffness_calculator` |
---
## Common Objective Formulations
### Stiffness Maximization
- k = F/δ (force/displacement)
- Maximize k or minimize 1/k (compliance)
- Requires consistent load magnitude across trials
### Mass Minimization
- Extract from BDF element properties + material density
- Units: typically kg (NX uses kg-mm-s)
### Stress Constraints
- Von Mises < σ_yield / safety_factor
- Account for stress concentrations
### Frequency Constraints
- f₁ > threshold (avoid resonance)
- Often paired with mass minimization
---
## Adding New Extractors
When the study needs result extraction not covered by existing extractors (E1-E10):
```
STEP 1: Check existing extractors in this catalog
├── If exists → IMPORT and USE it (done!)
└── If missing → Continue to STEP 2
STEP 2: Create extractor in optimization_engine/extractors/
├── File: extract_{feature}.py
├── Follow existing extractor patterns
└── Include comprehensive docstrings
STEP 3: Add to __init__.py
└── Export functions in optimization_engine/extractors/__init__.py
STEP 4: Update this module
├── Add to Extractor Catalog table
└── Add code snippet
STEP 5: Document in SYS_12_EXTRACTOR_LIBRARY.md
```
See [EXT_01_CREATE_EXTRACTOR](../../docs/protocols/extensions/EXT_01_CREATE_EXTRACTOR.md) for full guide.
---
## Cross-References
- **System Protocol**: [SYS_12_EXTRACTOR_LIBRARY](../../docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md)
- **Extension Guide**: [EXT_01_CREATE_EXTRACTOR](../../docs/protocols/extensions/EXT_01_CREATE_EXTRACTOR.md)
- **Zernike Extractors**: [zernike-optimization module](./zernike-optimization.md)
- **Core Skill**: [study-creation-core](../core/study-creation-core.md)

View File

@@ -0,0 +1,340 @@
# Neural Acceleration Module
**Last Updated**: December 5, 2025
**Version**: 1.0
**Type**: Optional Module
This module provides guidance for AtomizerField neural network surrogate acceleration, enabling 1000x faster optimization by replacing expensive FEA evaluations with instant neural predictions.
---
## When to Load
- User needs >50 optimization trials
- User mentions "neural", "surrogate", "NN", "machine learning"
- User wants faster optimization
- Exporting training data for neural networks
---
## Overview
**Key Innovation**: Train once on FEA data, then explore 50,000+ designs in the time it takes to run 50 FEA trials.
| Metric | Traditional FEA | Neural Network | Improvement |
|--------|-----------------|----------------|-------------|
| Time per evaluation | 10-30 minutes | 4.5 milliseconds | **2,000-500,000x** |
| Trials per hour | 2-6 | 800,000+ | **1000x** |
| Design exploration | ~50 designs | ~50,000 designs | **1000x** |
---
## Training Data Export (PR.9)
Enable training data export in your optimization config:
```json
{
"training_data_export": {
"enabled": true,
"export_dir": "atomizer_field_training_data/my_study"
}
}
```
### Using TrainingDataExporter
```python
from optimization_engine.training_data_exporter import TrainingDataExporter
training_exporter = TrainingDataExporter(
export_dir=export_dir,
study_name=study_name,
design_variable_names=['param1', 'param2'],
objective_names=['stiffness', 'mass'],
constraint_names=['mass_limit'],
metadata={'atomizer_version': '2.0', 'optimization_algorithm': 'NSGA-II'}
)
# In objective function:
training_exporter.export_trial(
trial_number=trial.number,
design_variables=design_vars,
results={'objectives': {...}, 'constraints': {...}},
simulation_files={'dat_file': dat_path, 'op2_file': op2_path}
)
# After optimization:
training_exporter.finalize()
```
### Training Data Structure
```
atomizer_field_training_data/{study_name}/
├── trial_0001/
│ ├── input/model.bdf # Nastran input (mesh + params)
│ ├── output/model.op2 # Binary results
│ └── metadata.json # Design params + objectives
├── trial_0002/
│ └── ...
└── study_summary.json # Study-level metadata
```
**Recommended**: 100-500 FEA samples for good generalization.
---
## Neural Configuration
### Full Configuration Example
```json
{
"study_name": "bracket_neural_optimization",
"surrogate_settings": {
"enabled": true,
"model_type": "parametric_gnn",
"model_path": "models/bracket_surrogate.pt",
"confidence_threshold": 0.85,
"validation_frequency": 10,
"fallback_to_fea": true
},
"training_data_export": {
"enabled": true,
"export_dir": "atomizer_field_training_data/bracket_study",
"export_bdf": true,
"export_op2": true,
"export_fields": ["displacement", "stress"]
},
"neural_optimization": {
"initial_fea_trials": 50,
"neural_trials": 5000,
"retraining_interval": 500,
"uncertainty_threshold": 0.15
}
}
```
### Configuration Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enabled` | bool | false | Enable neural surrogate |
| `model_type` | string | "parametric_gnn" | Model architecture |
| `model_path` | string | - | Path to trained model |
| `confidence_threshold` | float | 0.85 | Min confidence for predictions |
| `validation_frequency` | int | 10 | FEA validation every N trials |
| `fallback_to_fea` | bool | true | Use FEA when uncertain |
---
## Model Types
### Parametric Predictor GNN (Recommended)
Direct optimization objective prediction - fastest option.
```
Design Parameters (ND) → Design Encoder (MLP) → GNN Backbone → Scalar Heads
Output (objectives):
├── mass (grams)
├── frequency (Hz)
├── max_displacement (mm)
└── max_stress (MPa)
```
**Use When**: You only need scalar objectives, not full field predictions.
### Field Predictor GNN
Full displacement/stress field prediction.
```
Input Features (12D per node):
├── Node coordinates (x, y, z)
├── Material properties (E, nu, rho)
├── Boundary conditions (fixed/free per DOF)
└── Load information (force magnitude, direction)
Output (per node):
├── Displacement (6 DOF: Tx, Ty, Tz, Rx, Ry, Rz)
└── Von Mises stress (1 value)
```
**Use When**: You need field visualization or complex derived quantities.
### Ensemble Models
Multiple models for uncertainty quantification.
```python
# Run N models
predictions = [model_i(x) for model_i in ensemble]
# Statistics
mean_prediction = np.mean(predictions)
uncertainty = np.std(predictions)
# Decision
if uncertainty > threshold:
result = run_fea(x) # Fall back to FEA
else:
result = mean_prediction
```
---
## Hybrid FEA/Neural Workflow
### Phase 1: FEA Exploration (50-100 trials)
- Run standard FEA optimization
- Export training data automatically
- Build landscape understanding
### Phase 2: Neural Training
- Parse collected data
- Train parametric predictor
- Validate accuracy
### Phase 3: Neural Acceleration (1000s of trials)
- Use neural network for rapid exploration
- Periodic FEA validation
- Retrain if distribution shifts
### Phase 4: FEA Refinement (10-20 trials)
- Validate top candidates with FEA
- Ensure results are physically accurate
- Generate final Pareto front
---
## Training Pipeline
### Step 1: Collect Training Data
Run optimization with export enabled:
```bash
python run_optimization.py --train --trials 100
```
### Step 2: Parse to Neural Format
```bash
cd atomizer-field
python batch_parser.py ../atomizer_field_training_data/my_study
```
### Step 3: Train Model
**Parametric Predictor** (recommended):
```bash
python train_parametric.py \
--train_dir ../training_data/parsed \
--val_dir ../validation_data/parsed \
--epochs 200 \
--hidden_channels 128 \
--num_layers 4
```
**Field Predictor**:
```bash
python train.py \
--train_dir ../training_data/parsed \
--epochs 200 \
--model FieldPredictorGNN \
--hidden_channels 128 \
--num_layers 6 \
--physics_loss_weight 0.3
```
### Step 4: Validate
```bash
python validate.py --checkpoint runs/my_model/checkpoint_best.pt
```
Expected output:
```
Validation Results:
├── Mean Absolute Error: 2.3% (mass), 1.8% (frequency)
├── R² Score: 0.987
├── Inference Time: 4.5ms ± 0.8ms
└── Physics Violations: 0.2%
```
### Step 5: Deploy
Update config to use trained model:
```json
{
"neural_surrogate": {
"enabled": true,
"model_checkpoint": "atomizer-field/runs/my_model/checkpoint_best.pt",
"confidence_threshold": 0.85
}
}
```
---
## Uncertainty Thresholds
| Uncertainty | Action |
|-------------|--------|
| < 5% | Use neural prediction |
| 5-15% | Use neural, flag for validation |
| > 15% | Fall back to FEA |
---
## Accuracy Expectations
| Problem Type | Expected R² | Samples Needed |
|--------------|-------------|----------------|
| Well-behaved | > 0.95 | 50-100 |
| Moderate nonlinear | > 0.90 | 100-200 |
| Highly nonlinear | > 0.85 | 200-500 |
---
## AtomizerField Components
```
atomizer-field/
├── neural_field_parser.py # BDF/OP2 parsing
├── field_predictor.py # Field GNN
├── parametric_predictor.py # Parametric GNN
├── train.py # Field training
├── train_parametric.py # Parametric training
├── validate.py # Model validation
├── physics_losses.py # Physics-informed loss
└── batch_parser.py # Batch data conversion
optimization_engine/
├── neural_surrogate.py # Atomizer integration
└── runner_with_neural.py # Neural runner
```
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| High prediction error | Insufficient training data | Collect more FEA samples |
| Out-of-distribution warnings | Design outside training range | Retrain with expanded range |
| Slow inference | Large mesh | Use parametric predictor instead |
| Physics violations | Low physics loss weight | Increase `physics_loss_weight` |
---
## Cross-References
- **System Protocol**: [SYS_14_NEURAL_ACCELERATION](../../docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md)
- **Operations**: [OP_05_EXPORT_TRAINING_DATA](../../docs/protocols/operations/OP_05_EXPORT_TRAINING_DATA.md)
- **Core Skill**: [study-creation-core](../core/study-creation-core.md)

View File

@@ -0,0 +1,209 @@
# NX Documentation Lookup Module
## Overview
This module provides on-demand access to Siemens NX Open and Simcenter documentation via the Dalidou MCP server. Use these tools when building new extractors, NX automation scripts, or debugging NX-related issues.
## CRITICAL: When to AUTO-SEARCH Documentation
**You MUST call `siemens_docs_search` BEFORE writing any code that uses NX Open APIs.**
### Automatic Search Triggers
| User Request | Action Required |
|--------------|-----------------|
| "Create extractor for {X}" | → `siemens_docs_search("{X} NXOpen")` |
| "Get {property} from part" | → `siemens_docs_search("{property} NXOpen.Part")` |
| "Extract {data} from FEM" | → `siemens_docs_search("{data} NXOpen.CAE")` |
| "How do I {action} in NX" | → `siemens_docs_search("{action} NXOpen")` |
| Any code with `NXOpen.*` | → Search before writing |
### Example: User asks "Create an extractor for inertia values"
```
STEP 1: Immediately search
→ siemens_docs_search("inertia mass properties NXOpen")
STEP 2: Review results, fetch details
→ siemens_docs_fetch("NXOpen.MeasureManager")
STEP 3: Now write code with correct API calls
```
**DO NOT guess NX Open API names.** Always search first.
## When to Load
Load this module when:
- Creating new NX Open scripts or extractors
- Working with `NXOpen.*` namespaces
- Debugging NX automation errors
- User mentions "NX API", "NX Open", "Simcenter docs"
- Building features that interact with NX/Simcenter
## Available MCP Tools
### `siemens_docs_search`
**Purpose**: Search across NX Open, Simcenter, and Teamcenter documentation
**When to use**:
- Finding which class/method performs a specific task
- Discovering available APIs for a feature
- Looking up Nastran card references
**Examples**:
```
siemens_docs_search("get node coordinates FEM")
siemens_docs_search("CQUAD4 element properties")
siemens_docs_search("NXOpen.CAE mesh creation")
siemens_docs_search("extract stress results OP2")
```
### `siemens_docs_fetch`
**Purpose**: Fetch a specific documentation page with full content
**When to use**:
- Need complete class reference
- Getting detailed method signatures
- Reading full examples
**Examples**:
```
siemens_docs_fetch("NXOpen.CAE.FemPart")
siemens_docs_fetch("Nastran Quick Reference CQUAD4")
```
### `siemens_auth_status`
**Purpose**: Check if the Siemens SSO session is valid
**When to use**:
- Before a series of documentation lookups
- When fetch requests fail
- Debugging connection issues
### `siemens_login`
**Purpose**: Re-authenticate with Siemens if session expired
**When to use**:
- After `siemens_auth_status` shows expired
- When documentation fetches return auth errors
## Workflow: Building New Extractor
When creating a new extractor that uses NX Open APIs:
### Step 1: Search for Relevant APIs
```
→ siemens_docs_search("element stress results OP2")
```
Review results to identify candidate classes/methods.
### Step 2: Fetch Detailed Documentation
```
→ siemens_docs_fetch("NXOpen.CAE.Result")
```
Get full class documentation with method signatures.
### Step 3: Understand Data Formats
```
→ siemens_docs_search("CQUAD4 stress output format")
```
Understand Nastran output structure.
### Step 4: Build Extractor
Following EXT_01 template, create the extractor with:
- Proper API calls based on documentation
- Docstring referencing the APIs used
- Error handling for common NX exceptions
### Step 5: Document API Usage
In the extractor docstring:
```python
def extract_element_stress(op2_path: Path) -> Dict:
"""
Extract element stress results from OP2 file.
NX Open APIs Used:
- NXOpen.CAE.Result.AskElementStress
- NXOpen.CAE.ResultAccess.AskResultValues
Nastran Cards:
- CQUAD4, CTRIA3 (shell elements)
- STRESS case control
"""
```
## Workflow: Debugging NX Errors
When encountering NX Open errors:
### Step 1: Search for Correct API
```
Error: AttributeError: 'FemPart' object has no attribute 'GetNodes'
→ siemens_docs_search("FemPart get nodes")
```
### Step 2: Fetch Correct Class Reference
```
→ siemens_docs_fetch("NXOpen.CAE.FemPart")
```
Find the actual method name and signature.
### Step 3: Apply Fix
Document the correction:
```python
# Wrong: femPart.GetNodes()
# Right: femPart.BaseFEModel.FemMesh.Nodes
```
## Common Search Patterns
| Task | Search Query |
|------|--------------|
| Mesh operations | `siemens_docs_search("NXOpen.CAE mesh")` |
| Result extraction | `siemens_docs_search("CAE result OP2")` |
| Geometry access | `siemens_docs_search("NXOpen.Features body")` |
| Material properties | `siemens_docs_search("Nastran MAT1 material")` |
| Load application | `siemens_docs_search("CAE load force")` |
| Constraint setup | `siemens_docs_search("CAE boundary condition")` |
| Expressions/Parameters | `siemens_docs_search("NXOpen Expression")` |
| Part manipulation | `siemens_docs_search("NXOpen.Part")` |
## Key NX Open Namespaces
| Namespace | Domain |
|-----------|--------|
| `NXOpen.CAE` | FEA, meshing, results |
| `NXOpen.Features` | Parametric features |
| `NXOpen.Assemblies` | Assembly operations |
| `NXOpen.Part` | Part-level operations |
| `NXOpen.UF` | User Function (legacy) |
| `NXOpen.GeometricUtilities` | Geometry helpers |
## Integration with Extractors
All extractors in `optimization_engine/extractors/` should:
1. **Search before coding**: Use `siemens_docs_search` to find correct APIs
2. **Document API usage**: List NX Open APIs in docstring
3. **Handle NX exceptions**: Catch `NXOpen.NXException` appropriately
4. **Follow 20-line rule**: If extraction is complex, check if existing extractor handles it
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Auth errors | Run `siemens_auth_status`, then `siemens_login` if needed |
| No results | Try broader search terms, check namespace spelling |
| Incomplete docs | Fetch the parent class for full context |
| Network errors | Verify Dalidou is accessible: `ping dalidou.local` |
---
*Module Version: 1.0*
*MCP Server: dalidou.local:5000*

View File

@@ -0,0 +1,364 @@
# Zernike Optimization Module
**Last Updated**: December 5, 2025
**Version**: 1.0
**Type**: Optional Module
This module provides specialized guidance for telescope mirror and optical surface optimization using Zernike polynomial decomposition.
---
## When to Load
- User mentions "telescope", "mirror", "optical", "wavefront"
- Optimization involves surface deformation analysis
- Need to extract Zernike coefficients from FEA results
- Working with multi-subcase elevation angle comparisons
---
## Zernike Extractors (E8-E10)
| ID | Extractor | Function | Input | Output | Use Case |
|----|-----------|----------|-------|--------|----------|
| E8 | **Zernike WFE** | `extract_zernike_from_op2()` | `.op2` + `.bdf` | nm | Single subcase wavefront error |
| E9 | **Zernike Relative** | `extract_zernike_relative_rms()` | `.op2` + `.bdf` | nm | Compare target vs reference subcase |
| E10 | **Zernike Helpers** | `ZernikeObjectiveBuilder` | `.op2` | nm | Multi-subcase optimization builder |
---
## E8: Single Subcase Zernike Extraction
Extract Zernike coefficients and RMS metrics for a single subcase (e.g., one elevation angle).
```python
from optimization_engine.extractors.extract_zernike import extract_zernike_from_op2
# Extract Zernike coefficients and RMS metrics for a single subcase
result = extract_zernike_from_op2(
op2_file,
bdf_file=None, # Auto-detect from op2 location
subcase="20", # Subcase label (e.g., "20" = 20 deg elevation)
displacement_unit="mm"
)
global_rms = result['global_rms_nm'] # Total surface RMS in nm
filtered_rms = result['filtered_rms_nm'] # RMS with low orders removed
coefficients = result['coefficients'] # List of 50 Zernike coefficients
```
**Return Dictionary**:
```python
{
'global_rms_nm': 45.2, # Total surface RMS (nm)
'filtered_rms_nm': 12.8, # RMS with J1-J4 (piston, tip, tilt, defocus) removed
'coefficients': [0.0, 12.3, ...], # 50 Zernike coefficients (Noll indexing)
'n_nodes': 5432, # Number of surface nodes
'rms_per_mode': {...} # RMS contribution per Zernike mode
}
```
**When to Use**:
- Single elevation angle analysis
- Polishing orientation (zenith) wavefront error
- Absolute surface quality metrics
---
## E9: Relative RMS Between Subcases
Compare wavefront error between two subcases (e.g., 40° vs 20° reference).
```python
from optimization_engine.extractors.extract_zernike import extract_zernike_relative_rms
# Compare wavefront error between subcases (e.g., 40 deg vs 20 deg reference)
result = extract_zernike_relative_rms(
op2_file,
bdf_file=None,
target_subcase="40", # Target orientation
reference_subcase="20", # Reference (usually polishing orientation)
displacement_unit="mm"
)
relative_rms = result['relative_filtered_rms_nm'] # Differential WFE in nm
delta_coeffs = result['delta_coefficients'] # Coefficient differences
```
**Return Dictionary**:
```python
{
'relative_filtered_rms_nm': 8.7, # Differential WFE (target - reference)
'delta_coefficients': [...], # Coefficient differences
'target_rms_nm': 52.3, # Target subcase absolute RMS
'reference_rms_nm': 45.2, # Reference subcase absolute RMS
'improvement_percent': -15.7 # Negative = worse than reference
}
```
**When to Use**:
- Comparing performance across elevation angles
- Minimizing deformation relative to polishing orientation
- Multi-angle telescope mirror optimization
---
## E10: Multi-Subcase Objective Builder
Build objectives for multiple subcases in a single extractor (most efficient for complex optimization).
```python
from optimization_engine.extractors.zernike_helpers import ZernikeObjectiveBuilder
# Build objectives for multiple subcases in one extractor
builder = ZernikeObjectiveBuilder(
op2_finder=lambda: model_dir / "ASSY_M1-solution_1.op2"
)
# Add relative objectives (target vs reference)
builder.add_relative_objective(
"40", "20", # 40° vs 20° reference
metric="relative_filtered_rms_nm",
weight=5.0
)
builder.add_relative_objective(
"60", "20", # 60° vs 20° reference
metric="relative_filtered_rms_nm",
weight=5.0
)
# Add absolute objective for polishing orientation
builder.add_subcase_objective(
"90", # Zenith (polishing orientation)
metric="rms_filter_j1to3", # Only remove piston, tip, tilt
weight=1.0
)
# Evaluate all at once (efficient - parses OP2 only once)
results = builder.evaluate_all()
# Returns: {'rel_40_vs_20': 4.2, 'rel_60_vs_20': 8.7, 'rms_90': 15.3}
```
**When to Use**:
- Multi-objective telescope optimization
- Multiple elevation angles to optimize
- Weighted combination of absolute and relative WFE
---
## Zernike Modes Reference
| Noll Index | Name | Physical Meaning | Correctability |
|------------|------|------------------|----------------|
| J1 | Piston | Constant offset | Easily corrected |
| J2 | Tip | X-tilt | Easily corrected |
| J3 | Tilt | Y-tilt | Easily corrected |
| J4 | Defocus | Power error | Easily corrected |
| J5 | Astigmatism (0°) | Cylindrical error | Correctable |
| J6 | Astigmatism (45°) | Cylindrical error | Correctable |
| J7 | Coma (x) | Off-axis aberration | Harder to correct |
| J8 | Coma (y) | Off-axis aberration | Harder to correct |
| J9-J10 | Trefoil | Triangular error | Hard to correct |
| J11+ | Higher order | Complex aberrations | Very hard to correct |
**Filtering Convention**:
- `filtered_rms`: Removes J1-J4 (piston, tip, tilt, defocus) - standard
- `rms_filter_j1to3`: Removes only J1-J3 (keeps defocus) - for focus-sensitive applications
---
## Common Zernike Optimization Patterns
### Pattern 1: Minimize Relative WFE Across Elevations
```python
# Objective: Minimize max relative WFE across all elevation angles
objectives = [
{"name": "rel_40_vs_20", "goal": "minimize"},
{"name": "rel_60_vs_20", "goal": "minimize"},
]
# Use weighted sum or multi-objective
def objective(trial):
results = builder.evaluate_all()
return (results['rel_40_vs_20'], results['rel_60_vs_20'])
```
### Pattern 2: Single Elevation + Mass
```python
# Objective: Minimize WFE at 45° while minimizing mass
objectives = [
{"name": "wfe_45", "goal": "minimize"}, # Wavefront error
{"name": "mass", "goal": "minimize"}, # Mirror mass
]
```
### Pattern 3: Weighted Multi-Angle
```python
# Weighted combination of multiple angles
def combined_wfe(trial):
results = builder.evaluate_all()
weighted_wfe = (
5.0 * results['rel_40_vs_20'] +
5.0 * results['rel_60_vs_20'] +
1.0 * results['rms_90']
)
return weighted_wfe
```
---
## Telescope Mirror Study Configuration
```json
{
"study_name": "m1_mirror_optimization",
"description": "Minimize wavefront error across elevation angles",
"objectives": [
{
"name": "wfe_40_vs_20",
"goal": "minimize",
"unit": "nm",
"extraction": {
"action": "extract_zernike_relative_rms",
"params": {
"target_subcase": "40",
"reference_subcase": "20"
}
}
}
],
"simulation": {
"analysis_types": ["static"],
"subcases": ["20", "40", "60", "90"],
"solution_name": null
}
}
```
---
## Performance Considerations
1. **Parse OP2 Once**: Use `ZernikeObjectiveBuilder` to parse the OP2 file only once per trial
2. **Subcase Labels**: Match exact subcase labels from NX simulation
3. **Node Selection**: Zernike extraction uses surface nodes only (auto-detected from BDF)
4. **Memory**: Large meshes (>50k nodes) may require chunked processing
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "Subcase not found" | Wrong subcase label | Check NX .sim for exact labels |
| High J1-J4 coefficients | Rigid body motion not constrained | Check boundary conditions |
| NaN in coefficients | Insufficient nodes for polynomial order | Reduce max Zernike order |
| Inconsistent RMS | Different node sets per subcase | Verify mesh consistency |
| "Billion nm" RMS values | Node merge failed in AFEM | Check `MergeOccurrenceNodes = True` |
| Corrupt OP2 data | All-zero displacements | Validate OP2 before processing |
---
## Assembly FEM (AFEM) Structure for Mirrors
Telescope mirror assemblies in NX typically consist of:
```
ASSY_M1.prt # Master assembly part
ASSY_M1_assyfem1.afm # Assembly FEM container
ASSY_M1_assyfem1_sim1.sim # Simulation file (solve this)
M1_Blank.prt # Mirror blank part
M1_Blank_fem1.fem # Mirror blank mesh
M1_Vertical_Support_Skeleton.prt # Support structure
```
**Key Point**: Expressions in master `.prt` propagate through assembly → AFEM updates automatically.
---
## Multi-Subcase Gravity Analysis
For telescope mirrors, analyze multiple gravity orientations:
| Subcase | Elevation Angle | Purpose |
|---------|-----------------|---------|
| 1 | 90° (zenith) | Polishing orientation - manufacturing reference |
| 2 | 20° | Low elevation - reference for relative metrics |
| 3 | 40° | Mid-low elevation |
| 4 | 60° | Mid-high elevation |
**CRITICAL**: NX subcase numbers don't always match angle labels! Use explicit mapping:
```json
"subcase_labels": {
"1": "90deg",
"2": "20deg",
"3": "40deg",
"4": "60deg"
}
```
---
## Lessons Learned (M1 Mirror V1-V9)
### 1. TPE Sampler Seed Issue
**Problem**: Resuming study with fixed seed causes duplicate parameters.
**Solution**:
```python
if is_new_study:
sampler = TPESampler(seed=42)
else:
sampler = TPESampler() # No seed for resume
```
### 2. OP2 Data Validation
**Always validate before processing**:
```python
unique_values = len(np.unique(disp_z))
if unique_values < 10:
raise RuntimeError("CORRUPT OP2: insufficient unique values")
if np.abs(disp_z).max() > 1e6:
raise RuntimeError("CORRUPT OP2: unrealistic displacement")
```
### 3. Reference Subcase Selection
Use lowest operational elevation (typically 20°) as reference. Higher elevations show positive relative WFE as gravity effects increase.
### 4. Optical Convention
For mirror surface to wavefront error:
```python
WFE = 2 * surface_displacement # Reflection doubles path difference
wfe_nm = 2.0 * displacement_mm * 1e6 # Convert mm to nm
```
---
## Typical Mirror Design Variables
| Parameter | Description | Typical Range |
|-----------|-------------|---------------|
| `whiffle_min` | Whiffle tree minimum dimension | 35-55 mm |
| `whiffle_outer_to_vertical` | Whiffle arm angle | 68-80 deg |
| `inner_circular_rib_dia` | Rib diameter | 480-620 mm |
| `lateral_inner_angle` | Lateral support angle | 25-28.5 deg |
| `blank_backface_angle` | Mirror blank geometry | 3.5-5.0 deg |
---
## Cross-References
- **Extractor Catalog**: [extractors-catalog module](./extractors-catalog.md)
- **System Protocol**: [SYS_12_EXTRACTOR_LIBRARY](../../docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md)
- **Core Skill**: [study-creation-core](../core/study-creation-core.md)

490
CLAUDE.md
View File

@@ -2,262 +2,336 @@
You are the AI orchestrator for **Atomizer**, an LLM-first FEA optimization framework. Your role is to help users set up, run, and analyze structural optimization studies through natural conversation.
## Session Initialization (CRITICAL - Read on Every New Session)
On **EVERY new Claude session**, perform these initialization steps:
### Step 1: Load Context
1. Read `.claude/ATOMIZER_CONTEXT.md` for unified context (if not already loaded via this file)
2. This file (CLAUDE.md) provides system instructions
3. Use `.claude/skills/00_BOOTSTRAP.md` for task routing
### Step 2: Detect Study Context
If working directory is inside a study (`studies/*/`):
1. Read `optimization_config.json` to understand the study
2. Check `2_results/study.db` for optimization status (trial count, state)
3. Summarize study state to user in first response
### Step 3: Route by User Intent
| User Keywords | Load Protocol | Subagent Type |
|---------------|---------------|---------------|
| "create", "new", "set up" | OP_01, SYS_12 | general-purpose |
| "run", "start", "trials" | OP_02, SYS_15 | - (direct execution) |
| "status", "progress" | OP_03 | - (DB query) |
| "results", "analyze", "Pareto" | OP_04 | - (analysis) |
| "neural", "surrogate", "turbo" | SYS_14, SYS_15 | general-purpose |
| "NX", "model", "expression" | MCP siemens-docs | general-purpose |
| "error", "fix", "debug" | OP_06 | Explore |
### Step 4: Proactive Actions
- If optimization is running: Report progress automatically
- If no study context: Offer to create one or list available studies
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
---
## Quick Start - Protocol Operating System
**For ANY task, first check**: `.claude/skills/00_BOOTSTRAP.md`
This file provides:
- Task classification (CREATE → RUN → MONITOR → ANALYZE → DEBUG)
- Protocol routing (which docs to load)
- Role detection (user / power_user / admin)
## Core Philosophy
**Talk, don't click.** Users describe what they want in plain language. You interpret, configure, execute, and explain. The dashboard is for monitoring - you handle the setup.
**Talk, don't click.** Users describe what they want in plain language. You interpret, configure, execute, and explain.
## What Atomizer Does
## Context Loading Layers
Atomizer automates parametric FEA optimization using NX Nastran:
- User describes optimization goals in natural language
- You create configurations, scripts, and study structure
- NX Nastran runs FEA simulations
- Optuna optimizes design parameters
- Neural networks accelerate repeated evaluations
- Dashboard visualizes results in real-time
The Protocol Operating System (POS) provides layered documentation:
## Your Capabilities
| Layer | Location | When to Load |
|-------|----------|--------------|
| **Bootstrap** | `.claude/skills/00-02*.md` | Always (via this file) |
| **Operations** | `docs/protocols/operations/OP_*.md` | Per task type |
| **System** | `docs/protocols/system/SYS_*.md` | When protocols referenced |
| **Extensions** | `docs/protocols/extensions/EXT_*.md` | When extending (power_user+) |
### 1. Create Optimization Studies
When user wants to optimize something:
- Gather requirements through conversation
- Read `.claude/skills/create-study.md` for the full protocol
- Generate all configuration files
- Validate setup before running
**Context loading rules**: See `.claude/skills/02_CONTEXT_LOADER.md`
### 2. Analyze NX Models
When user provides NX files:
- Extract expressions (design parameters)
- Identify simulation setup
- Suggest optimization targets
- Check for multi-solution requirements
## Task → Protocol Quick Lookup
### 3. Run & Monitor Optimizations
- Start optimization runs
- Check progress in databases
- Interpret results
- Generate reports
| Task | Protocol | Key File |
|------|----------|----------|
| Create study | OP_01 | `docs/protocols/operations/OP_01_CREATE_STUDY.md` |
| Run optimization | OP_02 | `docs/protocols/operations/OP_02_RUN_OPTIMIZATION.md` |
| Check progress | OP_03 | `docs/protocols/operations/OP_03_MONITOR_PROGRESS.md` |
| Analyze results | OP_04 | `docs/protocols/operations/OP_04_ANALYZE_RESULTS.md` |
| Export neural data | OP_05 | `docs/protocols/operations/OP_05_EXPORT_TRAINING_DATA.md` |
| Debug issues | OP_06 | `docs/protocols/operations/OP_06_TROUBLESHOOT.md` |
### 4. Configure Neural Network Surrogates
When optimization needs >50 trials:
- Generate space-filling training data
- Run parallel FEA for training
- Train and validate surrogates
- Enable accelerated optimization
## System Protocols (Technical Specs)
### 5. Troubleshoot Issues
- Parse error logs
- Identify common problems
- Suggest fixes
- Recover from failures
| # | Name | When to Load |
|---|------|--------------|
| 10 | IMSO (Adaptive) | Single-objective, "adaptive", "intelligent" |
| 11 | Multi-Objective | 2+ objectives, "pareto", NSGA-II |
| 12 | Extractor Library | Any extraction, "displacement", "stress" |
| 13 | Dashboard | "dashboard", "real-time", monitoring |
| 14 | Neural Acceleration | >50 trials, "neural", "surrogate" |
| 15 | Method Selector | "which method", "recommend", "turbo vs" |
## Key Files & Locations
**Full specs**: `docs/protocols/system/SYS_{N}_{NAME}.md`
## Python Environment
**CRITICAL: Always use the `atomizer` conda environment.**
```bash
conda activate atomizer
python run_optimization.py
```
**DO NOT:**
- Install packages with pip/conda (everything is installed)
- Create new virtual environments
- Use system Python
## Key Directories
```
Atomizer/
├── .claude/
│ ├── skills/ # Skill instructions (READ THESE)
│ ├── create-study.md # Main study creation skill
│ └── analyze-workflow.md
│ └── settings.local.json
├── docs/
│ ├── 01_PROTOCOLS.md # Quick protocol reference
│ ├── 06_PROTOCOLS_DETAILED/ # Full protocol docs
│ └── 07_DEVELOPMENT/ # Development plans
├── .claude/skills/ # LLM skills (Bootstrap + Core + Modules)
├── docs/protocols/ # Protocol Operating System
│ ├── operations/ # OP_01 - OP_06
├── system/ # SYS_10 - SYS_15
│ └── extensions/ # EXT_01 - EXT_04
├── optimization_engine/ # Core Python modules
│ ├── runner.py # Main optimizer
── nx_solver.py # NX interface
│ ├── extractors/ # Result extraction
│ └── validators/ # Config validation
├── studies/ # User studies live here
│ └── {study_name}/
│ ├── 1_setup/ # Config & model files
│ ├── 2_results/ # Optuna DB & outputs
│ └── run_optimization.py
│ ├── extractors/ # Physics extraction library
── gnn/ # GNN surrogate module (Zernike)
├── studies/ # User studies
└── atomizer-dashboard/ # React dashboard
```
## Conversation Patterns
## GNN Surrogate for Zernike Optimization
### User: "I want to optimize this bracket"
1. Ask about model location, goals, constraints
2. Load skill: `.claude/skills/create-study.md`
3. Follow the interactive discovery process
4. Generate files, validate, confirm
The `optimization_engine/gnn/` module provides Graph Neural Network surrogates for mirror optimization:
### User: "Run 200 trials with neural network"
1. Check if surrogate_settings needed
2. Modify config to enable NN
3. Explain the hybrid workflow stages
4. Start run, show monitoring options
| Component | Purpose |
|-----------|---------|
| `polar_graph.py` | PolarMirrorGraph - fixed 3000-node polar grid |
| `zernike_gnn.py` | ZernikeGNN model with design-conditioned convolutions |
| `differentiable_zernike.py` | GPU-accelerated Zernike fitting |
| `train_zernike_gnn.py` | Training pipeline with multi-task loss |
| `gnn_optimizer.py` | ZernikeGNNOptimizer for turbo mode |
### User: "What's the status?"
1. Query database for trial counts
2. Check for running background processes
3. Summarize progress and best results
4. Suggest next steps
### Quick Start
### User: "The optimization failed"
1. Read error logs
2. Check common failure modes
3. Suggest fixes
4. Offer to retry
## Protocols Reference
| Protocol | Use Case | Sampler |
|----------|----------|---------|
| Protocol 10 | Single objective + constraints | TPE/CMA-ES |
| Protocol 11 | Multi-objective (2-3 goals) | NSGA-II |
| Protocol 12 | Hybrid FEA/NN acceleration | NSGA-II + surrogate |
## Result Extraction
Use centralized extractors from `optimization_engine/extractors/`:
| Need | Extractor | Example |
|------|-----------|---------|
| Displacement | `extract_displacement` | Max tip deflection |
| Stress | `extract_solid_stress` | Max von Mises |
| Frequency | `extract_frequency` | 1st natural freq |
| Mass | `extract_mass_from_expression` | CAD mass property |
## Multi-Solution Detection
If user needs BOTH:
- Static results (stress, displacement)
- Modal results (frequency)
Then set `solution_name=None` to solve ALL solutions.
## Validation Before Action
Always validate before:
- Starting optimization (config validator)
- Generating files (check paths exist)
- Running FEA (check NX files present)
## Dashboard Integration
- Setup/Config: **You handle it**
- Real-time monitoring: **Dashboard at localhost:3000**
- Results analysis: **Both (you interpret, dashboard visualizes)**
## CRITICAL: Code Reuse Protocol (MUST FOLLOW)
### STOP! Before Writing ANY Code in run_optimization.py
**This is the #1 cause of code duplication. EVERY TIME you're about to write:**
- A function longer than 20 lines
- Any physics/math calculations (Zernike, RMS, stress, etc.)
- Any OP2/BDF parsing logic
- Any post-processing or extraction logic
**STOP and run this checklist:**
```bash
# Train GNN on existing FEA data
python -m optimization_engine.gnn.train_zernike_gnn V11 V12 --epochs 200
# Run turbo optimization (5000 GNN trials)
cd studies/m1_mirror_adaptive_V12
python run_gnn_turbo.py --trials 5000
```
□ Did I check optimization_engine/extractors/__init__.py?
□ Did I grep for similar function names in optimization_engine/?
□ Does this functionality exist somewhere else in the codebase?
**Full documentation**: `docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md`
## CRITICAL: NX Open Development Protocol
### Always Use Official Documentation First
**For ANY development involving NX, NX Open, or Siemens APIs:**
1. **FIRST** - Query the MCP Siemens docs tools:
- `mcp__siemens-docs__nxopen_get_class` - Get class documentation
- `mcp__siemens-docs__nxopen_get_index` - Browse class/function indexes
- `mcp__siemens-docs__siemens_docs_list` - List available resources
2. **THEN** - Use secondary sources if needed:
- PyNastran documentation (for BDF/OP2 parsing)
- NXOpen TSE examples in `nx_journals/`
- Existing extractors in `optimization_engine/extractors/`
3. **NEVER** - Guess NX Open API calls without checking documentation first
**Available NX Open Classes (quick lookup):**
| Class | Page ID | Description |
|-------|---------|-------------|
| Session | a03318.html | Main NX session object |
| Part | a02434.html | Part file operations |
| BasePart | a00266.html | Base class for parts |
| CaeSession | a10510.html | CAE/FEM session |
| PdmSession | a50542.html | PDM integration |
**Example workflow for NX journal development:**
```
1. User: "Extract mass from NX part"
2. Claude: Query nxopen_get_class("Part") to find mass-related methods
3. Claude: Query nxopen_get_class("Session") to understand part access
4. Claude: Check existing extractors for similar functionality
5. Claude: Write code using verified API calls
```
**MCP Server Setup:** See `mcp-server/README.md`
## CRITICAL: Code Reuse Protocol
### The 20-Line Rule
If you're writing a function longer than ~20 lines in `studies/*/run_optimization.py`:
If you're writing a function longer than ~20 lines in `run_optimization.py`:
1. **STOP** - This is a code smell
2. **SEARCH** - The functionality probably exists
3. **IMPORT** - Use the existing module
4. **Only if truly new** - Create in `optimization_engine/extractors/`, NOT in the study
2. **SEARCH** - Check `optimization_engine/extractors/`
3. **IMPORT** - Use existing extractor
4. **Only if truly new** - Follow EXT_01 to create new extractor
### Available Extractors (ALWAYS CHECK FIRST)
### Available Extractors
| Module | Functions | Use For |
|--------|-----------|---------|
| **`extract_zernike.py`** | `ZernikeExtractor`, `extract_zernike_from_op2`, `extract_zernike_filtered_rms`, `extract_zernike_relative_rms` | Telescope mirror WFE analysis - Noll indexing, RMS calculations, multi-subcase |
| **`zernike_helpers.py`** | `create_zernike_objective`, `ZernikeObjectiveBuilder`, `extract_zernike_for_trial` | Zernike optimization integration |
| **`extract_displacement.py`** | `extract_displacement` | Max/min displacement from OP2 |
| **`extract_von_mises_stress.py`** | `extract_solid_stress` | Von Mises stress extraction |
| **`extract_frequency.py`** | `extract_frequency` | Natural frequencies from OP2 |
| **`extract_mass.py`** | `extract_mass_from_expression` | CAD mass property |
| **`op2_extractor.py`** | Generic OP2 result extraction | Low-level OP2 access |
| **`field_data_extractor.py`** | Field data for neural networks | Training data generation |
| ID | Physics | Function |
|----|---------|----------|
| E1 | Displacement | `extract_displacement()` |
| E2 | Frequency | `extract_frequency()` |
| E3 | Stress | `extract_solid_stress()` |
| E4 | BDF Mass | `extract_mass_from_bdf()` |
| E5 | CAD Mass | `extract_mass_from_expression()` |
| E8-10 | Zernike | `extract_zernike_*()` |
### Correct Pattern: Zernike Example
**Full catalog**: `docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md`
**❌ WRONG - What I did (and must NEVER do again):**
```python
# studies/m1_mirror/run_optimization.py
def noll_indices(j): # 30 lines
...
def zernike_radial(n, m, r): # 20 lines
...
def compute_zernike_coefficients(...): # 80 lines
...
def compute_rms_metrics(...): # 40 lines
...
# Total: 500+ lines of duplicated code
```
## Privilege Levels
**✅ CORRECT - What I should have done:**
```python
# studies/m1_mirror/run_optimization.py
from optimization_engine.extractors import (
ZernikeExtractor,
extract_zernike_for_trial
)
| Level | Operations | Extensions |
|-------|------------|------------|
| **user** | All OP_* | None |
| **power_user** | All OP_* | EXT_01, EXT_02 |
| **admin** | All | All |
# In objective function - 5 lines instead of 500
extractor = ZernikeExtractor(op2_file, bdf_file)
result = extractor.extract_relative(target_subcase="40", reference_subcase="20")
filtered_rms = result['relative_filtered_rms_nm']
```
### Creating New Extractors (Only When Truly Needed)
When functionality genuinely doesn't exist:
```
1. CREATE module in optimization_engine/extractors/new_feature.py
2. ADD exports to optimization_engine/extractors/__init__.py
3. UPDATE this table in CLAUDE.md
4. IMPORT in run_optimization.py (just the import, not the implementation)
```
### Why This Is Critical
| Embedding Code in Studies | Using Central Extractors |
|---------------------------|-------------------------|
| Bug fixes don't propagate | Fix once, applies everywhere |
| No unit tests | Tested in isolation |
| Hard to discover | Clear API in __init__.py |
| Copy-paste errors | Single source of truth |
| 500+ line studies | Clean, readable studies |
Default to `user` unless explicitly stated otherwise.
## Key Principles
1. **Conversation first** - Don't ask user to edit JSON manually
2. **Validate everything** - Catch errors before they cause failures
3. **Explain decisions** - Say why you chose a sampler/protocol
4. **Sensible defaults** - User only specifies what they care about
5. **Progressive disclosure** - Start simple, add complexity when needed
6. **NEVER modify master files** - Always copy model files to study working directory before optimization. User's source files must remain untouched. If corruption occurs during iteration, working copy can be deleted and re-copied.
7. **ALWAYS reuse existing code** - Check `optimization_engine/extractors/` BEFORE writing any new post-processing logic. Never duplicate functionality that already exists.
4. **NEVER modify master files** - Copy NX files to study directory
5. **ALWAYS reuse code** - Check extractors before writing new code
## Current State Awareness
## CRITICAL: NX FEM Mesh Update Requirements
Check these before suggesting actions:
- Running background processes: `/tasks` command
- Study databases: `studies/*/2_results/study.db`
- Model files: `studies/*/1_setup/model/`
- Dashboard status: Check if servers running
**When parametric optimization produces identical results, the mesh is NOT updating!**
### Required File Chain
```
.sim (Simulation)
└── .fem (FEM)
└── *_i.prt (Idealized Part) ← MUST EXIST AND BE LOADED!
└── .prt (Geometry Part)
```
### The Fix (Already Implemented in solve_simulation.py)
The idealized part (`*_i.prt`) MUST be explicitly loaded BEFORE calling `UpdateFemodel()`:
```python
# STEP 2: Load idealized part first (CRITICAL!)
for filename in os.listdir(working_dir):
if '_i.prt' in filename.lower():
idealized_part, status = theSession.Parts.Open(path)
break
# THEN update FEM - now it will actually regenerate the mesh
feModel.UpdateFemodel()
```
**Without loading the `_i.prt`, `UpdateFemodel()` runs but the mesh doesn't change!**
### Study Setup Checklist
When creating a new study, ensure ALL these files are copied:
- [ ] `Model.prt` - Geometry part
- [ ] `Model_fem1_i.prt` - Idealized part ← **OFTEN MISSING!**
- [ ] `Model_fem1.fem` - FEM file
- [ ] `Model_sim1.sim` - Simulation file
See `docs/protocols/operations/OP_06_TROUBLESHOOT.md` for full troubleshooting guide.
## Developer Documentation
**For developers maintaining Atomizer**:
- Read `.claude/skills/DEV_DOCUMENTATION.md`
- Use self-documenting commands: "Document the {feature} I added"
- Commit code + docs together
## When Uncertain
1. Read the relevant skill file
2. Check docs/06_PROTOCOLS_DETAILED/
3. Look at existing similar studies
1. Check `.claude/skills/00_BOOTSTRAP.md` for task routing
2. Check `.claude/skills/01_CHEATSHEET.md` for quick lookup
3. Load relevant protocol from `docs/protocols/`
4. Ask user for clarification
---
## Subagent Architecture
For complex tasks, spawn specialized subagents using the Task tool:
### Available Subagent Patterns
| Task Type | Subagent | Context to Provide |
|-----------|----------|-------------------|
| **Create Study** | `general-purpose` | Load `core/study-creation-core.md`, SYS_12. Task: Create complete study from description. |
| **NX Automation** | `general-purpose` | Use MCP siemens-docs tools. Query NXOpen classes before writing journals. |
| **Codebase Search** | `Explore` | Search for patterns, extractors, or understand existing code |
| **Architecture** | `Plan` | Design implementation approach for complex features |
| **Protocol Audit** | `general-purpose` | Validate config against SYS_12 extractors, check for issues |
### When to Use Subagents
**Use subagents for**:
- Creating new studies (complex, multi-file generation)
- NX API lookups and journal development
- Searching for patterns across multiple files
- Planning complex architectural changes
**Don't use subagents for**:
- Simple file reads/edits
- Running Python scripts
- Quick DB queries
- Direct user questions
### Subagent Prompt Template
When spawning a subagent, provide comprehensive context:
```
Context: [What the user wants]
Study: [Current study name if applicable]
Files to check: [Specific paths]
Task: [Specific deliverable expected]
Output: [What to return - files created, analysis, etc.]
```
---
## Auto-Documentation Protocol
When creating or modifying extractors/protocols, **proactively update docs**:
1. **New extractor created**
- Add to `optimization_engine/extractors/__init__.py`
- Update `SYS_12_EXTRACTOR_LIBRARY.md`
- Update `.claude/skills/01_CHEATSHEET.md`
- Commit with: `feat: Add E{N} {name} extractor`
2. **Protocol updated**
- Update version in protocol header
- Update `ATOMIZER_CONTEXT.md` version table
- Mention in commit message
3. **New study template**
- Add to `optimization_engine/templates/registry.json`
- Update `ATOMIZER_CONTEXT.md` template table
---
*Atomizer: Where engineers talk, AI optimizes.*

View File

@@ -34,6 +34,63 @@ def get_results_dir(study_dir: Path) -> Path:
return results_dir
def is_optimization_running(study_id: str) -> bool:
"""Check if an optimization process is currently running for a study.
Looks for Python processes running run_optimization.py with the study_id in the command line.
"""
study_dir = STUDIES_DIR / study_id
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']):
try:
cmdline = proc.info.get('cmdline') or []
cmdline_str = ' '.join(cmdline) if cmdline else ''
# Check if this is a Python process running run_optimization.py for this study
if 'python' in cmdline_str.lower() and 'run_optimization' in cmdline_str:
if study_id in cmdline_str or str(study_dir) in cmdline_str:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return False
def get_accurate_study_status(study_id: str, trial_count: int, total_trials: int, has_db: bool) -> str:
"""Determine accurate study status based on multiple factors.
Status can be:
- not_started: No database or 0 trials
- running: Active process found
- paused: Has trials but no active process and not completed
- completed: Reached trial target
- failed: Has error indicators (future enhancement)
Args:
study_id: The study identifier
trial_count: Number of completed trials
total_trials: Target number of trials from config
has_db: Whether the study database exists
Returns:
Status string: "not_started", "running", "paused", or "completed"
"""
# No database or no trials = not started
if not has_db or trial_count == 0:
return "not_started"
# Check if we've reached the target
if trial_count >= total_trials:
return "completed"
# Check if process is actively running
if is_optimization_running(study_id):
return "running"
# Has trials but not running and not complete = paused
return "paused"
@router.get("/studies")
async def list_studies():
"""List all available optimization studies"""
@@ -66,12 +123,13 @@ async def list_studies():
study_db = results_dir / "study.db"
history_file = results_dir / "optimization_history_incremental.json"
status = "not_started"
trial_count = 0
best_value = None
has_db = False
# Protocol 10: Read from Optuna SQLite database
if study_db.exists():
has_db = True
try:
# Use timeout to avoid blocking on locked databases
conn = sqlite3.connect(str(study_db), timeout=2.0)
@@ -97,19 +155,12 @@ async def list_studies():
conn.close()
# Determine status
total_trials = config.get('optimization_settings', {}).get('n_trials', 50)
if trial_count >= total_trials:
status = "completed"
else:
status = "running" # Simplified - would need process check
except Exception as e:
print(f"Warning: Failed to read Optuna database for {study_dir.name}: {e}")
status = "error"
# Legacy: Read from JSON history
elif history_file.exists():
has_db = True
with open(history_file) as f:
history = json.load(f)
trial_count = len(history)
@@ -118,19 +169,15 @@ async def list_studies():
best_trial = min(history, key=lambda x: x['objective'])
best_value = best_trial['objective']
# Determine status
total_trials = config.get('trials', {}).get('n_trials', 50)
if trial_count >= total_trials:
status = "completed"
else:
status = "running" # Simplified - would need process check
# Get total trials from config (supports both formats)
total_trials = (
config.get('optimization_settings', {}).get('n_trials') or
config.get('trials', {}).get('n_trials', 50)
)
# Get accurate status using process detection
status = get_accurate_study_status(study_dir.name, trial_count, total_trials, has_db)
# Get creation date from directory or config modification time
created_at = None
try:
@@ -240,7 +287,7 @@ async def get_study_status(study_id: str):
conn.close()
total_trials = config.get('optimization_settings', {}).get('n_trials', 50)
status = "completed" if trial_count >= total_trials else "running"
status = get_accurate_study_status(study_id, trial_count, total_trials, True)
return {
"study_id": study_id,
@@ -639,6 +686,82 @@ async def get_pareto_front(study_id: str):
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get Pareto front: {str(e)}")
@router.get("/studies/{study_id}/nn-pareto-front")
async def get_nn_pareto_front(study_id: str):
"""Get NN surrogate Pareto front from nn_pareto_front.json"""
try:
study_dir = STUDIES_DIR / study_id
results_dir = get_results_dir(study_dir)
nn_pareto_file = results_dir / "nn_pareto_front.json"
if not nn_pareto_file.exists():
return {"has_nn_results": False, "pareto_front": []}
with open(nn_pareto_file) as f:
nn_pareto = json.load(f)
# Transform to match Trial interface format
transformed = []
for trial in nn_pareto:
transformed.append({
"trial_number": trial.get("trial_number"),
"values": [trial.get("mass"), trial.get("frequency")],
"params": trial.get("params", {}),
"user_attrs": {
"source": "NN",
"feasible": trial.get("feasible", False),
"predicted_stress": trial.get("predicted_stress"),
"predicted_displacement": trial.get("predicted_displacement"),
"mass": trial.get("mass"),
"frequency": trial.get("frequency")
},
"constraint_satisfied": trial.get("feasible", False),
"source": "NN"
})
return {
"has_nn_results": True,
"pareto_front": transformed,
"count": len(transformed)
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get NN Pareto front: {str(e)}")
@router.get("/studies/{study_id}/nn-state")
async def get_nn_optimization_state(study_id: str):
"""Get NN optimization state/summary from nn_optimization_state.json"""
try:
study_dir = STUDIES_DIR / study_id
results_dir = get_results_dir(study_dir)
nn_state_file = results_dir / "nn_optimization_state.json"
if not nn_state_file.exists():
return {"has_nn_state": False}
with open(nn_state_file) as f:
state = json.load(f)
return {
"has_nn_state": True,
"total_fea_count": state.get("total_fea_count", 0),
"total_nn_count": state.get("total_nn_count", 0),
"pareto_front_size": state.get("pareto_front_size", 0),
"best_mass": state.get("best_mass"),
"best_frequency": state.get("best_frequency"),
"timestamp": state.get("timestamp")
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get NN state: {str(e)}")
@router.post("/studies")
async def create_study(
config: str = Form(...),
@@ -1671,3 +1794,563 @@ run_server("{storage_url}", host="0.0.0.0", port={port})
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to launch Optuna dashboard: {str(e)}")
# ============================================================================
# Model Files Endpoint
# ============================================================================
@router.get("/studies/{study_id}/model-files")
async def get_model_files(study_id: str):
"""
Get list of NX model files (.prt, .sim, .fem, .bdf, .dat, .op2) for a study
Args:
study_id: Study identifier
Returns:
JSON with list of model files and their paths
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Look for model directory (check multiple locations)
model_dirs = [
study_dir / "1_setup" / "model",
study_dir / "model",
study_dir / "1_setup",
study_dir
]
model_files = []
model_dir_path = None
# NX and FEA file extensions to look for
nx_extensions = {'.prt', '.sim', '.fem', '.bdf', '.dat', '.op2', '.f06', '.inp'}
for model_dir in model_dirs:
if model_dir.exists() and model_dir.is_dir():
for file_path in model_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in nx_extensions:
model_files.append({
"name": file_path.name,
"path": str(file_path),
"extension": file_path.suffix.lower(),
"size_bytes": file_path.stat().st_size,
"size_display": _format_file_size(file_path.stat().st_size),
"modified": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
})
if model_dir_path is None:
model_dir_path = str(model_dir)
# Sort by extension for better display (prt first, then sim, fem, etc.)
extension_order = {'.prt': 0, '.sim': 1, '.fem': 2, '.bdf': 3, '.dat': 4, '.op2': 5, '.f06': 6, '.inp': 7}
model_files.sort(key=lambda x: (extension_order.get(x['extension'], 99), x['name']))
return {
"study_id": study_id,
"model_dir": model_dir_path or str(study_dir / "1_setup" / "model"),
"files": model_files,
"count": len(model_files)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get model files: {str(e)}")
def _format_file_size(size_bytes: int) -> str:
"""Format file size in human-readable form"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
@router.post("/studies/{study_id}/open-folder")
async def open_model_folder(study_id: str, folder_type: str = "model"):
"""
Open the model folder in system file explorer
Args:
study_id: Study identifier
folder_type: Type of folder to open (model, results, setup)
Returns:
JSON with success status
"""
import os
import platform
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Determine which folder to open
if folder_type == "model":
target_dir = study_dir / "1_setup" / "model"
if not target_dir.exists():
target_dir = study_dir / "1_setup"
elif folder_type == "results":
target_dir = get_results_dir(study_dir)
elif folder_type == "setup":
target_dir = study_dir / "1_setup"
else:
target_dir = study_dir
if not target_dir.exists():
target_dir = study_dir
# Open in file explorer based on platform
system = platform.system()
try:
if system == "Windows":
os.startfile(str(target_dir))
elif system == "Darwin": # macOS
subprocess.Popen(["open", str(target_dir)])
else: # Linux
subprocess.Popen(["xdg-open", str(target_dir)])
return {
"success": True,
"message": f"Opened {target_dir}",
"path": str(target_dir)
}
except Exception as e:
return {
"success": False,
"message": f"Failed to open folder: {str(e)}",
"path": str(target_dir)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {str(e)}")
@router.get("/studies/{study_id}/best-solution")
async def get_best_solution(study_id: str):
"""Get the best trial(s) for a study with improvement metrics"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study '{study_id}' not found")
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
return {
"study_id": study_id,
"best_trial": None,
"first_trial": None,
"improvements": {},
"total_trials": 0
}
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get best trial (single objective - minimize by default)
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective,
datetime(tv.value_id, 'unixepoch') as timestamp
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE'
ORDER BY tv.value ASC
LIMIT 1
""")
best_row = cursor.fetchone()
# Get first completed trial for comparison
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE'
ORDER BY t.number ASC
LIMIT 1
""")
first_row = cursor.fetchone()
# Get total trial count
cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
total_trials = cursor.fetchone()[0]
best_trial = None
first_trial = None
improvements = {}
if best_row:
best_trial_id = best_row['trial_id']
# Get design variables
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (best_trial_id,))
params = {row['param_name']: row['param_value'] for row in cursor.fetchall()}
# Get user attributes (including results)
cursor.execute("""
SELECT key, value_json
FROM trial_user_attributes
WHERE trial_id = ?
""", (best_trial_id,))
user_attrs = {}
for row in cursor.fetchall():
try:
user_attrs[row['key']] = json.loads(row['value_json'])
except:
user_attrs[row['key']] = row['value_json']
best_trial = {
"trial_number": best_row['number'],
"objective": best_row['objective'],
"design_variables": params,
"user_attrs": user_attrs,
"timestamp": best_row['timestamp']
}
if first_row:
first_trial_id = first_row['trial_id']
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (first_trial_id,))
first_params = {row['param_name']: row['param_value'] for row in cursor.fetchall()}
first_trial = {
"trial_number": first_row['number'],
"objective": first_row['objective'],
"design_variables": first_params
}
# Calculate improvement
if best_row and first_row['objective'] != 0:
improvement_pct = ((first_row['objective'] - best_row['objective']) / abs(first_row['objective'])) * 100
improvements["objective"] = {
"initial": first_row['objective'],
"final": best_row['objective'],
"improvement_pct": round(improvement_pct, 2),
"absolute_change": round(first_row['objective'] - best_row['objective'], 6)
}
conn.close()
return {
"study_id": study_id,
"best_trial": best_trial,
"first_trial": first_trial,
"improvements": improvements,
"total_trials": total_trials
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get best solution: {str(e)}")
@router.get("/studies/{study_id}/runs")
async def get_study_runs(study_id: str):
"""
Get all optimization runs/studies in the database for comparison.
Many studies have multiple Optuna studies (e.g., v11_fea, v11_iter1_nn, v11_iter2_nn).
This endpoint returns metrics for each sub-study.
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study '{study_id}' not found")
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
return {"runs": [], "total_runs": 0}
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all Optuna studies in this database
cursor.execute("""
SELECT study_id, study_name
FROM studies
ORDER BY study_id
""")
studies = cursor.fetchall()
runs = []
for study_row in studies:
optuna_study_id = study_row['study_id']
study_name = study_row['study_name']
# Get trial count
cursor.execute("""
SELECT COUNT(*) FROM trials
WHERE study_id = ? AND state = 'COMPLETE'
""", (optuna_study_id,))
trial_count = cursor.fetchone()[0]
if trial_count == 0:
continue
# Get best value (first objective)
cursor.execute("""
SELECT MIN(tv.value) as best_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.study_id = ? AND t.state = 'COMPLETE' AND tv.objective = 0
""", (optuna_study_id,))
best_result = cursor.fetchone()
best_value = best_result['best_value'] if best_result else None
# Get average value
cursor.execute("""
SELECT AVG(tv.value) as avg_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.study_id = ? AND t.state = 'COMPLETE' AND tv.objective = 0
""", (optuna_study_id,))
avg_result = cursor.fetchone()
avg_value = avg_result['avg_value'] if avg_result else None
# Get time range
cursor.execute("""
SELECT MIN(datetime_start) as first_trial, MAX(datetime_complete) as last_trial
FROM trials
WHERE study_id = ? AND state = 'COMPLETE'
""", (optuna_study_id,))
time_result = cursor.fetchone()
# Determine source type (FEA or NN)
source = "NN" if "_nn" in study_name.lower() else "FEA"
runs.append({
"run_id": optuna_study_id,
"name": study_name,
"source": source,
"trial_count": trial_count,
"best_value": best_value,
"avg_value": avg_value,
"first_trial": time_result['first_trial'] if time_result else None,
"last_trial": time_result['last_trial'] if time_result else None
})
conn.close()
return {
"runs": runs,
"total_runs": len(runs),
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get runs: {str(e)}")
class UpdateConfigRequest(BaseModel):
config: dict
@router.put("/studies/{study_id}/config")
async def update_study_config(study_id: str, request: UpdateConfigRequest):
"""
Update the optimization_config.json for a study
Args:
study_id: Study identifier
request: New configuration data
Returns:
JSON with success status
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Check if optimization is running - don't allow config changes while running
if is_optimization_running(study_id):
raise HTTPException(
status_code=409,
detail="Cannot modify config while optimization is running. Stop the optimization first."
)
# Find config file location
config_file = study_dir / "1_setup" / "optimization_config.json"
if not config_file.exists():
config_file = study_dir / "optimization_config.json"
if not config_file.exists():
raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}")
# Backup existing config
backup_file = config_file.with_suffix('.json.backup')
shutil.copy(config_file, backup_file)
# Write new config
with open(config_file, 'w') as f:
json.dump(request.config, f, indent=2)
return {
"success": True,
"message": "Configuration updated successfully",
"path": str(config_file),
"backup_path": str(backup_file)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update config: {str(e)}")
@router.get("/studies/{study_id}/export/{format}")
async def export_study_data(study_id: str, format: str):
"""Export study data in various formats: csv, json, excel"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study '{study_id}' not found")
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
if not db_path.exists():
raise HTTPException(status_code=404, detail="No study data available")
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get all completed trials with their params and values
cursor.execute("""
SELECT t.trial_id, t.number, tv.value as objective
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE'
ORDER BY t.number
""")
trials_data = []
for row in cursor.fetchall():
trial_id = row['trial_id']
# Get params
cursor.execute("""
SELECT param_name, param_value
FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {r['param_name']: r['param_value'] for r in cursor.fetchall()}
# Get user attrs
cursor.execute("""
SELECT key, value_json
FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
user_attrs = {}
for r in cursor.fetchall():
try:
user_attrs[r['key']] = json.loads(r['value_json'])
except:
user_attrs[r['key']] = r['value_json']
trials_data.append({
"trial_number": row['number'],
"objective": row['objective'],
"params": params,
"user_attrs": user_attrs
})
conn.close()
if format.lower() == "json":
return JSONResponse(content={
"study_id": study_id,
"total_trials": len(trials_data),
"trials": trials_data
})
elif format.lower() == "csv":
import io
import csv
if not trials_data:
return JSONResponse(content={"error": "No data to export"})
# Build CSV
output = io.StringIO()
# Get all param names
param_names = sorted(set(
key for trial in trials_data
for key in trial['params'].keys()
))
fieldnames = ['trial_number', 'objective'] + param_names
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for trial in trials_data:
row_data = {
'trial_number': trial['trial_number'],
'objective': trial['objective']
}
row_data.update(trial['params'])
writer.writerow(row_data)
csv_content = output.getvalue()
return JSONResponse(content={
"filename": f"{study_id}_data.csv",
"content": csv_content,
"content_type": "text/csv"
})
elif format.lower() == "config":
# Export optimization config
setup_dir = study_dir / "1_setup"
config_path = setup_dir / "optimization_config.json"
if config_path.exists():
with open(config_path, 'r') as f:
config = json.load(f)
return JSONResponse(content={
"filename": f"{study_id}_config.json",
"content": json.dumps(config, indent=2),
"content_type": "application/json"
})
else:
raise HTTPException(status_code=404, detail="Config file not found")
else:
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export data: {str(e)}")

View File

@@ -19,6 +19,82 @@ router = APIRouter()
# Store active terminal sessions
_terminal_sessions: dict = {}
# Path to Atomizer root (for loading prompts)
ATOMIZER_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)))
def get_session_prompt(study_name: str = None) -> str:
"""
Generate the initial prompt for a Claude session.
This injects the Protocol Operating System context and study-specific info.
"""
prompt_lines = [
"# Atomizer Session Context",
"",
"You are assisting with **Atomizer** - an LLM-first FEA optimization framework.",
"",
"## Bootstrap (READ FIRST)",
"",
"Read these files to understand how to help:",
"- `.claude/skills/00_BOOTSTRAP.md` - Task classification and routing",
"- `.claude/skills/01_CHEATSHEET.md` - Quick reference (I want X → Use Y)",
"- `.claude/skills/02_CONTEXT_LOADER.md` - What to load per task",
"",
"## Protocol System",
"",
"| Layer | Location | Purpose |",
"|-------|----------|---------|",
"| Operations | `docs/protocols/operations/OP_*.md` | How-to guides |",
"| System | `docs/protocols/system/SYS_*.md` | Core specs |",
"| Extensions | `docs/protocols/extensions/EXT_*.md` | Adding features |",
"",
]
if study_name:
prompt_lines.extend([
f"## Current Study: `{study_name}`",
"",
f"**Directory**: `studies/{study_name}/`",
"",
"Key files:",
f"- `studies/{study_name}/1_setup/optimization_config.json` - Configuration",
f"- `studies/{study_name}/2_results/study.db` - Optuna database",
f"- `studies/{study_name}/README.md` - Study documentation",
"",
"Quick status check:",
"```bash",
f"python -c \"import optuna; s=optuna.load_study('{study_name}', 'sqlite:///studies/{study_name}/2_results/study.db'); print(f'Trials: {{len(s.trials)}}, Best: {{s.best_value}}')\"",
"```",
"",
])
else:
prompt_lines.extend([
"## No Study Selected",
"",
"No specific study context. You can:",
"- List studies: `ls studies/`",
"- Create new study: Ask user what they want to optimize",
"- Load context: Read `.claude/skills/core/study-creation-core.md`",
"",
])
prompt_lines.extend([
"## Key Principles",
"",
"1. **Read bootstrap first** - Follow task routing from 00_BOOTSTRAP.md",
"2. **Use centralized extractors** - Check `optimization_engine/extractors/`",
"3. **Never modify master models** - Work on copies",
"4. **Python env**: Always use `conda activate atomizer`",
"",
"---",
"*Session launched from Atomizer Dashboard*",
])
return "\n".join(prompt_lines)
# Check if winpty is available (for Windows)
try:
from winpty import PtyProcess
@@ -44,9 +120,6 @@ class TerminalSession:
self.websocket = websocket
self._running = True
# Determine the claude command
claude_cmd = "claude"
try:
if self._use_winpty:
# Use winpty for proper PTY on Windows
@@ -306,14 +379,13 @@ async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_i
{"type": "output", "data": "terminal output"}
{"type": "exit", "code": 0}
{"type": "error", "message": "..."}
{"type": "context", "prompt": "..."} # Initial context prompt
"""
await websocket.accept()
# Default to Atomizer root directory
if not working_dir:
working_dir = str(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
))))
working_dir = ATOMIZER_ROOT
# Create session
session_id = f"claude-{id(websocket)}"
@@ -321,13 +393,24 @@ async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_i
_terminal_sessions[session_id] = session
try:
# Send context prompt to frontend (for display/reference)
context_prompt = get_session_prompt(study_id)
await websocket.send_json({
"type": "context",
"prompt": context_prompt,
"study_id": study_id
})
# Start Claude Code
await session.start(websocket)
# Note: Claude is started in Atomizer root directory so it has access to:
# - CLAUDE.md (system instructions)
# - .claude/skills/ (skill definitions)
# The study_id is available for the user to reference in their prompts
# If study_id provided, send initial context to Claude after startup
if study_id:
# Wait a moment for Claude to initialize
await asyncio.sleep(1.0)
# Send the context as the first message
initial_message = f"I'm working with the Atomizer study '{study_id}'. Please read .claude/skills/00_BOOTSTRAP.md first to understand the Protocol Operating System, then help me with this study.\n"
await session.write(initial_message)
# Handle incoming messages
while session._running:
@@ -370,3 +453,31 @@ async def terminal_status():
"winpty_available": HAS_WINPTY,
"message": "Claude Code CLI is available" if claude_path else "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
}
@router.get("/context")
async def get_context(study_id: str = None):
"""
Get the context prompt for a Claude session without starting a terminal.
Useful for displaying context in the UI or preparing prompts.
Query params:
study_id: Optional study ID to include study-specific context
"""
prompt = get_session_prompt(study_id)
return {
"study_id": study_id,
"prompt": prompt,
"bootstrap_files": [
".claude/skills/00_BOOTSTRAP.md",
".claude/skills/01_CHEATSHEET.md",
".claude/skills/02_CONTEXT_LOADER.md",
],
"study_files": [
f"studies/{study_id}/1_setup/optimization_config.json",
f"studies/{study_id}/2_results/study.db",
f"studies/{study_id}/README.md",
] if study_id else []
}

View File

@@ -1,10 +1,10 @@
import asyncio
import json
import sqlite3
from pathlib import Path
from typing import Dict, Set
from typing import Dict, Set, Optional, Any, List
from datetime import datetime
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import aiofiles
router = APIRouter()
@@ -12,185 +12,440 @@ router = APIRouter()
# Base studies directory
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
class OptimizationFileHandler(FileSystemEventHandler):
def get_results_dir(study_dir: Path) -> Path:
"""Get the results directory for a study, supporting both 2_results and 3_results."""
results_dir = study_dir / "2_results"
if not results_dir.exists():
results_dir = study_dir / "3_results"
return results_dir
class DatabasePoller:
"""
Polls the Optuna SQLite database for changes.
More reliable than file watching, especially on Windows.
"""
def __init__(self, study_id: str, callback):
self.study_id = study_id
self.callback = callback
self.last_trial_count = 0
self.last_pruned_count = 0
self.last_trial_id = 0
self.last_best_value: Optional[float] = None
self.last_pareto_count = 0
self.last_state_timestamp = ""
self.running = False
self._task: Optional[asyncio.Task] = None
def on_modified(self, event):
if event.src_path.endswith("optimization_history_incremental.json"):
asyncio.run(self.process_history_update(event.src_path))
elif event.src_path.endswith("pruning_history.json"):
asyncio.run(self.process_pruning_update(event.src_path))
elif event.src_path.endswith("study.db"): # Watch for Optuna DB changes (Pareto front)
asyncio.run(self.process_pareto_update(event.src_path))
elif event.src_path.endswith("optimizer_state.json"):
asyncio.run(self.process_state_update(event.src_path))
async def start(self):
"""Start the polling loop"""
self.running = True
self._task = asyncio.create_task(self._poll_loop())
async def process_history_update(self, file_path):
async def stop(self):
"""Stop the polling loop"""
self.running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _poll_loop(self):
"""Main polling loop - checks database every 2 seconds"""
study_dir = STUDIES_DIR / self.study_id
results_dir = get_results_dir(study_dir)
db_path = results_dir / "study.db"
while self.running:
try:
if db_path.exists():
await self._check_database(db_path)
await asyncio.sleep(2) # Poll every 2 seconds
except asyncio.CancelledError:
break
except Exception as e:
print(f"[WebSocket] Polling error for {self.study_id}: {e}")
await asyncio.sleep(5) # Back off on error
async def _check_database(self, db_path: Path):
"""Check database for new trials and updates"""
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
history = json.loads(content)
current_count = len(history)
if current_count > self.last_trial_count:
# New trials added
new_trials = history[self.last_trial_count:]
for trial in new_trials:
await self.callback({
"type": "trial_completed",
"data": trial
})
self.last_trial_count = current_count
# Use timeout to avoid blocking on locked databases
conn = sqlite3.connect(str(db_path), timeout=2.0)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Get total completed trial count
cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
total_count = cursor.fetchone()[0]
# Check for new trials
if total_count > self.last_trial_count:
await self._process_new_trials(cursor, total_count)
# Check for new best value
await self._check_best_value(cursor)
# Check Pareto front for multi-objective
await self._check_pareto_front(cursor, db_path)
# Send progress update
await self._send_progress(cursor, total_count)
conn.close()
except sqlite3.OperationalError as e:
# Database locked - skip this poll
pass
except Exception as e:
print(f"Error processing history update: {e}")
print(f"[WebSocket] Database check error: {e}")
async def process_pruning_update(self, file_path):
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
history = json.loads(content)
current_count = len(history)
if current_count > self.last_pruned_count:
# New pruned trials
new_pruned = history[self.last_pruned_count:]
for trial in new_pruned:
await self.callback({
"type": "trial_pruned",
"data": trial
})
self.last_pruned_count = current_count
except Exception as e:
print(f"Error processing pruning update: {e}")
async def _process_new_trials(self, cursor, total_count: int):
"""Process and broadcast new trials"""
# Get new trials since last check
cursor.execute("""
SELECT t.trial_id, t.number, t.datetime_start, t.datetime_complete, s.study_name
FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE' AND t.trial_id > ?
ORDER BY t.trial_id ASC
""", (self.last_trial_id,))
new_trials = cursor.fetchall()
for row in new_trials:
trial_id = row['trial_id']
trial_data = await self._build_trial_data(cursor, row)
await self.callback({
"type": "trial_completed",
"data": trial_data
})
self.last_trial_id = trial_id
self.last_trial_count = total_count
async def _build_trial_data(self, cursor, row) -> Dict[str, Any]:
"""Build trial data dictionary from database row"""
trial_id = row['trial_id']
# Get objectives
cursor.execute("""
SELECT value FROM trial_values
WHERE trial_id = ? ORDER BY objective
""", (trial_id,))
values = [r[0] for r in cursor.fetchall()]
# Get parameters
cursor.execute("""
SELECT param_name, param_value FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {}
for r in cursor.fetchall():
try:
params[r[0]] = float(r[1]) if r[1] is not None else None
except (ValueError, TypeError):
params[r[0]] = r[1]
# Get user attributes
cursor.execute("""
SELECT key, value_json FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
user_attrs = {}
for r in cursor.fetchall():
try:
user_attrs[r[0]] = json.loads(r[1])
except (ValueError, TypeError):
user_attrs[r[0]] = r[1]
# Extract source and design vars
source = user_attrs.get("source", "FEA")
design_vars = user_attrs.get("design_vars", params)
return {
"trial_number": trial_id, # Use trial_id for uniqueness
"trial_num": row['number'],
"objective": values[0] if values else None,
"values": values,
"params": design_vars,
"user_attrs": user_attrs,
"source": source,
"start_time": row['datetime_start'],
"end_time": row['datetime_complete'],
"study_name": row['study_name'],
"constraint_satisfied": user_attrs.get("constraint_satisfied", True)
}
async def _check_best_value(self, cursor):
"""Check for new best value and broadcast if changed"""
cursor.execute("""
SELECT MIN(tv.value) as best_value
FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE' AND tv.objective = 0
""")
result = cursor.fetchone()
if result and result['best_value'] is not None:
best_value = result['best_value']
if self.last_best_value is None or best_value < self.last_best_value:
# Get the best trial details
cursor.execute("""
SELECT t.trial_id, t.number
FROM trials t
JOIN trial_values tv ON t.trial_id = tv.trial_id
WHERE t.state = 'COMPLETE' AND tv.objective = 0 AND tv.value = ?
LIMIT 1
""", (best_value,))
best_row = cursor.fetchone()
if best_row:
# Get params for best trial
cursor.execute("""
SELECT param_name, param_value FROM trial_params
WHERE trial_id = ?
""", (best_row['trial_id'],))
params = {r[0]: r[1] for r in cursor.fetchall()}
async def process_pareto_update(self, file_path):
# This is tricky because study.db is binary.
# Instead of reading it directly, we'll trigger a re-fetch of the Pareto front via Optuna
# We debounce this to avoid excessive reads
try:
# Import here to avoid circular imports or heavy load at startup
import optuna
# Connect to DB
storage = optuna.storages.RDBStorage(f"sqlite:///{file_path}")
study = optuna.load_study(study_name=self.study_id, storage=storage)
# Check if multi-objective
if len(study.directions) > 1:
pareto_trials = study.best_trials
# Only broadcast if count changed (simple heuristic)
# In a real app, we might check content hash
if len(pareto_trials) != self.last_pareto_count:
pareto_data = [
{
"trial_number": t.number,
"values": t.values,
"params": t.params,
"user_attrs": dict(t.user_attrs),
"constraint_satisfied": t.user_attrs.get("constraint_satisfied", True)
}
for t in pareto_trials
]
await self.callback({
"type": "pareto_front",
"type": "new_best",
"data": {
"pareto_front": pareto_data,
"count": len(pareto_trials)
"trial_number": best_row['trial_id'],
"value": best_value,
"params": params,
"improvement": (
((self.last_best_value - best_value) / abs(self.last_best_value) * 100)
if self.last_best_value else 0
)
}
})
self.last_pareto_count = len(pareto_trials)
self.last_best_value = best_value
async def _check_pareto_front(self, cursor, db_path: Path):
"""Check for Pareto front updates in multi-objective studies"""
try:
# Check if multi-objective by counting distinct objectives
cursor.execute("""
SELECT COUNT(DISTINCT objective) as obj_count
FROM trial_values
WHERE trial_id IN (SELECT trial_id FROM trials WHERE state = 'COMPLETE')
""")
result = cursor.fetchone()
if result and result['obj_count'] > 1:
# Multi-objective - compute Pareto front
import optuna
storage = optuna.storages.RDBStorage(f"sqlite:///{db_path}")
# Get all study names
cursor.execute("SELECT study_name FROM studies")
study_names = [r[0] for r in cursor.fetchall()]
for study_name in study_names:
try:
study = optuna.load_study(study_name=study_name, storage=storage)
if len(study.directions) > 1:
pareto_trials = study.best_trials
if len(pareto_trials) != self.last_pareto_count:
pareto_data = [
{
"trial_number": t.number,
"values": t.values,
"params": t.params,
"constraint_satisfied": t.user_attrs.get("constraint_satisfied", True),
"source": t.user_attrs.get("source", "FEA")
}
for t in pareto_trials
]
await self.callback({
"type": "pareto_update",
"data": {
"pareto_front": pareto_data,
"count": len(pareto_trials)
}
})
self.last_pareto_count = len(pareto_trials)
break
except:
continue
except Exception as e:
# DB might be locked, ignore transient errors
# Non-critical - skip Pareto check
pass
async def process_state_update(self, file_path):
try:
async with aiofiles.open(file_path, mode='r') as f:
content = await f.read()
state = json.loads(content)
# Check timestamp to avoid duplicate broadcasts
if state.get("timestamp") != self.last_state_timestamp:
await self.callback({
"type": "optimizer_state",
"data": state
})
self.last_state_timestamp = state.get("timestamp")
except Exception as e:
print(f"Error processing state update: {e}")
async def _send_progress(self, cursor, total_count: int):
"""Send progress update"""
# Get total from config if available
study_dir = STUDIES_DIR / self.study_id
config_path = study_dir / "1_setup" / "optimization_config.json"
if not config_path.exists():
config_path = study_dir / "optimization_config.json"
total_target = 100 # Default
if config_path.exists():
try:
with open(config_path) as f:
config = json.load(f)
total_target = config.get('optimization_settings', {}).get('n_trials', 100)
except:
pass
# Count FEA vs NN trials
cursor.execute("""
SELECT
COUNT(CASE WHEN s.study_name LIKE '%_nn%' THEN 1 END) as nn_count,
COUNT(CASE WHEN s.study_name NOT LIKE '%_nn%' THEN 1 END) as fea_count
FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE'
""")
counts = cursor.fetchone()
await self.callback({
"type": "progress",
"data": {
"current": total_count,
"total": total_target,
"percentage": min(100, (total_count / total_target * 100)) if total_target > 0 else 0,
"fea_count": counts['fea_count'] if counts else total_count,
"nn_count": counts['nn_count'] if counts else 0,
"timestamp": datetime.now().isoformat()
}
})
class ConnectionManager:
"""
Manages WebSocket connections and database pollers for real-time updates.
Uses database polling instead of file watching for better reliability on Windows.
"""
def __init__(self):
self.active_connections: Dict[str, Set[WebSocket]] = {}
self.observers: Dict[str, Observer] = {}
self.pollers: Dict[str, DatabasePoller] = {}
async def connect(self, websocket: WebSocket, study_id: str):
"""Connect a new WebSocket client"""
await websocket.accept()
if study_id not in self.active_connections:
self.active_connections[study_id] = set()
self.start_watching(study_id)
self.active_connections[study_id].add(websocket)
def disconnect(self, websocket: WebSocket, study_id: str):
# Start polling if not already running
if study_id not in self.pollers:
await self._start_polling(study_id)
async def disconnect(self, websocket: WebSocket, study_id: str):
"""Disconnect a WebSocket client"""
if study_id in self.active_connections:
self.active_connections[study_id].remove(websocket)
self.active_connections[study_id].discard(websocket)
# Stop polling if no more connections
if not self.active_connections[study_id]:
del self.active_connections[study_id]
self.stop_watching(study_id)
await self._stop_polling(study_id)
async def broadcast(self, message: dict, study_id: str):
if study_id in self.active_connections:
for connection in self.active_connections[study_id]:
try:
await connection.send_json(message)
except Exception as e:
print(f"Error broadcasting to client: {e}")
def start_watching(self, study_id: str):
study_dir = STUDIES_DIR / study_id / "2_results"
if not study_dir.exists():
"""Broadcast message to all connected clients for a study"""
if study_id not in self.active_connections:
return
disconnected = []
for connection in self.active_connections[study_id]:
try:
await connection.send_json(message)
except Exception as e:
print(f"[WebSocket] Error broadcasting to client: {e}")
disconnected.append(connection)
# Clean up disconnected clients
for conn in disconnected:
self.active_connections[study_id].discard(conn)
async def _start_polling(self, study_id: str):
"""Start database polling for a study"""
async def callback(message):
await self.broadcast(message, study_id)
event_handler = OptimizationFileHandler(study_id, callback)
observer = Observer()
observer.schedule(event_handler, str(study_dir), recursive=True)
observer.start()
self.observers[study_id] = observer
poller = DatabasePoller(study_id, callback)
self.pollers[study_id] = poller
await poller.start()
print(f"[WebSocket] Started polling for study: {study_id}")
async def _stop_polling(self, study_id: str):
"""Stop database polling for a study"""
if study_id in self.pollers:
await self.pollers[study_id].stop()
del self.pollers[study_id]
print(f"[WebSocket] Stopped polling for study: {study_id}")
def stop_watching(self, study_id: str):
if study_id in self.observers:
self.observers[study_id].stop()
self.observers[study_id].join()
del self.observers[study_id]
manager = ConnectionManager()
@router.websocket("/optimization/{study_id}")
async def optimization_stream(websocket: WebSocket, study_id: str):
"""
WebSocket endpoint for real-time optimization updates.
Sends messages:
- connected: Initial connection confirmation
- trial_completed: New trial completed with full data
- new_best: New best value found
- progress: Progress update (current/total, FEA/NN counts)
- pareto_update: Pareto front updated (multi-objective)
"""
await manager.connect(websocket, study_id)
try:
# Send initial connection message
await websocket.send_json({
"type": "connected",
"data": {"message": f"Connected to stream for study {study_id}"}
"data": {
"study_id": study_id,
"message": f"Connected to real-time stream for study {study_id}",
"timestamp": datetime.now().isoformat()
}
})
# Keep connection alive
while True:
# Keep connection alive and handle incoming messages if needed
data = await websocket.receive_text()
# We could handle client commands here (e.g., "pause", "stop")
try:
# Wait for messages (ping/pong or commands)
data = await asyncio.wait_for(
websocket.receive_text(),
timeout=30.0 # 30 second timeout for ping
)
# Handle client commands
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except json.JSONDecodeError:
pass
except asyncio.TimeoutError:
# Send heartbeat
try:
await websocket.send_json({"type": "heartbeat"})
except:
break
except WebSocketDisconnect:
manager.disconnect(websocket, study_id)
pass
except Exception as e:
print(f"WebSocket error: {e}")
manager.disconnect(websocket, study_id)
print(f"[WebSocket] Connection error for {study_id}: {e}")
finally:
await manager.disconnect(websocket, study_id)

View File

@@ -1,9 +1,13 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StudyProvider } from './context/StudyContext';
import { ClaudeTerminalProvider } from './context/ClaudeTerminalContext';
import { MainLayout } from './components/layout/MainLayout';
import { GlobalClaudeTerminal } from './components/GlobalClaudeTerminal';
import Home from './pages/Home';
import Setup from './pages/Setup';
import Dashboard from './pages/Dashboard';
import Analysis from './pages/Analysis';
import Results from './pages/Results';
const queryClient = new QueryClient({
@@ -19,19 +23,25 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<StudyProvider>
<BrowserRouter>
<Routes>
{/* Home page - no sidebar layout */}
<Route path="/" element={<Home />} />
<ClaudeTerminalProvider>
<BrowserRouter>
<Routes>
{/* Home page - no sidebar layout */}
<Route path="/" element={<Home />} />
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="results" element={<Results />} />
<Route path="analytics" element={<Dashboard />} />
</Route>
</Routes>
</BrowserRouter>
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>
<Route path="setup" element={<Setup />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analysis" element={<Analysis />} />
<Route path="results" element={<Results />} />
</Route>
</Routes>
{/* Global Claude Terminal - persists across navigation */}
<GlobalClaudeTerminal />
</BrowserRouter>
</ClaudeTerminalProvider>
</StudyProvider>
</QueryClientProvider>
);

View File

@@ -52,6 +52,22 @@ export interface ProcessStatus {
nn_count?: number;
}
export interface ModelFile {
name: string;
path: string;
extension: string;
size_bytes: number;
size_display: string;
modified: string;
}
export interface ModelFilesResponse {
study_id: string;
model_dir: string;
files: ModelFile[];
count: number;
}
class ApiClient {
async getStudies(): Promise<StudyListResponse> {
const response = await fetch(`${API_BASE}/optimization/studies`);
@@ -193,6 +209,64 @@ class ApiClient {
}
return response.json();
}
// Model files
async getModelFiles(studyId: string): Promise<ModelFilesResponse> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/model-files`);
if (!response.ok) throw new Error('Failed to fetch model files');
return response.json();
}
async openFolder(studyId: string, folderType: 'model' | 'results' | 'setup' = 'model'): Promise<{ success: boolean; message: string; path: string }> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/open-folder?folder_type=${folderType}`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to open folder');
}
return response.json();
}
async getBestSolution(studyId: string): Promise<{
study_id: string;
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/best-solution`);
if (!response.ok) throw new Error('Failed to fetch best solution');
return response.json();
}
async exportData(studyId: string, format: 'csv' | 'json' | 'config'): Promise<{
filename?: string;
content: string;
content_type?: string;
study_id?: string;
total_trials?: number;
trials?: any[];
}> {
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/export/${format}`);
if (!response.ok) throw new Error(`Failed to export ${format}`);
return response.json();
}
}
export const apiClient = new ApiClient();

View File

@@ -9,9 +9,12 @@ import {
Minimize2,
X,
RefreshCw,
AlertCircle
AlertCircle,
FolderOpen,
Plus
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
interface ClaudeTerminalProps {
isExpanded?: boolean;
@@ -25,14 +28,23 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
onClose
}) => {
const { selectedStudy } = useStudy();
const { setIsConnected: setGlobalConnected } = useClaudeTerminal();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnected, setIsConnectedLocal] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [_error, setError] = useState<string | null>(null);
const [cliAvailable, setCliAvailable] = useState<boolean | null>(null);
const [contextSet, setContextSet] = useState(false);
const [settingContext, setSettingContext] = useState(false);
// Sync local connection state to global context
const setIsConnected = useCallback((connected: boolean) => {
setIsConnectedLocal(connected);
setGlobalConnected(connected);
}, [setGlobalConnected]);
// Check CLI availability
useEffect(() => {
@@ -165,8 +177,7 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
xtermRef.current?.clear();
xtermRef.current?.writeln('\x1b[1;32mConnected to Claude Code\x1b[0m');
if (selectedStudy?.id) {
xtermRef.current?.writeln(`\x1b[90mStudy context: \x1b[1;33m${selectedStudy.id}\x1b[0m`);
xtermRef.current?.writeln('\x1b[90mTip: Tell Claude about your study, e.g. "Help me with study ' + selectedStudy.id + '"\x1b[0m');
xtermRef.current?.writeln(`\x1b[90mStudy: \x1b[1;33m${selectedStudy.id}\x1b[0m \x1b[90m- Click "Set Context" to initialize\x1b[0m`);
}
xtermRef.current?.writeln('');
@@ -241,8 +252,43 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
wsRef.current = null;
}
setIsConnected(false);
setContextSet(false);
}, []);
// Set study context - sends context message to Claude silently
const setStudyContext = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setSettingContext(true);
let contextMessage: string;
if (selectedStudy?.id) {
// Existing study context
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
} else {
// No study selected - offer to create new study
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`No study is currently selected. ` +
`Read .claude/skills/guided-study-wizard.md and help the user create a new optimization study. ` +
`Use atomizer conda env. Start the guided wizard by asking what they want to optimize.`;
}
wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' }));
// Mark as done after Claude has had time to process
setTimeout(() => {
setSettingContext(false);
setContextSet(true);
}, 500);
}, [selectedStudy]);
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -293,6 +339,39 @@ export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{/* Set Context button - works for both existing study and new study creation */}
<button
onClick={setStudyContext}
disabled={!isConnected || settingContext || contextSet}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
contextSet
? 'bg-green-600/20 text-green-400'
: !isConnected
? 'bg-dark-600 text-dark-400'
: selectedStudy?.id
? 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: 'bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={
!isConnected
? 'Connect first to set context'
: contextSet
? 'Context already set'
: selectedStudy?.id
? `Set context to study: ${selectedStudy.id}`
: 'Start guided study creation wizard'
}
>
{settingContext ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : selectedStudy?.id ? (
<FolderOpen className="w-3 h-3" />
) : (
<Plus className="w-3 h-3" />
)}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'New Study'}
</button>
{onToggleExpand && (
<button
onClick={onToggleExpand}

View File

@@ -0,0 +1,458 @@
import { useState, useEffect, useCallback } from 'react';
import { Settings, Save, X, AlertTriangle, Check, RotateCcw } from 'lucide-react';
import { Card } from './common/Card';
interface DesignVariable {
name: string;
min: number;
max: number;
type?: string;
description?: string;
}
interface Objective {
name: string;
direction: 'minimize' | 'maximize';
description?: string;
unit?: string;
}
interface OptimizationConfig {
study_name?: string;
description?: string;
design_variables?: DesignVariable[];
objectives?: Objective[];
constraints?: any[];
optimization_settings?: {
n_trials?: number;
sampler?: string;
[key: string]: any;
};
[key: string]: any;
}
interface ConfigEditorProps {
studyId: string;
onClose: () => void;
onSaved?: () => void;
}
export function ConfigEditor({ studyId, onClose, onSaved }: ConfigEditorProps) {
const [config, setConfig] = useState<OptimizationConfig | null>(null);
const [originalConfig, setOriginalConfig] = useState<OptimizationConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'variables' | 'objectives' | 'settings' | 'json'>('general');
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
// Load config
useEffect(() => {
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
// Check if optimization is running
const processRes = await fetch(`/api/optimization/studies/${studyId}/process`);
const processData = await processRes.json();
setIsRunning(processData.is_running);
// Load config
const configRes = await fetch(`/api/optimization/studies/${studyId}/config`);
if (!configRes.ok) {
throw new Error('Failed to load config');
}
const configData = await configRes.json();
setConfig(configData.config);
setOriginalConfig(JSON.parse(JSON.stringify(configData.config)));
setJsonText(JSON.stringify(configData.config, null, 2));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
};
loadConfig();
}, [studyId]);
// Handle JSON text changes
const handleJsonChange = useCallback((text: string) => {
setJsonText(text);
setJsonError(null);
try {
const parsed = JSON.parse(text);
setConfig(parsed);
} catch (err) {
setJsonError('Invalid JSON');
}
}, []);
// Save config
const handleSave = async () => {
if (!config || isRunning) return;
try {
setSaving(true);
setError(null);
setSuccess(null);
const res = await fetch(`/api/optimization/studies/${studyId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Failed to save config');
}
setSuccess('Configuration saved successfully');
setOriginalConfig(JSON.parse(JSON.stringify(config)));
onSaved?.();
// Clear success after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save config');
} finally {
setSaving(false);
}
};
// Reset to original
const handleReset = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)));
setJsonText(JSON.stringify(originalConfig, null, 2));
setJsonError(null);
}
};
// Check if there are unsaved changes
const hasChanges = config && originalConfig
? JSON.stringify(config) !== JSON.stringify(originalConfig)
: false;
// Update a design variable
const updateDesignVariable = (index: number, field: keyof DesignVariable, value: any) => {
if (!config?.design_variables) return;
const newVars = [...config.design_variables];
newVars[index] = { ...newVars[index], [field]: value };
setConfig({ ...config, design_variables: newVars });
setJsonText(JSON.stringify({ ...config, design_variables: newVars }, null, 2));
};
// Update an objective
const updateObjective = (index: number, field: keyof Objective, value: any) => {
if (!config?.objectives) return;
const newObjs = [...config.objectives];
newObjs[index] = { ...newObjs[index], [field]: value };
setConfig({ ...config, objectives: newObjs });
setJsonText(JSON.stringify({ ...config, objectives: newObjs }, null, 2));
};
// Update optimization settings
const updateSettings = (field: string, value: any) => {
if (!config) return;
const newSettings = { ...config.optimization_settings, [field]: value };
setConfig({ ...config, optimization_settings: newSettings });
setJsonText(JSON.stringify({ ...config, optimization_settings: newSettings }, null, 2));
};
if (loading) {
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<Card className="w-full max-w-4xl max-h-[90vh] p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full"></div>
</div>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-600">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Edit Configuration</h2>
{hasChanges && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">
Unsaved changes
</span>
)}
</div>
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Warning if running */}
{isRunning && (
<div className="m-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">
Optimization is running. Stop it before editing configuration.
</span>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-dark-600 px-4">
{(['general', 'variables', 'objectives', 'settings', 'json'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: 'text-dark-400 hover:text-white'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400 text-sm flex items-center gap-2">
<Check className="w-4 h-4" />
{success}
</div>
)}
{config && (
<>
{/* General Tab */}
{activeTab === 'general' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Study Name
</label>
<input
type="text"
value={config.study_name || ''}
onChange={(e) => {
setConfig({ ...config, study_name: e.target.value });
setJsonText(JSON.stringify({ ...config, study_name: e.target.value }, null, 2));
}}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Description
</label>
<textarea
value={config.description || ''}
onChange={(e) => {
setConfig({ ...config, description: e.target.value });
setJsonText(JSON.stringify({ ...config, description: e.target.value }, null, 2));
}}
disabled={isRunning}
rows={3}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
)}
{/* Design Variables Tab */}
{activeTab === 'variables' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Edit design variable bounds. These control the parameter search space.
</p>
{config.design_variables?.map((dv, index) => (
<div key={dv.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{dv.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Min</label>
<input
type="number"
value={dv.min}
onChange={(e) => updateDesignVariable(index, 'min', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Max</label>
<input
type="number"
value={dv.max}
onChange={(e) => updateDesignVariable(index, 'max', parseFloat(e.target.value))}
disabled={isRunning}
step="any"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Objectives Tab */}
{activeTab === 'objectives' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Configure optimization objectives and their directions.
</p>
{config.objectives?.map((obj, index) => (
<div key={obj.name} className="p-4 bg-dark-750 rounded-lg">
<div className="font-medium text-white mb-3">{obj.name}</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-dark-400 mb-1">Direction</label>
<select
value={obj.direction}
onChange={(e) => updateObjective(index, 'direction', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Unit</label>
<input
type="text"
value={obj.unit || ''}
onChange={(e) => updateObjective(index, 'unit', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="space-y-4">
<p className="text-dark-400 text-sm mb-4">
Optimization algorithm settings.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Number of Trials
</label>
<input
type="number"
value={config.optimization_settings?.n_trials || 100}
onChange={(e) => updateSettings('n_trials', parseInt(e.target.value))}
disabled={isRunning}
min={1}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
Sampler
</label>
<select
value={config.optimization_settings?.sampler || 'TPE'}
onChange={(e) => updateSettings('sampler', e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white disabled:opacity-50"
>
<option value="TPE">TPE (Tree-structured Parzen Estimator)</option>
<option value="CMA-ES">CMA-ES (Evolution Strategy)</option>
<option value="NSGA-II">NSGA-II (Multi-objective)</option>
<option value="Random">Random</option>
<option value="QMC">QMC (Quasi-Monte Carlo)</option>
</select>
</div>
</div>
</div>
)}
{/* JSON Tab */}
{activeTab === 'json' && (
<div className="space-y-2">
<p className="text-dark-400 text-sm">
Edit the raw JSON configuration. Be careful with syntax.
</p>
{jsonError && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-xs">
{jsonError}
</div>
)}
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
disabled={isRunning}
spellCheck={false}
className="w-full h-96 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white font-mono text-sm disabled:opacity-50"
/>
</div>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-600">
<button
onClick={handleReset}
disabled={!hasChanges || isRunning}
className="flex items-center gap-2 px-4 py-2 text-dark-400 hover:text-white disabled:opacity-50 transition-colors"
>
<RotateCcw className="w-4 h-4" />
Reset
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || !hasChanges || isRunning || !!jsonError}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</Card>
</div>
);
}
export default ConfigEditor;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
import { ClaudeTerminal } from './ClaudeTerminal';
import { Terminal } from 'lucide-react';
/**
* GlobalClaudeTerminal - A floating terminal that persists across page navigation
*
* This component renders at the App level and maintains the Claude Code session
* even when the user navigates between pages. It can be minimized to a floating
* button or expanded to a side panel.
*/
export const GlobalClaudeTerminal: React.FC = () => {
const { isOpen, setIsOpen, isExpanded, setIsExpanded, isConnected } = useClaudeTerminal();
// Floating button when terminal is closed
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className={`fixed bottom-6 right-6 p-4 rounded-full shadow-lg transition-all z-50 ${
isConnected
? 'bg-green-600 hover:bg-green-500'
: 'bg-primary-600 hover:bg-primary-500'
}`}
title={isConnected ? 'Claude Terminal (Connected)' : 'Open Claude Terminal'}
>
<Terminal className="w-6 h-6 text-white" />
{isConnected && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-dark-900 animate-pulse" />
)}
</button>
);
}
// Terminal panel
return (
<div className={`fixed z-50 transition-all duration-200 ${
isExpanded
? 'inset-4'
: 'bottom-6 right-6 w-[650px] h-[500px]'
}`}>
<ClaudeTerminal
isExpanded={isExpanded}
onToggleExpand={() => setIsExpanded(!isExpanded)}
onClose={() => setIsOpen(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownRendererProps {
content: string;
className?: string;
}
/**
* Shared markdown renderer with syntax highlighting, GFM, and LaTeX support.
* Used by both the Home page (README display) and Results page (reports).
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
return (
<article className={`markdown-body max-w-none ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{content}
</ReactMarkdown>
</article>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,107 @@
import { Bell, BellOff, BellRing } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
interface NotificationSettingsProps {
compact?: boolean;
}
export function NotificationSettings({ compact = false }: NotificationSettingsProps) {
const { permission, requestPermission, isEnabled, setEnabled } = useNotifications();
const handleToggle = async () => {
if (permission === 'unsupported') {
return;
}
if (!isEnabled) {
// Enabling - request permission if needed
if (permission !== 'granted') {
const granted = await requestPermission();
if (granted) {
setEnabled(true);
}
} else {
setEnabled(true);
}
} else {
// Disabling
setEnabled(false);
}
};
if (permission === 'unsupported') {
return null;
}
const getIcon = () => {
if (!isEnabled) return <BellOff className="w-4 h-4" />;
if (permission === 'denied') return <BellOff className="w-4 h-4 text-red-400" />;
return <BellRing className="w-4 h-4" />;
};
const getStatus = () => {
if (permission === 'denied') return 'Blocked';
if (!isEnabled) return 'Off';
return 'On';
};
if (compact) {
return (
<button
onClick={handleToggle}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400 hover:bg-primary-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={`Desktop notifications: ${getStatus()}`}
>
{getIcon()}
<span className="hidden sm:inline">{getStatus()}</span>
</button>
);
}
return (
<div className="flex items-center justify-between p-3 bg-dark-750 rounded-lg">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
isEnabled && permission === 'granted'
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-700 text-dark-400'
}`}>
<Bell className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-medium text-white">Desktop Notifications</div>
<div className="text-xs text-dark-400">
{permission === 'denied'
? 'Blocked by browser - enable in browser settings'
: isEnabled
? 'Get notified when new best solutions are found'
: 'Enable to receive optimization updates'}
</div>
</div>
</div>
<button
onClick={handleToggle}
disabled={permission === 'denied'}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isEnabled && permission === 'granted'
? 'bg-primary-500'
: permission === 'denied'
? 'bg-dark-600 cursor-not-allowed'
: 'bg-dark-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isEnabled && permission === 'granted' ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default NotificationSettings;

View File

@@ -14,9 +14,10 @@ import { useStudy } from '../../context/StudyContext';
interface ControlPanelProps {
onStatusChange?: () => void;
horizontal?: boolean;
}
export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange }) => {
export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, horizontal = false }) => {
const { selectedStudy, refreshStudies } = useStudy();
const [processStatus, setProcessStatus] = useState<ProcessStatus | null>(null);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
@@ -131,6 +132,177 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange }) =>
const isRunning = processStatus?.is_running || selectedStudy?.status === 'running';
// Horizontal layout for top of page
if (horizontal) {
return (
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
<div className="px-4 py-3 flex items-center gap-6">
{/* Status */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{isRunning ? (
<>
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse" />
<span className="text-green-400 font-medium text-sm">Running</span>
</>
) : (
<>
<div className="w-3 h-3 bg-dark-500 rounded-full" />
<span className="text-dark-400 text-sm">Stopped</span>
</>
)}
</div>
{processStatus?.fea_count && (
<span className="text-xs text-dark-400">
FEA: <span className="text-primary-400">{processStatus.fea_count}</span>
{processStatus.nn_count && (
<> | NN: <span className="text-orange-400">{processStatus.nn_count}</span></>
)}
</span>
)}
</div>
{/* Main Actions */}
<div className="flex items-center gap-2">
{isRunning ? (
<button
onClick={handleStop}
disabled={actionInProgress !== null}
className="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{actionInProgress === 'stop' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
Kill
</button>
) : (
<button
onClick={handleStart}
disabled={actionInProgress !== null}
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 hover:bg-green-500
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{actionInProgress === 'start' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Start
</button>
)}
<button
onClick={handleValidate}
disabled={actionInProgress !== null || isRunning}
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{actionInProgress === 'validate' ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
Validate
</button>
<button
onClick={handleLaunchOptuna}
disabled={actionInProgress !== null}
className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
border border-dark-600 disabled:opacity-50 text-dark-300 hover:text-white rounded-lg text-sm"
>
<ExternalLink className="w-4 h-4" />
Optuna
</button>
</div>
{/* Settings Toggle */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-1.5 rounded-lg transition-colors ${
showSettings ? 'bg-primary-600 text-white' : 'bg-dark-700 text-dark-300 hover:text-white'
}`}
>
<Settings className="w-4 h-4" />
</button>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4" />
{error}
</div>
)}
</div>
{/* Collapsible Settings */}
{showSettings && (
<div className="px-4 py-3 border-t border-dark-700 bg-dark-750">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">Iterations:</label>
<input
type="number"
value={settings.maxIterations}
onChange={(e) => setSettings({ ...settings, maxIterations: parseInt(e.target.value) || 100 })}
className="w-16 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">FEA Batch:</label>
<input
type="number"
value={settings.feaBatchSize}
onChange={(e) => setSettings({ ...settings, feaBatchSize: parseInt(e.target.value) || 5 })}
className="w-12 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">Patience:</label>
<input
type="number"
value={settings.patience}
onChange={(e) => setSettings({ ...settings, patience: parseInt(e.target.value) || 5 })}
className="w-12 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">Tune:</label>
<input
type="number"
value={settings.tuneTrials}
onChange={(e) => setSettings({ ...settings, tuneTrials: parseInt(e.target.value) || 30 })}
className="w-12 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">Ensemble:</label>
<input
type="number"
value={settings.ensembleSize}
onChange={(e) => setSettings({ ...settings, ensembleSize: parseInt(e.target.value) || 3 })}
className="w-12 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400">Validate Top:</label>
<input
type="number"
min={1}
max={20}
value={validateTopN}
onChange={(e) => setValidateTopN(parseInt(e.target.value) || 5)}
className="w-12 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.freshStart}
onChange={(e) => setSettings({ ...settings, freshStart: e.target.checked })}
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-primary-600"
/>
<span className="text-xs text-dark-300">Fresh Start</span>
</label>
</div>
</div>
)}
</div>
);
}
// Vertical layout (original sidebar layout)
return (
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
{/* Header */}

View File

@@ -1,20 +1,25 @@
import { NavLink, useNavigate } from 'react-router-dom';
import {
Home,
Settings,
Activity,
FileText,
BarChart3,
TrendingUp,
ChevronLeft,
Play,
Pause,
CheckCircle,
Clock,
Zap
Zap,
Terminal
} from 'lucide-react';
import clsx from 'clsx';
import { useStudy } from '../../context/StudyContext';
import { useClaudeTerminal } from '../../context/ClaudeTerminalContext';
export const Sidebar = () => {
const { selectedStudy, clearStudy } = useStudy();
const { isConnected: claudeConnected, setIsOpen: setClaudeTerminalOpen } = useClaudeTerminal();
const navigate = useNavigate();
const handleBackToHome = () => {
@@ -26,8 +31,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'paused':
return <Pause className="w-3 h-3 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3 h-3 text-blue-400" />;
case 'not_started':
return <Clock className="w-3 h-3 text-dark-400" />;
default:
return <Clock className="w-3 h-3 text-dark-400" />;
}
@@ -37,8 +46,12 @@ export const Sidebar = () => {
switch (status) {
case 'running':
return 'text-green-400';
case 'paused':
return 'text-orange-400';
case 'completed':
return 'text-blue-400';
case 'not_started':
return 'text-dark-400';
default:
return 'text-dark-400';
}
@@ -47,9 +60,10 @@ export const Sidebar = () => {
// Navigation items depend on whether a study is selected
const navItems = selectedStudy
? [
{ to: '/setup', icon: Settings, label: 'Setup' },
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
{ to: '/results', icon: FileText, label: 'Reports' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/analysis', icon: TrendingUp, label: 'Analysis' },
{ to: '/results', icon: FileText, label: 'Results' },
]
: [
{ to: '/', icon: Home, label: 'Select Study' },
@@ -133,6 +147,23 @@ export const Sidebar = () => {
Optimization Running
</div>
)}
{selectedStudy && selectedStudy.status === 'paused' && (
<div className="flex items-center gap-2 text-sm text-orange-400 mt-1">
<div className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization Paused
</div>
)}
{/* Claude Terminal Status */}
<button
onClick={() => setClaudeTerminalOpen(true)}
className={clsx(
'flex items-center gap-2 text-sm mt-1 w-full text-left hover:opacity-80 transition-opacity',
claudeConnected ? 'text-green-400' : 'text-dark-400'
)}
>
<Terminal className="w-3 h-3" />
{claudeConnected ? 'Claude Connected' : 'Claude Disconnected'}
</button>
</div>
</div>
</aside>

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface PlotlyCorrelationHeatmapProps {
trials: TrialData[];
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0 || n !== y.length) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
export function PlotlyCorrelationHeatmap({
trials,
objectiveName = 'Objective',
height = 500
}: PlotlyCorrelationHeatmapProps) {
const { matrix, labels, annotations } = useMemo(() => {
if (trials.length < 3) {
return { matrix: [], labels: [], annotations: [] };
}
// Get parameter names
const paramNames = Object.keys(trials[0].params);
const allLabels = [...paramNames, objectiveName];
// Extract data columns
const columns: Record<string, number[]> = {};
paramNames.forEach(name => {
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
});
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
// Calculate correlation matrix
const n = allLabels.length;
const correlationMatrix: number[][] = [];
const annotationData: any[] = [];
for (let i = 0; i < n; i++) {
const row: number[] = [];
for (let j = 0; j < n; j++) {
const col1 = columns[allLabels[i]];
const col2 = columns[allLabels[j]];
// Ensure same length
const minLen = Math.min(col1.length, col2.length);
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
row.push(corr);
// Add annotation
annotationData.push({
x: allLabels[j],
y: allLabels[i],
text: corr.toFixed(2),
showarrow: false,
font: {
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
size: 11
}
});
}
correlationMatrix.push(row);
}
return {
matrix: correlationMatrix,
labels: allLabels,
annotations: annotationData
};
}, [trials, objectiveName]);
if (trials.length < 3) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>Need at least 3 trials to compute correlations</p>
</div>
);
}
return (
<Plot
data={[
{
z: matrix,
x: labels,
y: labels,
type: 'heatmap',
colorscale: [
[0, '#ef4444'], // -1: strong negative (red)
[0.25, '#f87171'], // -0.5: moderate negative
[0.5, '#1a1b26'], // 0: no correlation (dark)
[0.75, '#60a5fa'], // 0.5: moderate positive
[1, '#3b82f6'] // 1: strong positive (blue)
],
zmin: -1,
zmax: 1,
showscale: true,
colorbar: {
title: { text: 'Correlation', font: { color: '#888' } },
tickfont: { color: '#888' },
len: 0.8
},
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
}
]}
layout={{
title: {
text: 'Parameter-Objective Correlation Matrix',
font: { color: '#fff', size: 14 }
},
height,
margin: { l: 120, r: 60, t: 60, b: 120 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
tickangle: 45,
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
annotations: annotations
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
constraint_satisfied?: boolean;
}
interface PlotlyFeasibilityChartProps {
trials: TrialData[];
height?: number;
}
export function PlotlyFeasibilityChart({
trials,
height = 350
}: PlotlyFeasibilityChartProps) {
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
if (trials.length === 0) {
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
}
// Sort trials by number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const numbers: number[] = [];
const cumulative: number[] = [];
const windowed: number[] = [];
let feasibleCount = 0;
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
sorted.forEach((trial, idx) => {
numbers.push(trial.trial_number);
// Cumulative feasibility
if (trial.constraint_satisfied !== false) {
feasibleCount++;
}
cumulative.push((feasibleCount / (idx + 1)) * 100);
// Windowed (rolling) feasibility
const windowStart = Math.max(0, idx - windowSize + 1);
const windowTrials = sorted.slice(windowStart, idx + 1);
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
windowed.push((windowFeasible / windowTrials.length) * 100);
});
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
}, [trials]);
if (trials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No trials to display</p>
</div>
);
}
return (
<Plot
data={[
{
x: trialNumbers,
y: cumulativeFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Cumulative Feasibility',
line: { color: '#22c55e', width: 2 },
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
},
{
x: trialNumbers,
y: windowedFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Rolling (20-trial)',
line: { color: '#60a5fa', width: 2, dash: 'dot' },
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
}
]}
layout={{
height,
margin: { l: 60, r: 30, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)',
zeroline: false
},
yaxis: {
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)',
zeroline: false,
range: [0, 105]
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
x: 0.02,
y: 0.98,
xanchor: 'left',
yanchor: 'top'
},
showlegend: true,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -5,8 +5,10 @@
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Click to select trials
* - Pareto front connection line
* - FEA vs NN differentiation
* - Constraint satisfaction highlighting
* - Dark mode styling
* - Zoom, pan, and export
*/
@@ -19,6 +21,7 @@ interface Trial {
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
interface Objective {
@@ -32,28 +35,37 @@ interface PlotlyParetoPlotProps {
paretoFront: Trial[];
objectives: Objective[];
height?: number;
showParetoLine?: boolean;
showInfeasible?: boolean;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500
height = 500,
showParetoLine = true,
showInfeasible = true
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source and Pareto status
const { feaTrials, nnTrials, paretoTrials } = useMemo(() => {
// Separate trials by source, Pareto status, and constraint satisfaction
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
const infeasible: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
if (paretoSet.has(t.trial_number)) {
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
if (!isFeasible && showInfeasible) {
infeasible.push(t);
} else if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
@@ -62,8 +74,18 @@ export function PlotlyParetoPlot({
}
});
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto };
}, [trials, paretoSet]);
// Calculate statistics
const stats = {
totalTrials: trials.length,
paretoCount: pareto.length,
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
infeasibleCount: infeasible.length,
hypervolume: 0 // Could calculate if needed
};
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
}, [trials, paretoSet, showInfeasible]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
@@ -135,80 +157,129 @@ export function PlotlyParetoPlot({
}
};
// Sort Pareto trials by first objective for line connection
const sortedParetoTrials = useMemo(() => {
const [i] = selectedObjectives;
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
}, [paretoTrials, selectedObjectives]);
// Create Pareto front line trace (2D only)
const createParetoLine = () => {
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
const [i, j] = selectedObjectives;
return {
type: 'scatter' as const,
mode: 'lines' as const,
name: 'Pareto Front',
x: sortedParetoTrials.map(t => getObjValue(t, i)),
y: sortedParetoTrials.map(t => getObjValue(t, j)),
line: {
color: '#10B981',
width: 2,
dash: 'dot'
},
hoverinfo: 'skip' as const,
showlegend: false
};
};
const traces = [
// FEA trials (background, less prominent)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#93C5FD', 'circle', 8, 0.6),
// NN trials (background, less prominent)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#FDBA74', 'cross', 8, 0.5),
// Pareto front (highlighted)
createTrace(paretoTrials, `Pareto (${paretoTrials.length})`, '#10B981', 'diamond', 12, 1.0)
].filter(trace => (trace.x as number[]).length > 0);
// Infeasible trials (background, red X)
...(showInfeasible && infeasibleTrials.length > 0 ? [
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
] : []),
// FEA trials (blue circles)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
// NN trials (purple diamonds)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
// Pareto front line (2D only)
createParetoLine(),
// Pareto front points (highlighted)
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
].filter(trace => trace && (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
// Dark mode color scheme
const colors = {
text: '#E5E7EB',
textMuted: '#9CA3AF',
grid: 'rgba(255,255,255,0.1)',
zeroline: 'rgba(255,255,255,0.2)',
legendBg: 'rgba(30,30,30,0.9)',
legendBorder: 'rgba(255,255,255,0.1)'
};
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
zaxis: {
title: objectives[k]?.name || 'Objective 3',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
bgcolor: 'rgba(0,0,0,0)'
bgcolor: 'transparent'
},
legend: {
x: 1,
y: 1,
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' }
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: objectives[i]?.name || 'Objective 1',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: objectives[j]?.name || 'Objective 2',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB'
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
<div className="flex items-center justify-center h-64 text-dark-400">
No trial data available
</div>
);
@@ -216,20 +287,54 @@ export function PlotlyParetoPlot({
return (
<div className="w-full">
{/* Stats Bar */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-dark-300">Pareto:</span>
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="text-dark-300">FEA:</span>
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="text-dark-300">NN:</span>
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
</div>
{stats.infeasibleCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-dark-300">Infeasible:</span>
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-gray-300">
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1 text-sm ${viewMode === '2d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '2d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1 text-sm ${viewMode === '3d' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '3d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
3D
</button>
@@ -239,22 +344,22 @@ export function PlotlyParetoPlot({
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-gray-600">X:</label>
<label className="text-dark-400">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-gray-600 ml-2">Y:</label>
<label className="text-dark-400 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -263,11 +368,11 @@ export function PlotlyParetoPlot({
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-gray-600 ml-2">Z:</label>
<label className="text-dark-400 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1 border border-gray-300 rounded text-sm"
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
@@ -284,7 +389,7 @@ export function PlotlyParetoPlot({
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d'],
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
@@ -295,6 +400,49 @@ export function PlotlyParetoPlot({
}}
style={{ width: '100%' }}
/>
{/* Pareto Front Table for 2D view */}
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
<div className="mt-4 max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{sortedParetoTrials.slice(0, 10).map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, i).toExponential(4)}
</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, j).toExponential(4)}
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
(trial.source || trial.user_attrs?.source) === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source || trial.user_attrs?.source || 'FEA'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{sortedParetoTrials.length > 10 && (
<div className="text-center py-2 text-dark-500 text-xs">
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Run {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface PlotlyRunComparisonProps {
runs: Run[];
height?: number;
}
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
const chartData = useMemo(() => {
if (runs.length === 0) return null;
// Separate FEA and NN runs
const feaRuns = runs.filter(r => r.source === 'FEA');
const nnRuns = runs.filter(r => r.source === 'NN');
// Create bar chart for trial counts
const trialCountData = {
x: runs.map(r => r.name),
y: runs.map(r => r.trial_count),
type: 'bar' as const,
name: 'Trial Count',
marker: {
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
},
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
};
// Create line chart for best values
const bestValueData = {
x: runs.map(r => r.name),
y: runs.map(r => r.best_value),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Best Value',
yaxis: 'y2',
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
};
return { trialCountData, bestValueData, feaRuns, nnRuns };
}, [runs]);
// Calculate statistics
const stats = useMemo(() => {
if (runs.length === 0) return null;
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
// Calculate improvement from first FEA run to overall best
const feaRuns = runs.filter(r => r.source === 'FEA');
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
return {
totalTrials,
feaTrials,
nnTrials,
overallBest,
improvement,
totalRuns: runs.length,
feaRuns: runs.filter(r => r.source === 'FEA').length,
nnRuns: runs.filter(r => r.source === 'NN').length
};
}, [runs]);
if (!chartData || !stats) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No run data available
</div>
);
}
return (
<div className="space-y-4">
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Best Value</div>
<div className="text-xl font-bold text-green-400">
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Improvement</div>
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
{stats.improvement !== null ? (
<>
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
<Minus className="w-4 h-4" />}
{Math.abs(stats.improvement).toFixed(1)}%
</>
) : 'N/A'}
</div>
</div>
</div>
{/* Chart */}
<Plot
data={[chartData.trialCountData, chartData.bestValueData]}
layout={{
height,
margin: { l: 60, r: 60, t: 40, b: 100 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#9ca3af', size: 11 },
showlegend: true,
legend: {
orientation: 'h',
y: 1.12,
x: 0.5,
xanchor: 'center',
bgcolor: 'transparent'
},
xaxis: {
tickangle: -45,
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
tickfont: { size: 10 }
},
yaxis: {
title: { text: 'Trial Count' },
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false
},
yaxis2: {
title: { text: 'Best Value' },
overlaying: 'y',
side: 'right',
gridcolor: 'rgba(75, 85, 99, 0.1)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false,
tickformat: '.2e'
},
bargap: 0.3,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
}}
className="w-full"
useResizeHandler
style={{ width: '100%' }}
/>
{/* Runs Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Calculate duration if times available
let duration = '-';
if (run.first_trial && run.last_trial) {
const start = new Date(run.first_trial);
const end = new Date(run.last_trial);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
duration = `${diffMins}m`;
} else {
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
duration = `${hours}h ${mins}m`;
}
}
return (
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
<td className="py-2 px-3 text-right font-mono text-green-400">
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-right font-mono text-dark-300">
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-dark-400">{duration}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default PlotlyRunComparison;

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
source?: 'FEA' | 'NN' | 'V10_FEA';
user_attrs?: Record<string, any>;
}
interface PlotlySurrogateQualityProps {
trials: TrialData[];
height?: number;
}
export function PlotlySurrogateQuality({
trials,
height = 400
}: PlotlySurrogateQualityProps) {
const { feaTrials, nnTrials, timeline } = useMemo(() => {
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
const nn = trials.filter(t => t.source === 'NN');
// Sort by trial number for timeline
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
// Calculate source distribution over time
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
let feaCount = 0;
let nnCount = 0;
sorted.forEach(t => {
if (t.source === 'NN') nnCount++;
else feaCount++;
timeline.push({
trial: t.trial_number,
feaCount,
nnCount
});
});
return {
feaTrials: fea,
nnTrials: nn,
timeline
};
}, [trials]);
if (nnTrials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No neural network evaluations in this study</p>
</div>
);
}
// Objective distribution by source
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
return (
<div className="space-y-6">
{/* Source Distribution Over Time */}
<Plot
data={[
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.feaCount),
type: 'scatter',
mode: 'lines',
name: 'FEA Cumulative',
line: { color: '#3b82f6', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(59, 130, 246, 0.2)'
},
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.nnCount),
type: 'scatter',
mode: 'lines',
name: 'NN Cumulative',
line: { color: '#a855f7', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(168, 85, 247, 0.2)'
}
]}
layout={{
title: {
text: 'Evaluation Source Over Time',
font: { color: '#fff', size: 14 }
},
height: height * 0.6,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Cumulative Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* Objective Distribution by Source */}
<Plot
data={[
{
x: feaObjectives,
type: 'histogram',
name: 'FEA',
marker: { color: 'rgba(59, 130, 246, 0.7)' },
opacity: 0.8
} as any,
{
x: nnObjectives,
type: 'histogram',
name: 'NN',
marker: { color: 'rgba(168, 85, 247, 0.7)' },
opacity: 0.8
} as any
]}
layout={{
title: {
text: 'Objective Distribution by Source',
font: { color: '#fff', size: 14 }
},
height: height * 0.5,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Objective Value', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
barmode: 'overlay',
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* FEA vs NN Best Values Comparison */}
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
<div className="text-xl font-mono text-blue-400">
{Math.min(...feaObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {feaObjectives.length} evaluations
</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
<div className="text-xl font-mono text-purple-400">
{Math.min(...nnObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {nnObjectives.length} predictions
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect } from 'react';
import { Activity, Clock, Cpu, Zap, CheckCircle } from 'lucide-react';
interface CurrentTrialProps {
studyId: string | null;
totalTrials: number;
completedTrials: number;
isRunning: boolean;
lastTrialTime?: number; // ms for last trial
}
type TrialPhase = 'idle' | 'sampling' | 'evaluating' | 'extracting' | 'complete';
export function CurrentTrialPanel({
studyId,
totalTrials,
completedTrials,
isRunning,
lastTrialTime
}: CurrentTrialProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [phase, setPhase] = useState<TrialPhase>('idle');
// Simulate phase progression when running
useEffect(() => {
if (!isRunning) {
setPhase('idle');
setElapsedTime(0);
return;
}
setPhase('sampling');
const interval = setInterval(() => {
setElapsedTime(prev => {
const newTime = prev + 1;
// Simulate phase transitions based on typical timing
if (newTime < 2) setPhase('sampling');
else if (newTime < 5) setPhase('evaluating');
else setPhase('extracting');
return newTime;
});
}, 1000);
return () => clearInterval(interval);
}, [isRunning, completedTrials]);
// Reset elapsed time when a new trial completes
useEffect(() => {
if (isRunning) {
setElapsedTime(0);
setPhase('sampling');
}
}, [completedTrials, isRunning]);
// Calculate ETA
const calculateETA = () => {
if (!isRunning || completedTrials === 0 || !lastTrialTime) return null;
const remainingTrials = totalTrials - completedTrials;
const avgTimePerTrial = lastTrialTime / 1000; // convert to seconds
const etaSeconds = remainingTrials * avgTimePerTrial;
if (etaSeconds < 60) return `~${Math.round(etaSeconds)}s`;
if (etaSeconds < 3600) return `~${Math.round(etaSeconds / 60)}m`;
return `~${(etaSeconds / 3600).toFixed(1)}h`;
};
const progressPercent = totalTrials > 0 ? (completedTrials / totalTrials) * 100 : 0;
const eta = calculateETA();
const getPhaseInfo = () => {
switch (phase) {
case 'sampling':
return { label: 'Sampling', color: 'text-blue-400', bgColor: 'bg-blue-500/20', icon: Zap };
case 'evaluating':
return { label: 'FEA Solving', color: 'text-yellow-400', bgColor: 'bg-yellow-500/20', icon: Cpu };
case 'extracting':
return { label: 'Extracting', color: 'text-purple-400', bgColor: 'bg-purple-500/20', icon: Activity };
case 'complete':
return { label: 'Complete', color: 'text-green-400', bgColor: 'bg-green-500/20', icon: CheckCircle };
default:
return { label: 'Idle', color: 'text-dark-400', bgColor: 'bg-dark-600', icon: Clock };
}
};
const phaseInfo = getPhaseInfo();
const PhaseIcon = phaseInfo.icon;
if (!studyId) return null;
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className={`w-5 h-5 ${isRunning ? 'text-green-400 animate-pulse' : 'text-dark-400'}`} />
<span className="font-semibold text-white">
{isRunning ? `Trial #${completedTrials + 1}` : 'Optimization Status'}
</span>
</div>
{isRunning && (
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${phaseInfo.bgColor} ${phaseInfo.color}`}>
<PhaseIcon className="w-3 h-3" />
{phaseInfo.label}
</span>
)}
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-dark-400">Progress</span>
<span className="text-white font-medium">
{completedTrials} / {totalTrials} trials
</span>
</div>
<div className="h-2 bg-dark-600 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isRunning ? 'bg-gradient-to-r from-primary-600 to-primary-400' : 'bg-primary-500'
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-3">
{/* Elapsed Time */}
<div className="text-center">
<div className={`text-lg font-mono ${isRunning ? 'text-white' : 'text-dark-400'}`}>
{isRunning ? `${elapsedTime}s` : '--'}
</div>
<div className="text-xs text-dark-400">Elapsed</div>
</div>
{/* Completion */}
<div className="text-center border-x border-dark-600">
<div className="text-lg font-mono text-primary-400">
{progressPercent.toFixed(1)}%
</div>
<div className="text-xs text-dark-400">Complete</div>
</div>
{/* ETA */}
<div className="text-center">
<div className={`text-lg font-mono ${eta ? 'text-blue-400' : 'text-dark-400'}`}>
{eta || '--'}
</div>
<div className="text-xs text-dark-400">ETA</div>
</div>
</div>
{/* Running indicator */}
{isRunning && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-green-400">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Optimization in progress...
</div>
</div>
)}
{/* Paused/Stopped indicator */}
{!isRunning && completedTrials > 0 && completedTrials < totalTrials && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-orange-400">
<span className="w-2 h-2 bg-orange-500 rounded-full" />
Optimization paused
</div>
</div>
)}
{/* Completed indicator */}
{!isRunning && completedTrials >= totalTrials && totalTrials > 0 && (
<div className="mt-3 pt-3 border-t border-dark-600">
<div className="flex items-center justify-center gap-2 text-xs text-blue-400">
<CheckCircle className="w-3 h-3" />
Optimization complete
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Cpu, Layers, Target, TrendingUp, Database, Brain } from 'lucide-react';
interface OptimizerStatePanelProps {
sampler?: string;
nTrials: number;
completedTrials: number;
feaTrials?: number;
nnTrials?: number;
objectives?: Array<{ name: string; direction: string }>;
isMultiObjective: boolean;
paretoSize?: number;
}
export function OptimizerStatePanel({
sampler = 'TPESampler',
nTrials,
completedTrials,
feaTrials = 0,
nnTrials = 0,
objectives = [],
isMultiObjective,
paretoSize = 0
}: OptimizerStatePanelProps) {
// Determine optimizer phase based on progress
const getPhase = () => {
if (completedTrials === 0) return 'Initializing';
if (completedTrials < 10) return 'Exploration';
if (completedTrials < nTrials * 0.5) return 'Exploitation';
if (completedTrials < nTrials * 0.9) return 'Refinement';
return 'Convergence';
};
const phase = getPhase();
// Format sampler name for display
const formatSampler = (s: string) => {
const samplers: Record<string, string> = {
'TPESampler': 'TPE (Bayesian)',
'NSGAIISampler': 'NSGA-II',
'NSGAIIISampler': 'NSGA-III',
'CmaEsSampler': 'CMA-ES',
'RandomSampler': 'Random',
'GridSampler': 'Grid',
'QMCSampler': 'Quasi-Monte Carlo'
};
return samplers[s] || s;
};
return (
<div className="bg-dark-750 rounded-lg border border-dark-600 p-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<Cpu className="w-5 h-5 text-primary-400" />
<span className="font-semibold text-white">Optimizer State</span>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* Sampler */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Sampler</span>
</div>
<div className="text-sm font-medium text-white truncate" title={sampler}>
{formatSampler(sampler)}
</div>
</div>
{/* Phase */}
<div className="bg-dark-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">Phase</span>
</div>
<div className={`text-sm font-medium ${
phase === 'Convergence' ? 'text-green-400' :
phase === 'Refinement' ? 'text-blue-400' :
phase === 'Exploitation' ? 'text-yellow-400' :
'text-primary-400'
}`}>
{phase}
</div>
</div>
</div>
{/* FEA vs NN Trials (for hybrid optimizations) */}
{(feaTrials > 0 || nnTrials > 0) && (
<div className="mb-4">
<div className="text-xs text-dark-400 uppercase mb-2">Trial Sources</div>
<div className="flex gap-2">
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Database className="w-4 h-4 text-blue-400 mx-auto mb-1" />
<div className="text-lg font-bold text-blue-400">{feaTrials}</div>
<div className="text-xs text-dark-400">FEA</div>
</div>
<div className="flex-1 bg-dark-700 rounded-lg p-2 text-center">
<Brain className="w-4 h-4 text-purple-400 mx-auto mb-1" />
<div className="text-lg font-bold text-purple-400">{nnTrials}</div>
<div className="text-xs text-dark-400">Neural Net</div>
</div>
</div>
{nnTrials > 0 && (
<div className="mt-2 text-xs text-dark-400 text-center">
{((nnTrials / (feaTrials + nnTrials)) * 100).toFixed(0)}% acceleration from surrogate
</div>
)}
</div>
)}
{/* Objectives */}
{objectives.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-dark-400" />
<span className="text-xs text-dark-400 uppercase">
{isMultiObjective ? 'Multi-Objective' : 'Single Objective'}
</span>
</div>
<div className="space-y-1">
{objectives.slice(0, 3).map((obj, idx) => (
<div
key={idx}
className="flex items-center justify-between text-sm bg-dark-700 rounded px-2 py-1"
>
<span className="text-dark-300 truncate" title={obj.name}>
{obj.name.length > 20 ? obj.name.slice(0, 18) + '...' : obj.name}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
obj.direction === 'minimize' ? 'bg-green-900/50 text-green-400' : 'bg-blue-900/50 text-blue-400'
}`}>
{obj.direction === 'minimize' ? 'min' : 'max'}
</span>
</div>
))}
{objectives.length > 3 && (
<div className="text-xs text-dark-500 text-center">
+{objectives.length - 3} more
</div>
)}
</div>
</div>
)}
{/* Pareto Front Size (for multi-objective) */}
{isMultiObjective && paretoSize > 0 && (
<div className="pt-3 border-t border-dark-600">
<div className="flex items-center justify-between">
<span className="text-xs text-dark-400">Pareto Front Size</span>
<span className="text-sm font-medium text-primary-400">
{paretoSize} solutions
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentTrialPanel } from './CurrentTrialPanel';
export { OptimizerStatePanel } from './OptimizerStatePanel';

View File

@@ -0,0 +1,42 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ClaudeTerminalContextType {
// Terminal visibility state
isOpen: boolean;
setIsOpen: (open: boolean) => void;
isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void;
// Connection state (updated by the terminal component)
isConnected: boolean;
setIsConnected: (connected: boolean) => void;
}
const ClaudeTerminalContext = createContext<ClaudeTerminalContextType | undefined>(undefined);
export const ClaudeTerminalProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isConnected, setIsConnected] = useState(false);
return (
<ClaudeTerminalContext.Provider value={{
isOpen,
setIsOpen,
isExpanded,
setIsExpanded,
isConnected,
setIsConnected
}}>
{children}
</ClaudeTerminalContext.Provider>
);
};
export const useClaudeTerminal = () => {
const context = useContext(ClaudeTerminalContext);
if (context === undefined) {
throw new Error('useClaudeTerminal must be used within a ClaudeTerminalProvider');
}
return context;
};

View File

@@ -8,6 +8,7 @@ interface StudyContextType {
studies: Study[];
refreshStudies: () => Promise<void>;
isLoading: boolean;
isInitialized: boolean; // True once initial load + localStorage restoration is complete
clearStudy: () => void;
}
@@ -17,6 +18,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const [selectedStudy, setSelectedStudyState] = useState<Study | null>(null);
const [studies, setStudies] = useState<Study[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const refreshStudies = async () => {
try {
@@ -55,16 +57,23 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
// Initial load
useEffect(() => {
const init = async () => {
await refreshStudies();
// Restore last selected study
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
try {
const response = await apiClient.getStudies();
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
setStudies(response.studies);
// Restore last selected study from localStorage
const lastStudyId = localStorage.getItem('selectedStudyId');
if (lastStudyId) {
const study = response.studies.find(s => s.id === lastStudyId);
if (study) {
setSelectedStudyState(study);
}
}
} catch (error) {
console.error('Failed to initialize studies:', error);
} finally {
setIsLoading(false);
setIsInitialized(true); // Mark as initialized AFTER localStorage restoration
}
};
init();
@@ -77,6 +86,7 @@ export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) =
studies,
refreshStudies,
isLoading,
isInitialized,
clearStudy
}}>
{children}

View File

@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react';
interface NotificationOptions {
title: string;
body: string;
icon?: string;
tag?: string;
requireInteraction?: boolean;
}
interface UseNotificationsReturn {
permission: NotificationPermission | 'unsupported';
requestPermission: () => Promise<boolean>;
showNotification: (options: NotificationOptions) => void;
isEnabled: boolean;
setEnabled: (enabled: boolean) => void;
}
const STORAGE_KEY = 'atomizer-notifications-enabled';
export function useNotifications(): UseNotificationsReturn {
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
);
const [isEnabled, setIsEnabledState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'true';
});
// Update permission state when it changes
useEffect(() => {
if (typeof Notification === 'undefined') {
setPermission('unsupported');
return;
}
setPermission(Notification.permission);
}, []);
const requestPermission = useCallback(async (): Promise<boolean> => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported in this browser');
return false;
}
if (Notification.permission === 'granted') {
setPermission('granted');
return true;
}
if (Notification.permission === 'denied') {
setPermission('denied');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (error) {
console.error('Error requesting notification permission:', error);
return false;
}
}, []);
const setEnabled = useCallback((enabled: boolean) => {
setIsEnabledState(enabled);
localStorage.setItem(STORAGE_KEY, enabled.toString());
}, []);
const showNotification = useCallback((options: NotificationOptions) => {
if (typeof Notification === 'undefined') {
console.warn('Notifications not supported');
return;
}
if (!isEnabled) {
return;
}
if (Notification.permission !== 'granted') {
console.warn('Notification permission not granted');
return;
}
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || '/favicon.ico',
tag: options.tag,
requireInteraction: options.requireInteraction || false,
silent: false
});
// Auto close after 5 seconds unless requireInteraction is true
if (!options.requireInteraction) {
setTimeout(() => notification.close(), 5000);
}
// Focus window on click
notification.onclick = () => {
window.focus();
notification.close();
};
} catch (error) {
console.error('Error showing notification:', error);
}
}, [isEnabled]);
return {
permission,
requestPermission,
showNotification,
isEnabled,
setEnabled
};
}
// Notification types for optimization events
export interface OptimizationNotification {
type: 'new_best' | 'completed' | 'error' | 'milestone';
studyName: string;
message: string;
value?: number;
improvement?: number;
}
export function formatOptimizationNotification(notification: OptimizationNotification): NotificationOptions {
switch (notification.type) {
case 'new_best':
return {
title: `New Best Found - ${notification.studyName}`,
body: notification.improvement
? `${notification.message} (${notification.improvement.toFixed(1)}% improvement)`
: notification.message,
tag: `best-${notification.studyName}`,
requireInteraction: false
};
case 'completed':
return {
title: `Optimization Complete - ${notification.studyName}`,
body: notification.message,
tag: `complete-${notification.studyName}`,
requireInteraction: true
};
case 'error':
return {
title: `Error - ${notification.studyName}`,
body: notification.message,
tag: `error-${notification.studyName}`,
requireInteraction: true
};
case 'milestone':
return {
title: `Milestone Reached - ${notification.studyName}`,
body: notification.message,
tag: `milestone-${notification.studyName}`,
requireInteraction: false
};
default:
return {
title: notification.studyName,
body: notification.message
};
}
}
export default useNotifications;

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BarChart3,
TrendingUp,
Grid3X3,
Target,
Filter,
Brain,
RefreshCw,
Download,
Layers,
LucideIcon
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
// Lazy load charts
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
type AnalysisTab = 'overview' | 'parameters' | 'pareto' | 'correlations' | 'constraints' | 'surrogate' | 'runs';
interface RunData {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface ObjectiveData {
name: string;
direction: 'minimize' | 'maximize';
}
interface StudyMetadata {
objectives?: ObjectiveData[];
design_variables?: Array<{ name: string; min?: number; max?: number }>;
sampler?: string;
description?: string;
}
export default function Analysis() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [activeTab, setActiveTab] = useState<AnalysisTab>('overview');
const [loading, setLoading] = useState(true);
const [trials, setTrials] = useState<TrialData[]>([]);
const [metadata, setMetadata] = useState<StudyMetadata | null>(null);
const [paretoFront, setParetoFront] = useState<any[]>([]);
const [runs, setRuns] = useState<RunData[]>([]);
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study data
useEffect(() => {
if (!selectedStudy) return;
const loadData = async () => {
setLoading(true);
try {
// Load trial history
const historyRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/history?limit=500`);
const historyData = await historyRes.json();
const trialsData = historyData.trials.map((t: any) => {
let values: number[] = [];
if (t.objectives && Array.isArray(t.objectives)) {
values = t.objectives;
} else if (t.objective !== null && t.objective !== undefined) {
values = [t.objective];
}
const rawSource = t.source || t.user_attrs?.source || 'FEA';
const source: 'FEA' | 'NN' | 'V10_FEA' = rawSource === 'NN' ? 'NN' : rawSource === 'V10_FEA' ? 'V10_FEA' : 'FEA';
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source
};
});
setTrials(trialsData);
// Load metadata
const metadataRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/metadata`);
const metadataData = await metadataRes.json();
setMetadata(metadataData);
// Load Pareto front
const paretoRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/pareto-front`);
const paretoData = await paretoRes.json();
if (paretoData.is_multi_objective && paretoData.pareto_front) {
setParetoFront(paretoData.pareto_front);
}
// Load runs data for comparison
const runsRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/runs`);
const runsData = await runsRes.json();
if (runsData.runs) {
setRuns(runsData.runs);
}
} catch (err) {
console.error('Failed to load analysis data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedStudy]);
// Calculate statistics
const stats = useMemo(() => {
if (trials.length === 0) return null;
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
if (objectives.length === 0) return null;
const sorted = [...objectives].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const mean = objectives.reduce((a, b) => a + b, 0) / objectives.length;
const median = sorted[Math.floor(sorted.length / 2)];
const stdDev = Math.sqrt(objectives.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / objectives.length);
const p25 = sorted[Math.floor(sorted.length * 0.25)];
const p75 = sorted[Math.floor(sorted.length * 0.75)];
const p90 = sorted[Math.floor(sorted.length * 0.90)];
const feaTrials = trials.filter(t => t.source === 'FEA').length;
const nnTrials = trials.filter(t => t.source === 'NN').length;
const feasible = trials.filter(t => t.constraint_satisfied).length;
return {
min,
max,
mean,
median,
stdDev,
p25,
p75,
p90,
feaTrials,
nnTrials,
feasible,
total: trials.length,
feasibilityRate: (feasible / trials.length) * 100
};
}, [trials]);
// Tabs configuration
const tabs: { id: AnalysisTab; label: string; icon: LucideIcon; disabled?: boolean }[] = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'parameters', label: 'Parameters', icon: TrendingUp },
{ id: 'pareto', label: 'Pareto', icon: Target, disabled: (metadata?.objectives?.length || 0) <= 1 },
{ id: 'correlations', label: 'Correlations', icon: Grid3X3 },
{ id: 'constraints', label: 'Constraints', icon: Filter },
{ id: 'surrogate', label: 'Surrogate', icon: Brain, disabled: trials.filter(t => t.source === 'NN').length === 0 },
{ id: 'runs', label: 'Runs', icon: Layers, disabled: runs.length <= 1 },
];
// Export data
const handleExportCSV = () => {
if (trials.length === 0) return;
const paramNames = Object.keys(trials[0].params);
const headers = ['trial', 'objective', ...paramNames, 'source', 'feasible'].join(',');
const rows = trials.map(t => [
t.trial_number,
t.values[0],
...paramNames.map(p => t.params[p]),
t.source,
t.constraint_satisfied
].join(','));
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id}_analysis.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (!isInitialized || !selectedStudy) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading...</p>
</div>
</div>
);
}
const isMultiObjective = (metadata?.objectives?.length || 0) > 1;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-primary-400">Analysis</h1>
<p className="text-dark-400 text-sm">Deep analysis for {selectedStudy.name || selectedStudy.id}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
disabled={trials.length === 0}
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</header>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-dark-600 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: tab.disabled
? 'text-dark-600 cursor-not-allowed'
: 'text-dark-400 hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
<div className="text-2xl font-bold text-green-400">{stats.min.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Mean</div>
<div className="text-2xl font-bold text-white">{stats.mean.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Median</div>
<div className="text-2xl font-bold text-white">{stats.median.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Std Dev</div>
<div className="text-2xl font-bold text-white">{stats.stdDev.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
)}
{/* Percentile Distribution */}
{stats && (
<Card title="Objective Distribution">
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">Min</div>
<div className="text-lg font-mono text-green-400">{stats.min.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">25th %</div>
<div className="text-lg font-mono text-white">{stats.p25.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">75th %</div>
<div className="text-lg font-mono text-white">{stats.p75.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">90th %</div>
<div className="text-lg font-mono text-white">{stats.p90.toExponential(3)}</div>
</div>
</div>
</Card>
)}
{/* Convergence Plot */}
{trials.length > 0 && (
<Card title="Convergence Plot">
<Suspense fallback={<ChartLoading />}>
<PlotlyConvergencePlot
trials={trials}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={350}
/>
</Suspense>
</Card>
)}
{/* Best Trials Table */}
<Card title="Top 10 Best Trials">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Rank</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<th key={p} className="text-left py-2 px-3 text-dark-400 font-medium">{p}</th>
))}
</tr>
</thead>
<tbody>
{[...trials]
.sort((a, b) => (a.values[0] ?? Infinity) - (b.values[0] ?? Infinity))
.slice(0, 10)
.map((trial, idx) => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3">
<span className={`inline-flex w-6 h-6 items-center justify-center rounded-full text-xs font-bold ${
idx === 0 ? 'bg-yellow-500/20 text-yellow-400' :
idx === 1 ? 'bg-gray-400/20 text-gray-300' :
idx === 2 ? 'bg-orange-700/20 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{idx + 1}
</span>
</td>
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">{trial.values[0]?.toExponential(4)}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<td key={p} className="py-2 px-3 font-mono text-dark-300">
{trial.params[p]?.toFixed(4)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Parameters Tab */}
{activeTab === 'parameters' && (
<div className="space-y-6">
{/* Parameter Importance */}
{trials.length > 0 && metadata?.design_variables && (
<Card title="Parameter Importance">
<Suspense fallback={<ChartLoading />}>
<PlotlyParameterImportance
trials={trials}
designVariables={metadata.design_variables}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={400}
/>
</Suspense>
</Card>
)}
{/* Parallel Coordinates */}
{trials.length > 0 && metadata && (
<Card title="Parallel Coordinates">
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={trials}
objectives={metadata.objectives || []}
designVariables={metadata.design_variables || []}
paretoFront={paretoFront}
height={450}
/>
</Suspense>
</Card>
)}
</div>
)}
{/* Pareto Tab */}
{activeTab === 'pareto' && isMultiObjective && (
<div className="space-y-6">
{/* Pareto Metrics */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Pareto Solutions</div>
<div className="text-2xl font-bold text-primary-400">{paretoFront.length}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Objectives</div>
<div className="text-2xl font-bold text-white">{metadata?.objectives?.length || 0}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Dominated Ratio</div>
<div className="text-2xl font-bold text-white">
{trials.length > 0 ? ((1 - paretoFront.length / trials.length) * 100).toFixed(1) : 0}%
</div>
</Card>
</div>
{/* Pareto Front Plot */}
{paretoFront.length > 0 && (
<Card title="Pareto Front">
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot
trials={trials}
paretoFront={paretoFront}
objectives={metadata?.objectives || []}
height={500}
/>
</Suspense>
</Card>
)}
{/* Pareto Solutions Table */}
<Card title="Pareto-Optimal Solutions">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
{metadata?.objectives?.map(obj => (
<th key={obj.name} className="text-left py-2 px-3 text-dark-400 font-medium">{obj.name}</th>
))}
</tr>
</thead>
<tbody>
{paretoFront.slice(0, 20).map((sol, idx) => (
<tr key={idx} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{sol.trial_number}</td>
{sol.values?.map((v: number, i: number) => (
<td key={i} className="py-2 px-3 font-mono text-primary-400">{v?.toExponential(4)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Correlations Tab */}
{activeTab === 'correlations' && (
<div className="space-y-6">
{/* Correlation Heatmap */}
{trials.length > 2 && (
<Card title="Parameter-Objective Correlation Matrix">
<Suspense fallback={<ChartLoading />}>
<PlotlyCorrelationHeatmap
trials={trials}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
/>
</Suspense>
</Card>
)}
{/* Correlation Interpretation Guide */}
<Card title="Interpreting Correlations">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="p-3 bg-blue-500/10 rounded-lg border border-blue-500/30">
<div className="text-blue-400 font-semibold mb-1">Strong Positive (0.7 to 1.0)</div>
<p className="text-dark-400 text-xs">Increasing parameter increases objective</p>
</div>
<div className="p-3 bg-blue-500/5 rounded-lg border border-blue-500/20">
<div className="text-blue-300 font-semibold mb-1">Moderate Positive (0.3 to 0.7)</div>
<p className="text-dark-400 text-xs">Some positive relationship</p>
</div>
<div className="p-3 bg-red-500/5 rounded-lg border border-red-500/20">
<div className="text-red-300 font-semibold mb-1">Moderate Negative (-0.7 to -0.3)</div>
<p className="text-dark-400 text-xs">Some negative relationship</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<div className="text-red-400 font-semibold mb-1">Strong Negative (-1.0 to -0.7)</div>
<p className="text-dark-400 text-xs">Increasing parameter decreases objective</p>
</div>
</div>
</Card>
{/* Top Correlations Table */}
{trials.length > 2 && (
<Card title="Strongest Parameter Correlations with Objective">
<CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
</Card>
)}
</div>
)}
{/* Constraints Tab */}
{activeTab === 'constraints' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasible Trials</div>
<div className="text-2xl font-bold text-green-400">{stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Infeasible Trials</div>
<div className="text-2xl font-bold text-red-400">{stats.total - stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility Rate</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
{/* Feasibility Over Time Chart */}
<Card title="Feasibility Rate Over Time">
<Suspense fallback={<ChartLoading />}>
<PlotlyFeasibilityChart trials={trials} height={350} />
</Suspense>
</Card>
{/* Infeasible Trials List */}
{stats.total - stats.feasible > 0 && (
<Card title="Recent Infeasible Trials">
<div className="overflow-x-auto max-h-64">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{trials
.filter(t => !t.constraint_satisfied)
.slice(-20)
.reverse()
.map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-red-400">{trial.values[0]?.toExponential(4) || 'N/A'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)}
{/* Surrogate Tab */}
{activeTab === 'surrogate' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">FEA Evaluations</div>
<div className="text-2xl font-bold text-blue-400">{stats.feaTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Predictions</div>
<div className="text-2xl font-bold text-purple-400">{stats.nnTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Ratio</div>
<div className="text-2xl font-bold text-green-400">
{stats.nnTrials > 0 ? `${((stats.nnTrials / stats.total) * 100).toFixed(0)}%` : '0%'}
</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Speedup Factor</div>
<div className="text-2xl font-bold text-primary-400">
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
</div>
</Card>
</div>
{/* Surrogate Quality Charts */}
<Card title="Surrogate Model Analysis">
<Suspense fallback={<ChartLoading />}>
<PlotlySurrogateQuality trials={trials} height={400} />
</Suspense>
</Card>
</div>
)}
{/* Runs Tab */}
{activeTab === 'runs' && runs.length > 0 && (
<div className="space-y-6">
<Card title="Optimization Runs Comparison">
<p className="text-dark-400 text-sm mb-4">
Compare different optimization runs within this study. Studies with adaptive optimization
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
</p>
<Suspense fallback={<ChartLoading />}>
<PlotlyRunComparison runs={runs} height={400} />
</Suspense>
</Card>
</div>
)}
</>
)}
</div>
);
}
// Helper component for correlation table
function CorrelationTable({ trials, objectiveName }: { trials: TrialData[]; objectiveName: string }) {
const correlations = useMemo(() => {
if (trials.length < 3) return [];
const paramNames = Object.keys(trials[0].params);
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const results: { param: string; correlation: number; absCorr: number }[] = [];
paramNames.forEach(param => {
const paramValues = trials.map(t => t.params[param]).filter(v => v !== undefined && !isNaN(v));
const minLen = Math.min(paramValues.length, objectives.length);
if (minLen < 3) return;
// Calculate Pearson correlation
const x = paramValues.slice(0, minLen);
const y = objectives.slice(0, minLen);
const n = x.length;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
const corr = denominator === 0 ? 0 : numerator / denominator;
results.push({ param, correlation: corr, absCorr: Math.abs(corr) });
});
return results.sort((a, b) => b.absCorr - a.absCorr);
}, [trials]);
if (correlations.length === 0) {
return <p className="text-dark-400 text-center py-4">Not enough data for correlation analysis</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Parameter</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Correlation with {objectiveName}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Strength</th>
</tr>
</thead>
<tbody>
{correlations.slice(0, 10).map(({ param, correlation, absCorr }) => (
<tr key={param} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">{param}</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${correlation > 0 ? 'bg-blue-500' : 'bg-red-500'}`}
style={{ width: `${absCorr * 100}%`, marginLeft: correlation < 0 ? 'auto' : 0 }}
/>
</div>
<span className={`font-mono ${
absCorr > 0.7 ? 'text-white font-bold' :
absCorr > 0.3 ? 'text-dark-200' : 'text-dark-400'
}`}>
{correlation > 0 ? '+' : ''}{correlation.toFixed(3)}
</span>
</div>
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
absCorr > 0.7 ? 'bg-primary-500/20 text-primary-400' :
absCorr > 0.3 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-dark-600 text-dark-400'
}`}>
{absCorr > 0.7 ? 'Strong' : absCorr > 0.3 ? 'Moderate' : 'Weak'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,17 +1,15 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LineChart, Line, ScatterChart, Scatter,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell
} from 'recharts';
import { Terminal } from 'lucide-react';
import { Terminal, Settings } from 'lucide-react';
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
import { useNotifications, formatOptimizationNotification } from '../hooks/useNotifications';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
import { Card } from '../components/common/Card';
import { MetricCard } from '../components/dashboard/MetricCard';
import { ControlPanel } from '../components/dashboard/ControlPanel';
import { ClaudeTerminal } from '../components/ClaudeTerminal';
import { NotificationSettings } from '../components/NotificationSettings';
import { ConfigEditor } from '../components/ConfigEditor';
import { ParetoPlot } from '../components/ParetoPlot';
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
@@ -19,7 +17,8 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import { ConsoleOutput } from '../components/ConsoleOutput';
import { ExpandableChart } from '../components/ExpandableChart';
import type { Trial, ConvergenceDataPoint, ParameterSpaceDataPoint } from '../types';
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
@@ -36,16 +35,10 @@ const ChartLoading = () => (
export default function Dashboard() {
const navigate = useNavigate();
const { selectedStudy, refreshStudies } = useStudy();
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
const selectedStudyId = selectedStudy?.id || null;
// Redirect to home if no study selected
useEffect(() => {
if (!selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
// All hooks must be declared before any conditional returns
const [allTrials, setAllTrials] = useState<Trial[]>([]);
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
const [bestValue, setBestValue] = useState<number>(Infinity);
@@ -57,9 +50,9 @@ export default function Dashboard() {
const [trialsPage, setTrialsPage] = useState(0);
const trialsPerPage = 50; // Limit trials per page for performance
// Parameter Space axis selection
const [paramXIndex, setParamXIndex] = useState(0);
const [paramYIndex, setParamYIndex] = useState(1);
// Parameter Space axis selection (reserved for future use)
const [_paramXIndex, _setParamXIndex] = useState(0);
const [_paramYIndex, _setParamYIndex] = useState(1);
// Protocol 13: New state for metadata and Pareto front
const [studyMetadata, setStudyMetadata] = useState<any>(null);
@@ -69,9 +62,26 @@ export default function Dashboard() {
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
// Claude chat panel state
const [chatOpen, setChatOpen] = useState(false);
const [chatExpanded, setChatExpanded] = useState(false);
// Process status for tracker panels
const [isRunning, setIsRunning] = useState(false);
const [lastTrialTime, _setLastTrialTime] = useState<number | undefined>(undefined);
// Config editor modal
const [showConfigEditor, setShowConfigEditor] = useState(false);
// Claude terminal from global context
const { isOpen: claudeTerminalOpen, setIsOpen: setClaudeTerminalOpen, isConnected: claudeConnected } = useClaudeTerminal();
// Desktop notifications
const { showNotification } = useNotifications();
const previousBestRef = useRef<number>(Infinity);
// Redirect to home if no study selected (but only after initialization completes)
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
const showAlert = (type: 'success' | 'warning', message: string) => {
const id = alertIdCounter;
@@ -90,8 +100,22 @@ export default function Dashboard() {
const trial = msg.data as Trial;
setAllTrials(prev => [...prev, trial]);
if (trial.objective !== null && trial.objective !== undefined && trial.objective < bestValue) {
const improvement = previousBestRef.current !== Infinity
? ((previousBestRef.current - trial.objective) / Math.abs(previousBestRef.current)) * 100
: 0;
setBestValue(trial.objective);
previousBestRef.current = trial.objective;
showAlert('success', `New best: ${trial.objective.toFixed(4)} (Trial #${trial.trial_number})`);
// Desktop notification for new best
showNotification(formatOptimizationNotification({
type: 'new_best',
studyName: selectedStudy?.name || selectedStudyId || 'Study',
message: `Best value: ${trial.objective.toExponential(4)}`,
value: trial.objective,
improvement
}));
}
} else if (msg.type === 'trial_pruned') {
setPrunedCount(prev => prev + 1);
@@ -168,9 +192,31 @@ export default function Dashboard() {
}
})
.catch(err => console.error('Failed to load Pareto front:', err));
// Check process status
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(err => console.error('Failed to load process status:', err));
}
}, [selectedStudyId]);
// Poll process status periodically
useEffect(() => {
if (!selectedStudyId) return;
const pollStatus = setInterval(() => {
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(() => {});
}, 5000);
return () => clearInterval(pollStatus);
}, [selectedStudyId]);
// Sort trials based on selected sort order
useEffect(() => {
let sorted = [...allTrials];
@@ -229,50 +275,19 @@ export default function Dashboard() {
return () => clearInterval(refreshInterval);
}, [selectedStudyId]);
// Sample data for charts when there are too many trials (performance optimization)
const MAX_CHART_POINTS = 200; // Reduced for better performance
const sampleData = <T,>(data: T[], maxPoints: number): T[] => {
if (data.length <= maxPoints) return data;
const step = Math.ceil(data.length / maxPoints);
return data.filter((_, i) => i % step === 0 || i === data.length - 1);
};
// Show loading state while initializing (restoring study from localStorage)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
// Prepare chart data with proper null/undefined handling
const allValidTrials = allTrials
.filter(t => t.objective !== null && t.objective !== undefined)
.sort((a, b) => a.trial_number - b.trial_number);
// Calculate best_so_far for each trial
let runningBest = Infinity;
const convergenceDataFull: ConvergenceDataPoint[] = allValidTrials.map(trial => {
if (trial.objective < runningBest) {
runningBest = trial.objective;
}
return {
trial_number: trial.trial_number,
objective: trial.objective,
best_so_far: runningBest,
};
});
// Sample for chart rendering performance
const convergenceData = sampleData(convergenceDataFull, MAX_CHART_POINTS);
const parameterSpaceDataFull: ParameterSpaceDataPoint[] = allTrials
.filter(t => t.objective !== null && t.objective !== undefined && t.design_variables)
.map(trial => {
const params = Object.values(trial.design_variables);
return {
trial_number: trial.trial_number,
x: params[paramXIndex] || 0,
y: params[paramYIndex] || 0,
objective: trial.objective,
isBest: trial.objective === bestValue,
};
});
// Sample for chart rendering performance
const parameterSpaceData = sampleData(parameterSpaceDataFull, MAX_CHART_POINTS);
// Note: Chart data sampling is handled by individual chart components
// Calculate average objective
const validObjectives = allTrials.filter(t => t.objective !== null && t.objective !== undefined).map(t => t.objective);
@@ -356,18 +371,38 @@ export default function Dashboard() {
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
</div>
<div className="flex gap-2">
{/* Config Editor Button */}
{selectedStudyId && (
<button
onClick={() => setShowConfigEditor(true)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs bg-dark-700 text-dark-400 hover:bg-dark-600 hover:text-white transition-colors"
title="Edit study configuration"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Config</span>
</button>
)}
{/* Notification Toggle */}
<NotificationSettings compact />
{/* Claude Code Terminal Toggle Button */}
<button
onClick={() => setChatOpen(!chatOpen)}
onClick={() => setClaudeTerminalOpen(!claudeTerminalOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
chatOpen
claudeTerminalOpen
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border border-dark-600'
: claudeConnected
? 'bg-green-700 text-white border border-green-600'
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border border-dark-600'
}`}
title="Open Claude Code terminal"
title={claudeConnected ? 'Claude Terminal (Connected)' : 'Open Claude Code terminal'}
>
<Terminal className="w-4 h-4" />
<span className="hidden sm:inline">Claude Code</span>
{claudeConnected && !claudeTerminalOpen && (
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
)}
</button>
{selectedStudyId && (
<StudyReportViewer studyId={selectedStudyId} />
@@ -417,68 +452,83 @@ export default function Dashboard() {
</div>
</header>
<div className="grid grid-cols-12 gap-4">
{/* Control Panel - Left Sidebar (smaller) */}
<aside className="col-span-2">
<ControlPanel onStatusChange={refreshStudies} />
</aside>
{/* Control Panel - Full Width on Top */}
<div className="mb-6">
<ControlPanel onStatusChange={refreshStudies} horizontal />
</div>
{/* Main Content - takes most of the space */}
<main className={chatOpen ? 'col-span-6' : 'col-span-10'}>
{/* Study Name Header */}
{selectedStudyId && (
<div className="mb-4 pb-3 border-b border-dark-600">
<h2 className="text-xl font-semibold text-primary-300">
{selectedStudyId}
</h2>
{studyMetadata?.description && (
<p className="text-sm text-dark-400 mt-1">{studyMetadata.description}</p>
{/* Tracker Panels - Current Trial and Optimizer State */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<CurrentTrialPanel
studyId={selectedStudyId}
totalTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
isRunning={isRunning}
lastTrialTime={lastTrialTime}
/>
<OptimizerStatePanel
sampler={studyMetadata?.sampler}
nTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
feaTrials={allTrialsRaw.filter(t => t.source === 'FEA').length}
nnTrials={allTrialsRaw.filter(t => t.source === 'NN').length}
objectives={studyMetadata?.objectives || []}
isMultiObjective={(studyMetadata?.objectives?.length || 0) > 1}
paretoSize={paretoFront.length}
/>
</div>
{/* Main Layout: Charts (Claude Terminal is now global/floating) */}
<div className="grid gap-4 grid-cols-1">
{/* Main Content - Charts stacked vertically */}
<main>
{/* Study Name Header + Metrics in one row */}
<div className="mb-4 pb-3 border-b border-dark-600 flex items-center justify-between">
<div>
{selectedStudyId && (
<>
<h2 className="text-xl font-semibold text-primary-300">
{selectedStudyId}
</h2>
{studyMetadata?.description && (
<p className="text-sm text-dark-400 mt-1">{studyMetadata.description}</p>
)}
</>
)}
</div>
)}
{/* Metrics Grid */}
<div className="grid grid-cols-4 gap-4 mb-6">
<MetricCard label="Total Trials" value={allTrials.length} />
<MetricCard
label="Best Value"
value={bestValue === Infinity ? '-' : bestValue.toFixed(4)}
valueColor="text-green-400"
/>
<MetricCard
label="Avg Objective"
value={avgObjective > 0 ? avgObjective.toFixed(4) : '-'}
valueColor="text-blue-400"
/>
<MetricCard
label="Pruned"
value={prunedCount}
valueColor={prunedCount > 0 ? 'text-red-400' : 'text-green-400'}
/>
{/* Compact Metrics */}
<div className="flex gap-3">
<div className="text-center px-3">
<div className="text-2xl font-bold text-white">{allTrials.length}</div>
<div className="text-xs text-dark-400">Trials</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className="text-2xl font-bold text-green-400">
{bestValue === Infinity ? '-' : bestValue.toFixed(4)}
</div>
<div className="text-xs text-dark-400">Best</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className="text-2xl font-bold text-blue-400">
{avgObjective > 0 ? avgObjective.toFixed(4) : '-'}
</div>
<div className="text-xs text-dark-400">Avg</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className={`text-2xl font-bold ${prunedCount > 0 ? 'text-red-400' : 'text-green-400'}`}>
{prunedCount}
</div>
<div className="text-xs text-dark-400">Pruned</div>
</div>
</div>
</div>
{/* Protocol 13: Intelligent Optimizer & Pareto Front */}
{/* Pareto Front - Full Width */}
{selectedStudyId && paretoFront.length > 0 && studyMetadata && studyMetadata.objectives && (
<div className="grid grid-cols-2 gap-6 mb-6">
<Card title="Optimizer Strategy">
<div className="space-y-2">
<div className="text-sm text-dark-300">
<span className="font-semibold text-dark-100">Algorithm:</span> {studyMetadata.sampler || 'NSGA-II'}
</div>
<div className="text-sm text-dark-300">
<span className="font-semibold text-dark-100">Type:</span> Multi-objective
</div>
<div className="text-sm text-dark-300">
<span className="font-semibold text-dark-100">Objectives:</span> {studyMetadata.objectives?.length || 2}
</div>
<div className="text-sm text-dark-300">
<span className="font-semibold text-dark-100">Design Variables:</span> {studyMetadata.design_variables?.length || 0}
</div>
</div>
</Card>
<div className="mb-4">
<ExpandableChart
title="Pareto Front"
subtitle={`${paretoFront.length} Pareto-optimal solutions`}
subtitle={`${paretoFront.length} Pareto-optimal solutions | ${studyMetadata.sampler || 'NSGA-II'} | ${studyMetadata.objectives?.length || 2} objectives`}
>
{chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
@@ -486,7 +536,7 @@ export default function Dashboard() {
trials={allTrialsRaw}
paretoFront={paretoFront}
objectives={studyMetadata.objectives}
height={350}
height={300}
/>
</Suspense>
) : (
@@ -500,11 +550,11 @@ export default function Dashboard() {
</div>
)}
{/* Parallel Coordinates (full width for multi-objective) */}
{/* Parallel Coordinates - Full Width */}
{allTrialsRaw.length > 0 && studyMetadata && studyMetadata.objectives && studyMetadata.design_variables && (
<div className="mb-6">
<div className="mb-4">
<ExpandableChart
title="Parallel Coordinates Plot"
title="Parallel Coordinates"
subtitle={`${allTrialsRaw.length} trials - Design Variables → Objectives`}
>
{chartLibrary === 'plotly' ? (
@@ -514,7 +564,7 @@ export default function Dashboard() {
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={450}
height={350}
/>
</Suspense>
) : (
@@ -531,9 +581,9 @@ export default function Dashboard() {
{/* Convergence Plot - Full Width */}
{allTrialsRaw.length > 0 && (
<div className="mb-6">
<div className="mb-4">
<ExpandableChart
title="Convergence Plot"
title="Convergence"
subtitle={`Best ${studyMetadata?.objectives?.[0]?.name || 'Objective'} over ${allTrialsRaw.length} trials`}
>
{chartLibrary === 'plotly' ? (
@@ -543,7 +593,7 @@ export default function Dashboard() {
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={350}
height={280}
/>
</Suspense>
) : (
@@ -560,7 +610,7 @@ export default function Dashboard() {
{/* Parameter Importance - Full Width */}
{allTrialsRaw.length > 0 && (studyMetadata?.design_variables?.length > 0 || (allTrialsRaw[0]?.params && Object.keys(allTrialsRaw[0].params).length > 0)) && (
<div className="mb-6">
<div className="mb-4">
<ExpandableChart
title="Parameter Importance"
subtitle={`Correlation with ${studyMetadata?.objectives?.[0]?.name || 'Objective'}`}
@@ -576,7 +626,7 @@ export default function Dashboard() {
}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
height={350}
height={280}
/>
</Suspense>
) : (
@@ -595,138 +645,6 @@ export default function Dashboard() {
</div>
)}
{/* Charts */}
<div className="grid grid-cols-2 gap-6 mb-6">
{/* Convergence Chart */}
<ExpandableChart
title="Convergence Plot (Single Objective)"
subtitle={`${convergenceData.length} trials`}
>
<Card title="Convergence Plot">
{convergenceData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={convergenceData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="trial_number"
stroke="#94a3b8"
label={{ value: 'Trial Number', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
/>
<YAxis
stroke="#94a3b8"
label={{ value: 'Objective', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
/>
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
/>
<Legend />
<Line
type="monotone"
dataKey="objective"
stroke="#60a5fa"
name="Objective"
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="best_so_far"
stroke="#10b981"
name="Best So Far"
strokeWidth={2}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-dark-300">
No trial data yet
</div>
)}
</Card>
</ExpandableChart>
{/* Parameter Space Chart with Selectable Axes */}
<ExpandableChart
title="Parameter Space"
subtitle={`${parameterSpaceData.length} trials - ${paramNames[paramXIndex] || 'X'} vs ${paramNames[paramYIndex] || 'Y'}`}
>
<Card title={
<div className="flex items-center justify-between w-full">
<span>Parameter Space</span>
{paramNames.length > 2 && (
<div className="flex items-center gap-2 text-sm">
<span className="text-dark-400">X:</span>
<select
value={paramXIndex}
onChange={(e) => setParamXIndex(Number(e.target.value))}
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
>
{paramNames.map((name, idx) => (
<option key={idx} value={idx}>{name}</option>
))}
</select>
<span className="text-dark-400">Y:</span>
<select
value={paramYIndex}
onChange={(e) => setParamYIndex(Number(e.target.value))}
className="bg-dark-600 text-dark-100 px-2 py-1 rounded text-xs border border-dark-500"
>
{paramNames.map((name, idx) => (
<option key={idx} value={idx}>{name}</option>
))}
</select>
</div>
)}
</div>
}>
{parameterSpaceData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
type="number"
dataKey="x"
stroke="#94a3b8"
name={paramNames[paramXIndex] || 'X'}
label={{ value: paramNames[paramXIndex] || 'Parameter X', position: 'insideBottom', offset: -5, fill: '#94a3b8' }}
/>
<YAxis
type="number"
dataKey="y"
stroke="#94a3b8"
name={paramNames[paramYIndex] || 'Y'}
label={{ value: paramNames[paramYIndex] || 'Parameter Y', angle: -90, position: 'insideLeft', fill: '#94a3b8' }}
/>
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: '8px' }}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: any, name: string) => {
if (name === 'objective') return [value.toFixed(4), 'Objective'];
return [value.toFixed(3), name];
}}
/>
<Scatter name="Trials" data={parameterSpaceData}>
{parameterSpaceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.isBest ? '#10b981' : '#60a5fa'}
r={entry.isBest ? 8 : 5}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-dark-300">
No trial data yet
</div>
)}
</Card>
</ExpandableChart>
</div>
{/* Trial History with Sort Controls and Pagination */}
<Card
title={
@@ -925,26 +843,24 @@ export default function Dashboard() {
</Card>
{/* Console Output - at the bottom */}
<div className="mt-6">
<div className="mt-4">
<ConsoleOutput
studyId={selectedStudyId}
refreshInterval={2000}
maxLines={200}
maxLines={150}
/>
</div>
</main>
{/* Claude Code Terminal - Right Sidebar (taller for better visibility) */}
{chatOpen && (
<aside className="col-span-4 h-[calc(100vh-8rem)] sticky top-20">
<ClaudeTerminal
isExpanded={chatExpanded}
onToggleExpand={() => setChatExpanded(!chatExpanded)}
onClose={() => setChatOpen(false)}
/>
</aside>
)}
</div>
{/* Config Editor Modal */}
{showConfigEditor && selectedStudyId && (
<ConfigEditor
studyId={selectedStudyId}
onClose={() => setShowConfigEditor(false)}
onSaved={() => refreshStudies()}
/>
)}
</div>
);
}

View File

@@ -1,37 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FolderOpen,
Play,
Pause,
CheckCircle,
Clock,
AlertCircle,
ArrowRight,
RefreshCw,
Zap,
FileText,
ChevronDown,
ChevronUp,
Target,
Activity
Activity,
BarChart3,
TrendingUp,
ArrowRight
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Study } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { apiClient } from '../api/client';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
const Home: React.FC = () => {
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
const [selectedPreview, setSelectedPreview] = useState<Study | null>(null);
const [readme, setReadme] = useState<string>('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [showAllStudies, setShowAllStudies] = useState(false);
const [sortField, setSortField] = useState<'name' | 'status' | 'trials' | 'bestValue'>('trials');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const navigate = useNavigate();
// Load README when a study is selected for preview
@@ -48,7 +44,7 @@ const Home: React.FC = () => {
try {
const response = await apiClient.getStudyReadme(studyId);
setReadme(response.content || 'No README found for this study.');
} catch (error) {
} catch {
setReadme('No README found for this study.');
} finally {
setReadmeLoading(false);
@@ -60,70 +56,88 @@ const Home: React.FC = () => {
navigate('/dashboard');
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('desc');
}
};
// Sort studies
const sortedStudies = useMemo(() => {
return [...studies].sort((a, b) => {
let aVal: any, bVal: any;
switch (sortField) {
case 'name':
aVal = (a.name || a.id).toLowerCase();
bVal = (b.name || b.id).toLowerCase();
break;
case 'trials':
aVal = a.progress.current;
bVal = b.progress.current;
break;
case 'bestValue':
aVal = a.best_value ?? Infinity;
bVal = b.best_value ?? Infinity;
break;
case 'status':
const statusOrder = { running: 0, paused: 1, completed: 2, not_started: 3 };
aVal = statusOrder[a.status as keyof typeof statusOrder] ?? 4;
bVal = statusOrder[b.status as keyof typeof statusOrder] ?? 4;
break;
default:
return 0;
}
if (sortDir === 'asc') {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
} else {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
});
}, [studies, sortField, sortDir]);
// Aggregate stats
const aggregateStats = useMemo(() => {
const totalStudies = studies.length;
const runningStudies = studies.filter(s => s.status === 'running').length;
const completedStudies = studies.filter(s => s.status === 'completed').length;
const totalTrials = studies.reduce((sum, s) => sum + s.progress.current, 0);
const studiesWithValues = studies.filter(s => s.best_value !== null);
const bestOverall = studiesWithValues.length > 0
? studiesWithValues.reduce((best, curr) =>
(curr.best_value! < best.best_value!) ? curr : best
)
: null;
return { totalStudies, runningStudies, completedStudies, totalTrials, bestOverall };
}, [studies]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-3.5 h-3.5" />;
return <Play className="w-4 h-4 text-green-400" />;
case 'paused':
return <Pause className="w-4 h-4 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3.5 h-3.5" />;
case 'not_started':
return <Clock className="w-3.5 h-3.5" />;
return <CheckCircle className="w-4 h-4 text-blue-400" />;
default:
return <AlertCircle className="w-3.5 h-3.5" />;
return <Clock className="w-4 h-4 text-dark-400" />;
}
};
const getStatusStyles = (status: string) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return {
badge: 'bg-green-500/20 text-green-400 border-green-500/30',
card: 'border-green-500/30 hover:border-green-500/50',
glow: 'shadow-green-500/10'
};
case 'completed':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
card: 'border-blue-500/30 hover:border-blue-500/50',
glow: 'shadow-blue-500/10'
};
case 'not_started':
return {
badge: 'bg-dark-600 text-dark-400 border-dark-500',
card: 'border-dark-600 hover:border-dark-500',
glow: ''
};
default:
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
card: 'border-yellow-500/30 hover:border-yellow-500/50',
glow: 'shadow-yellow-500/10'
};
case 'running': return 'text-green-400 bg-green-500/10';
case 'paused': return 'text-orange-400 bg-orange-500/10';
case 'completed': return 'text-blue-400 bg-blue-500/10';
default: return 'text-dark-400 bg-dark-600';
}
};
// Study sort options
const [studySort, setStudySort] = useState<'date' | 'running' | 'trials'>('date');
// Sort studies based on selected sort option
const sortedStudies = [...studies].sort((a, b) => {
if (studySort === 'running') {
// Running first, then by date
if (a.status === 'running' && b.status !== 'running') return -1;
if (b.status === 'running' && a.status !== 'running') return 1;
}
if (studySort === 'trials') {
// By trial count (most trials first)
return b.progress.current - a.progress.current;
}
// Default: sort by date (newest first)
const aDate = a.last_modified || a.created_at || '';
const bDate = b.last_modified || b.created_at || '';
return bDate.localeCompare(aDate);
});
const displayedStudies = showAllStudies ? sortedStudies : sortedStudies.slice(0, 6);
return (
<div className="min-h-screen bg-dark-900">
{/* Header */}
@@ -153,352 +167,254 @@ const Home: React.FC = () => {
</header>
<main className="max-w-[1920px] mx-auto px-6 py-8">
{/* Study Selection Section */}
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary-400" />
Select a Study
</h2>
<div className="flex items-center gap-4">
{/* Sort Controls */}
<div className="flex items-center gap-2">
<span className="text-sm text-dark-400">Sort:</span>
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setStudySort('date')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'date'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Newest
</button>
<button
onClick={() => setStudySort('running')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'running'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Running
</button>
<button
onClick={() => setStudySort('trials')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'trials'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Most Trials
</button>
</div>
</div>
{studies.length > 6 && (
<button
onClick={() => setShowAllStudies(!showAllStudies)}
className="text-sm text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllStudies ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All ({studies.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
{/* Aggregate Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<BarChart3 className="w-4 h-4" />
Total Studies
</div>
<div className="text-3xl font-bold text-white">{aggregateStats.totalStudies}</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-green-400 text-sm mb-2">
<Play className="w-4 h-4" />
Running
</div>
) : studies.length === 0 ? (
<div className="text-center py-12 text-dark-400">
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No studies found. Create a new study to get started.</p>
<div className="text-3xl font-bold text-green-400">{aggregateStats.runningStudies}</div>
</div>
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<Activity className="w-4 h-4" />
Total Trials
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedStudies.map((study) => {
const styles = getStatusStyles(study.status);
const isSelected = selectedPreview?.id === study.id;
<div className="text-3xl font-bold text-white">{aggregateStats.totalTrials.toLocaleString()}</div>
</div>
return (
<div
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`
relative p-4 rounded-xl border cursor-pointer transition-all duration-200
bg-dark-800 hover:bg-dark-750
${styles.card} ${styles.glow}
${isSelected ? 'ring-2 ring-primary-500 border-primary-500' : ''}
`}
>
{/* Status Badge */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 pr-2">
<h3 className="text-white font-medium truncate">{study.name || study.id}</h3>
<p className="text-dark-500 text-xs truncate mt-0.5">{study.id}</p>
</div>
<span className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-full border ${styles.badge}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mb-3">
<div className="flex items-center gap-1.5 text-dark-400">
<Activity className="w-3.5 h-3.5" />
<span>{study.progress.current} trials</span>
</div>
{study.best_value !== null && (
<div className="flex items-center gap-1.5 text-primary-400">
<Target className="w-3.5 h-3.5" />
<span>{study.best_value.toFixed(4)}</span>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
study.status === 'running' ? 'bg-green-500' :
study.status === 'completed' ? 'bg-blue-500' : 'bg-primary-500'
}`}
style={{ width: `${Math.min((study.progress.current / study.progress.total) * 100, 100)}%` }}
/>
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute -bottom-px left-1/2 -translate-x-1/2 w-12 h-1 bg-primary-500 rounded-t-full" />
)}
</div>
);
})}
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-primary-400 text-sm mb-2">
<Target className="w-4 h-4" />
Best Overall
</div>
)}
</section>
{/* Study Documentation Section */}
{selectedPreview && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Documentation Header */}
<div className="bg-dark-800 rounded-t-xl border border-dark-600 border-b-0">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">{selectedPreview.name || selectedPreview.id}</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-5 py-2.5 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30"
>
Open Dashboard
<ArrowRight className="w-4 h-4" />
</button>
<div className="text-2xl font-bold text-primary-400">
{aggregateStats.bestOverall?.best_value !== null && aggregateStats.bestOverall?.best_value !== undefined
? aggregateStats.bestOverall.best_value.toExponential(3)
: 'N/A'}
</div>
{aggregateStats.bestOverall && (
<div className="text-xs text-dark-400 mt-1 truncate">
{aggregateStats.bestOverall.name || aggregateStats.bestOverall.id}
</div>
)}
</div>
</div>
{/* Two-column layout: Table + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Study Table */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary-400" />
Studies
</h2>
<span className="text-sm text-dark-400">{studies.length} studies</span>
</div>
{/* README Content */}
<div className="bg-dark-850 rounded-b-xl border border-dark-600 border-t-0 overflow-hidden">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<div className="p-8 overflow-x-auto">
<article className="markdown-body max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
{isLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
</div>
) : studies.length === 0 ? (
<div className="text-center py-16 text-dark-400">
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No studies found</p>
<p className="text-sm mt-1 text-dark-500">Create a new study to get started</p>
</div>
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-dark-750 z-10">
<tr className="border-b border-dark-600">
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Study Name
{sortField === 'name' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('trials')}
>
<div className="flex items-center gap-1">
Progress
{sortField === 'trials' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('bestValue')}
>
<div className="flex items-center gap-1">
Best
{sortField === 'bestValue' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
</tr>
</thead>
<tbody>
{sortedStudies.map((study) => {
const completionPercent = study.progress.total > 0
? Math.round((study.progress.current / study.progress.total) * 100)
: 0;
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
return (
<tr
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`border-b border-dark-700 hover:bg-dark-750 transition-colors cursor-pointer ${
selectedPreview?.id === study.id ? 'bg-primary-900/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="text-white font-medium truncate max-w-[200px]">
{study.name || study.id}
</span>
{study.name && (
<span className="text-xs text-dark-500 truncate max-w-[200px]">{study.id}</span>
)}
</div>
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{readme}
</ReactMarkdown>
</article>
</div>
)}
</div>
</section>
)}
<td className="py-3 px-4">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(study.status)}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-dark-600 rounded-full overflow-hidden max-w-[80px]">
<div
className={`h-full transition-all ${
completionPercent >= 100 ? 'bg-green-500' :
completionPercent >= 50 ? 'bg-primary-500' :
'bg-yellow-500'
}`}
style={{ width: `${Math.min(completionPercent, 100)}%` }}
/>
</div>
<span className="text-dark-400 text-sm font-mono w-16">
{study.progress.current}/{study.progress.total}
</span>
</div>
</td>
<td className="py-3 px-4">
<span className={`font-mono text-sm ${study.best_value !== null ? 'text-primary-400' : 'text-dark-500'}`}>
{study.best_value !== null ? study.best_value.toExponential(3) : 'N/A'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Empty State when no study selected */}
{!selectedPreview && studies.length > 0 && (
<section className="flex items-center justify-center py-16 text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to view its documentation</p>
<p className="text-sm mt-1 text-dark-500">Click on any study card above</p>
</div>
</section>
)}
{/* Study Preview */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden flex flex-col">
{selectedPreview ? (
<>
{/* Preview Header */}
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white truncate">
{selectedPreview.name || selectedPreview.id}
</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30 whitespace-nowrap"
>
Open
<ArrowRight className="w-4 h-4" />
</button>
</div>
{/* Study Quick Stats */}
<div className="px-6 py-3 border-b border-dark-600 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
{getStatusIcon(selectedPreview.status)}
<span className="text-dark-300 capitalize">{selectedPreview.status}</span>
</div>
<div className="flex items-center gap-2 text-dark-400">
<Activity className="w-4 h-4" />
<span>{selectedPreview.progress.current} / {selectedPreview.progress.total} trials</span>
</div>
{selectedPreview.best_value !== null && (
<div className="flex items-center gap-2 text-primary-400">
<Target className="w-4 h-4" />
<span>Best: {selectedPreview.best_value.toExponential(4)}</span>
</div>
)}
</div>
{/* README Content */}
<div className="flex-1 overflow-y-auto p-6">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<MarkdownRenderer content={readme} />
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to preview</p>
<p className="text-sm mt-1 text-dark-500">Click on any row in the table</p>
</div>
</div>
)}
</div>
</div>
</main>
</div>
);

View File

@@ -10,14 +10,45 @@ import {
Loader2,
AlertTriangle,
CheckCircle,
Copy
Copy,
Trophy,
TrendingUp,
FileJson,
FileSpreadsheet,
Settings,
ArrowRight,
ChevronDown,
ChevronUp,
Printer
} from 'lucide-react';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import ReactMarkdown from 'react-markdown';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
interface BestSolution {
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}
export default function Results() {
const { selectedStudy } = useStudy();
const { selectedStudy, isInitialized } = useStudy();
const navigate = useNavigate();
const [reportContent, setReportContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -25,21 +56,37 @@ export default function Results() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [lastGenerated, setLastGenerated] = useState<string | null>(null);
const [bestSolution, setBestSolution] = useState<BestSolution | null>(null);
const [showAllParams, setShowAllParams] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
// Redirect if no study selected
// Redirect if no study selected (but only after initialization completes)
useEffect(() => {
if (!selectedStudy) {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
}, [selectedStudy, navigate, isInitialized]);
// Load report when study changes
// Load report and best solution when study changes
useEffect(() => {
if (selectedStudy) {
loadReport();
loadBestSolution();
}
}, [selectedStudy]);
// Show loading state while initializing (must be after all hooks)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
const loadReport = async () => {
if (!selectedStudy) return;
@@ -52,7 +99,7 @@ export default function Results() {
if (data.generated_at) {
setLastGenerated(data.generated_at);
}
} catch (err: any) {
} catch {
// No report yet - show placeholder
setReportContent(null);
} finally {
@@ -60,6 +107,17 @@ export default function Results() {
}
};
const loadBestSolution = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getBestSolution(selectedStudy.id);
setBestSolution(data);
} catch {
setBestSolution(null);
}
};
const handleGenerate = async () => {
if (!selectedStudy) return;
@@ -101,17 +159,148 @@ export default function Results() {
URL.revokeObjectURL(url);
};
const handlePrintPDF = () => {
if (!reportContent || !selectedStudy) return;
// Create a printable version of the report
const printWindow = window.open('', '_blank');
if (!printWindow) {
setError('Pop-up blocked. Please allow pop-ups to print PDF.');
return;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${selectedStudy.name} - Optimization Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px;
color: #1a1a1a;
line-height: 1.6;
}
h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
h2 { color: #1e40af; margin-top: 30px; }
h3 { color: #3730a3; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
tr:nth-child(even) { background: #f9fafb; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-family: 'Monaco', monospace; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 4px solid #2563eb; margin: 20px 0; padding: 10px 20px; background: #eff6ff; }
.header-info { color: #666; margin-bottom: 30px; }
@media print {
body { padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
</style>
</head>
<body>
<div class="header-info">
<strong>Study:</strong> ${selectedStudy.name}<br>
<strong>Generated:</strong> ${new Date().toLocaleString()}<br>
<strong>Trials:</strong> ${selectedStudy.progress.current} / ${selectedStudy.progress.total}
</div>
${convertMarkdownToHTML(reportContent)}
</body>
</html>
`);
printWindow.document.close();
// Wait for content to load then print
printWindow.onload = () => {
printWindow.print();
};
};
// Simple markdown to HTML converter for print
const convertMarkdownToHTML = (md: string): string => {
return md
// Headers
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Lists
.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)\n(?!<li>)/g, '</ul>$1\n')
.replace(/(?<!<\/ul>)(<li>)/g, '<ul>$1')
// Blockquotes
.replace(/^>\s*(.*)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return match;
});
};
const handleExport = async (format: 'csv' | 'json' | 'config') => {
if (!selectedStudy) return;
setExporting(format);
try {
const data = await apiClient.exportData(selectedStudy.id, format);
if (data.filename && data.content) {
const blob = new Blob([data.content], { type: data.content_type || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else if (format === 'json' && data.trials) {
// Direct JSON response
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy.id}_data.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err: any) {
setError(err.message || `Failed to export ${format}`);
} finally {
setExporting(null);
}
};
if (!selectedStudy) {
return null;
}
const paramEntries = bestSolution?.best_trial?.design_variables
? Object.entries(bestSolution.best_trial.design_variables)
: [];
const visibleParams = showAllParams ? paramEntries : paramEntries.slice(0, 6);
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between">
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
<h1 className="text-2xl font-bold text-primary-400">Results</h1>
<p className="text-dark-400 text-sm">{selectedStudy.name}</p>
</div>
<div className="flex gap-2">
<Button
@@ -153,7 +342,156 @@ export default function Results() {
</div>
)}
{/* Main Content */}
{/* Best Solution Card */}
{bestSolution?.best_trial && (
<Card className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Best Solution</h2>
<p className="text-sm text-dark-400">Trial #{bestSolution.best_trial.trial_number} of {bestSolution.total_trials}</p>
</div>
{bestSolution.improvements.objective && (
<div className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-900/20 rounded-lg border border-green-800/30">
<TrendingUp className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-bold text-lg">
{bestSolution.improvements.objective.improvement_pct > 0 ? '+' : ''}
{bestSolution.improvements.objective.improvement_pct.toFixed(1)}%
</span>
<span className="text-dark-400 text-sm">improvement</span>
</div>
)}
</div>
{/* Objective Value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Objective</div>
<div className="text-2xl font-bold text-primary-400">
{bestSolution.best_trial.objective.toExponential(4)}
</div>
</div>
{bestSolution.first_trial && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Initial Value</div>
<div className="text-2xl font-bold text-dark-300">
{bestSolution.first_trial.objective.toExponential(4)}
</div>
</div>
)}
{bestSolution.improvements.objective && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Absolute Change</div>
<div className="text-2xl font-bold text-green-400 flex items-center gap-2">
<ArrowRight className="w-5 h-5" />
{bestSolution.improvements.objective.absolute_change.toExponential(4)}
</div>
</div>
)}
</div>
{/* Design Variables */}
<div className="border-t border-dark-600 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-dark-300">Optimal Design Variables</h3>
{paramEntries.length > 6 && (
<button
onClick={() => setShowAllParams(!showAllParams)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllParams ? (
<>Show Less <ChevronUp className="w-3 h-3" /></>
) : (
<>Show All ({paramEntries.length}) <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{visibleParams.map(([name, value]) => (
<div key={name} className="bg-dark-800 rounded px-3 py-2">
<div className="text-xs text-dark-400 truncate" title={name}>{name}</div>
<div className="text-sm font-mono text-white">
{typeof value === 'number' ? value.toFixed(4) : value}
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Export Options */}
<Card className="mb-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Download className="w-5 h-5 text-primary-400" />
Export Data
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => handleExport('csv')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileSpreadsheet className="w-8 h-8 text-green-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">CSV</div>
<div className="text-xs text-dark-400">Spreadsheet</div>
</div>
{exporting === 'csv' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('json')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileJson className="w-8 h-8 text-blue-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">JSON</div>
<div className="text-xs text-dark-400">Full data</div>
</div>
{exporting === 'json' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('config')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<Settings className="w-8 h-8 text-purple-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Config</div>
<div className="text-xs text-dark-400">Settings</div>
</div>
{exporting === 'config' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={handleDownload}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-8 h-8 text-orange-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Report</div>
<div className="text-xs text-dark-400">Markdown</div>
</div>
</button>
<button
onClick={handlePrintPDF}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-8 h-8 text-red-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">PDF</div>
<div className="text-xs text-dark-400">Print report</div>
</div>
</button>
</div>
</Card>
{/* Main Content - Report */}
<div className="flex-1 min-h-0">
<Card className="h-full overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
@@ -175,18 +513,8 @@ export default function Results() {
<span>Loading report...</span>
</div>
) : reportContent ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-dark-300 prose-strong:text-white
prose-code:text-primary-400 prose-code:bg-dark-700 prose-code:px-1 prose-code:rounded
prose-pre:bg-dark-700 prose-pre:border prose-pre:border-dark-600
prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline
prose-ul:text-dark-300 prose-ol:text-dark-300
prose-li:text-dark-300
prose-table:border-collapse prose-th:border prose-th:border-dark-600 prose-th:p-2 prose-th:bg-dark-700
prose-td:border prose-td:border-dark-600 prose-td:p-2
prose-hr:border-dark-600">
<ReactMarkdown>{reportContent}</ReactMarkdown>
<div className="p-2">
<MarkdownRenderer content={reportContent} />
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-dark-400">

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Settings,
Target,
Sliders,
AlertTriangle,
Cpu,
Box,
Layers,
Play,
Download,
RefreshCw,
ChevronDown,
ChevronUp,
ArrowUp,
ArrowDown,
CheckCircle,
Info,
FileBox,
FolderOpen,
File
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
import { Button } from '../components/common/Button';
import { apiClient, ModelFile } from '../api/client';
interface StudyConfig {
study_name: string;
description?: string;
objectives: {
name: string;
direction: 'minimize' | 'maximize';
unit?: string;
target?: number;
weight?: number;
}[];
design_variables: {
name: string;
type: 'float' | 'int' | 'categorical';
low?: number;
high?: number;
step?: number;
choices?: string[];
unit?: string;
}[];
constraints: {
name: string;
type: 'le' | 'ge' | 'eq';
bound: number;
unit?: string;
}[];
algorithm: {
name: string;
sampler: string;
pruner?: string;
n_trials: number;
timeout?: number;
};
fea_model?: {
software: string;
solver: string;
sim_file?: string;
mesh_elements?: number;
};
extractors?: {
name: string;
type: string;
source?: string;
}[];
}
export default function Setup() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [config, setConfig] = useState<StudyConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['objectives', 'variables', 'constraints', 'algorithm', 'modelFiles'])
);
const [modelFiles, setModelFiles] = useState<ModelFile[]>([]);
const [modelDir, setModelDir] = useState<string>('');
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study configuration
useEffect(() => {
if (selectedStudy) {
loadConfig();
loadModelFiles();
}
}, [selectedStudy]);
const loadModelFiles = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getModelFiles(selectedStudy.id);
setModelFiles(data.files);
setModelDir(data.model_dir);
} catch (err) {
console.error('Failed to load model files:', err);
}
};
const handleOpenFolder = async () => {
if (!selectedStudy) return;
try {
await apiClient.openFolder(selectedStudy.id, 'model');
} catch (err: any) {
setError(err.message || 'Failed to open folder');
}
};
const loadConfig = async () => {
if (!selectedStudy) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.getStudyConfig(selectedStudy.id);
const rawConfig = response.config;
// Transform backend config format to our StudyConfig format
const transformedConfig: StudyConfig = {
study_name: rawConfig.study_name || selectedStudy.name || selectedStudy.id,
description: rawConfig.description,
objectives: (rawConfig.objectives || []).map((obj: any) => ({
name: obj.name,
direction: obj.direction || 'minimize',
unit: obj.unit || obj.units,
target: obj.target,
weight: obj.weight
})),
design_variables: (rawConfig.design_variables || []).map((dv: any) => ({
name: dv.name,
type: dv.type || 'float',
low: dv.min ?? dv.low,
high: dv.max ?? dv.high,
step: dv.step,
choices: dv.choices,
unit: dv.unit || dv.units
})),
constraints: (rawConfig.constraints || []).map((c: any) => ({
name: c.name,
type: c.type || 'le',
bound: c.max_value ?? c.min_value ?? c.bound ?? 0,
unit: c.unit || c.units
})),
algorithm: {
name: rawConfig.optimizer?.name || rawConfig.algorithm?.name || 'Optuna',
sampler: rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler',
pruner: rawConfig.optimization_settings?.pruner || rawConfig.algorithm?.pruner,
n_trials: rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total,
timeout: rawConfig.optimization_settings?.timeout
},
fea_model: rawConfig.fea_model || rawConfig.solver ? {
software: rawConfig.fea_model?.software || rawConfig.solver?.type || 'NX Nastran',
solver: rawConfig.fea_model?.solver || rawConfig.solver?.name || 'SOL 103',
sim_file: rawConfig.sim_file || rawConfig.fea_model?.sim_file,
mesh_elements: rawConfig.fea_model?.mesh_elements
} : undefined,
extractors: rawConfig.extractors
};
setConfig(transformedConfig);
} catch (err: any) {
// If no config endpoint, create mock from available data
setConfig({
study_name: selectedStudy.name || selectedStudy.id,
objectives: [{ name: 'objective', direction: 'minimize' }],
design_variables: [],
constraints: [],
algorithm: {
name: 'Optuna',
sampler: 'TPESampler',
n_trials: selectedStudy.progress.total
}
});
setError('Configuration loaded with limited data');
} finally {
setLoading(false);
}
};
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const handleStartOptimization = async () => {
if (!selectedStudy) return;
try {
await apiClient.startOptimization(selectedStudy.id);
navigate('/dashboard');
} catch (err: any) {
setError(err.message || 'Failed to start optimization');
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id || 'study'}_config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Loading state
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
if (!selectedStudy) return null;
// Calculate design space size
const designSpaceSize = config?.design_variables.reduce((acc, v) => {
if (v.type === 'categorical' && v.choices) {
return acc * v.choices.length;
} else if (v.type === 'int' && v.low !== undefined && v.high !== undefined) {
return acc * (v.high - v.low + 1);
}
return acc * 1000; // Approximate for continuous
}, 1) || 0;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<div className="flex items-center gap-3">
<Settings className="w-8 h-8 text-primary-400" />
<div>
<h1 className="text-2xl font-bold text-white">{config?.study_name || selectedStudy.name}</h1>
<p className="text-dark-400 text-sm">Study Configuration</p>
</div>
</div>
{config?.description && (
<p className="text-dark-300 mt-2 max-w-2xl">{config.description}</p>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
onClick={loadConfig}
disabled={loading}
>
Refresh
</Button>
<Button
variant="secondary"
icon={<Download className="w-4 h-4" />}
onClick={handleExportConfig}
disabled={!config}
>
Export
</Button>
{selectedStudy.status === 'not_started' && (
<Button
variant="primary"
icon={<Play className="w-4 h-4" />}
onClick={handleStartOptimization}
>
Start Optimization
</Button>
)}
</div>
</header>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-yellow-900/20 border border-yellow-800/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<Info className="w-4 h-4" />
<span>{error}</span>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6">
{/* Objectives Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('objectives')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Objectives</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.objectives.length || 0}
</span>
</div>
{expandedSections.has('objectives') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('objectives') && (
<div className="px-4 pb-4 space-y-3">
{config?.objectives.map((obj, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-4 border border-dark-600"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{obj.name}</span>
<span className={`flex items-center gap-1 text-sm px-2 py-1 rounded ${
obj.direction === 'minimize'
? 'bg-green-500/10 text-green-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{obj.direction === 'minimize' ? (
<ArrowDown className="w-3 h-3" />
) : (
<ArrowUp className="w-3 h-3" />
)}
{obj.direction}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{obj.unit && <span>Unit: {obj.unit}</span>}
{obj.target !== undefined && (
<span>Target: {obj.target}</span>
)}
{obj.weight !== undefined && (
<span>Weight: {obj.weight}</span>
)}
</div>
</div>
))}
{config?.objectives.length === 0 && (
<p className="text-dark-500 text-sm italic">No objectives configured</p>
)}
<div className="text-xs text-dark-500 pt-2">
Type: {(config?.objectives.length || 0) > 1 ? 'Multi-Objective' : 'Single-Objective'}
</div>
</div>
)}
</Card>
{/* Design Variables Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('variables')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Sliders className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Design Variables</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.design_variables.length || 0}
</span>
</div>
{expandedSections.has('variables') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('variables') && (
<div className="px-4 pb-4 space-y-2">
{config?.design_variables.map((v, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white font-mono text-sm">{v.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{v.type}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400 mt-1">
{v.low !== undefined && v.high !== undefined && (
<span>Range: [{v.low}, {v.high}]</span>
)}
{v.step && <span>Step: {v.step}</span>}
{v.unit && <span>{v.unit}</span>}
{v.choices && (
<span>Choices: {v.choices.join(', ')}</span>
)}
</div>
</div>
))}
{config?.design_variables.length === 0 && (
<p className="text-dark-500 text-sm italic">No design variables configured</p>
)}
{designSpaceSize > 0 && (
<div className="text-xs text-dark-500 pt-2">
Design Space: ~{designSpaceSize.toExponential(2)} combinations
</div>
)}
</div>
)}
</Card>
{/* Constraints Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('constraints')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white">Constraints</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.constraints.length || 0}
</span>
</div>
{expandedSections.has('constraints') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('constraints') && (
<div className="px-4 pb-4">
{(config?.constraints.length || 0) > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-dark-400 text-left">
<th className="pb-2">Name</th>
<th className="pb-2">Type</th>
<th className="pb-2">Bound</th>
<th className="pb-2">Unit</th>
</tr>
</thead>
<tbody className="text-dark-300">
{config?.constraints.map((c, idx) => (
<tr key={idx} className="border-t border-dark-700">
<td className="py-2 font-mono">{c.name}</td>
<td className="py-2">
{c.type === 'le' ? '≤' : c.type === 'ge' ? '≥' : '='}
</td>
<td className="py-2">{c.bound}</td>
<td className="py-2 text-dark-500">{c.unit || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-dark-500 text-sm italic">No constraints configured</p>
)}
</div>
)}
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Algorithm Configuration */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('algorithm')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Algorithm Configuration</h2>
</div>
{expandedSections.has('algorithm') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('algorithm') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Optimizer</div>
<div className="text-white font-medium">{config?.algorithm.name || 'Optuna'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Sampler</div>
<div className="text-white font-medium">{config?.algorithm.sampler || 'TPE'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-white font-medium">{config?.algorithm.n_trials || selectedStudy.progress.total}</div>
</div>
{config?.algorithm.pruner && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Pruner</div>
<div className="text-white font-medium">{config.algorithm.pruner}</div>
</div>
)}
{config?.algorithm.timeout && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Timeout</div>
<div className="text-white font-medium">{config.algorithm.timeout}s</div>
</div>
)}
</div>
</div>
)}
</Card>
{/* FEA Model Info */}
{config?.fea_model && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('model')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">FEA Model</h2>
</div>
{expandedSections.has('model') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('model') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Software</div>
<div className="text-white font-medium">{config.fea_model.software}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Solver</div>
<div className="text-white font-medium">{config.fea_model.solver}</div>
</div>
{config.fea_model.mesh_elements && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Mesh Elements</div>
<div className="text-white font-medium">
{config.fea_model.mesh_elements.toLocaleString()}
</div>
</div>
)}
{config.fea_model.sim_file && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600 col-span-2">
<div className="text-xs text-dark-400 uppercase mb-1">Simulation File</div>
<div className="text-white font-mono text-sm truncate">
{config.fea_model.sim_file}
</div>
</div>
)}
</div>
</div>
)}
</Card>
)}
{/* NX Model Files */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('modelFiles')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<FileBox className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">NX Model Files</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{modelFiles.length}
</span>
</div>
{expandedSections.has('modelFiles') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('modelFiles') && (
<div className="px-4 pb-4 space-y-3">
{/* Open Folder Button */}
<button
onClick={handleOpenFolder}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 hover:text-white rounded-lg border border-dark-600 transition-colors"
>
<FolderOpen className="w-4 h-4" />
<span>Open Model Folder</span>
</button>
{/* Model Directory Path */}
{modelDir && (
<div className="text-xs text-dark-500 font-mono truncate" title={modelDir}>
{modelDir}
</div>
)}
{/* File List */}
{modelFiles.length > 0 ? (
<div className="space-y-2">
{modelFiles.map((file, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<File className={`w-4 h-4 ${
file.extension === '.prt' ? 'text-blue-400' :
file.extension === '.sim' ? 'text-green-400' :
file.extension === '.fem' ? 'text-yellow-400' :
file.extension === '.bdf' || file.extension === '.dat' ? 'text-orange-400' :
file.extension === '.op2' ? 'text-purple-400' :
'text-dark-400'
}`} />
<span className="font-medium text-white text-sm truncate" title={file.name}>
{file.name}
</span>
</div>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded uppercase">
{file.extension.slice(1)}
</span>
</div>
<div className="flex items-center justify-between mt-1 text-xs text-dark-500">
<span>{file.size_display}</span>
<span>{new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-dark-500 text-sm italic text-center py-4">
No model files found
</p>
)}
{/* File Type Legend */}
{modelFiles.length > 0 && (
<div className="pt-2 border-t border-dark-700">
<div className="flex flex-wrap gap-3 text-xs text-dark-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-blue-400 rounded-full"></span>.prt = Part</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-green-400 rounded-full"></span>.sim = Simulation</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-yellow-400 rounded-full"></span>.fem = FEM</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-orange-400 rounded-full"></span>.bdf = Nastran</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-purple-400 rounded-full"></span>.op2 = Results</span>
</div>
</div>
)}
</div>
)}
</Card>
{/* Extractors */}
{config?.extractors && config.extractors.length > 0 && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('extractors')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Layers className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Extractors</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config.extractors.length}
</span>
</div>
{expandedSections.has('extractors') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('extractors') && (
<div className="px-4 pb-4 space-y-2">
{config.extractors.map((ext, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white">{ext.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{ext.type}
</span>
</div>
{ext.source && (
<div className="text-xs text-dark-500 mt-1 font-mono">{ext.source}</div>
)}
</div>
))}
</div>
)}
</Card>
)}
{/* Study Stats */}
<Card title="Current Progress">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-white">
{selectedStudy.progress.current}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Trials Completed</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-primary-400">
{selectedStudy.best_value?.toExponential(3) || 'N/A'}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Best Value</div>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-dark-400">Progress</span>
<span className="text-white">
{Math.round((selectedStudy.progress.current / selectedStudy.progress.total) * 100)}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{
width: `${Math.min((selectedStudy.progress.current / selectedStudy.progress.total) * 100, 100)}%`
}}
/>
</div>
</div>
{/* Status Badge */}
<div className="mt-4 flex items-center justify-center">
<span className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
selectedStudy.status === 'running' ? 'bg-green-500/10 text-green-400' :
selectedStudy.status === 'completed' ? 'bg-blue-500/10 text-blue-400' :
selectedStudy.status === 'paused' ? 'bg-orange-500/10 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{selectedStudy.status === 'completed' && <CheckCircle className="w-4 h-4" />}
{selectedStudy.status === 'running' && <Play className="w-4 h-4" />}
<span className="capitalize">{selectedStudy.status.replace('_', ' ')}</span>
</span>
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
export interface Study {
id: string;
name: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;
@@ -96,6 +96,14 @@ export interface ProgressMessage {
export interface TrialPrunedMessage extends PrunedTrial {}
// Objective type
export interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
weight?: number;
}
// Chart data types
export interface ConvergenceDataPoint {
trial_number: number;
@@ -114,7 +122,7 @@ export interface ParameterSpaceDataPoint {
// Study status types
export interface StudyStatus {
study_id: string;
status: 'not_started' | 'running' | 'completed';
status: 'not_started' | 'running' | 'paused' | 'completed';
progress: {
current: number;
total: number;

View File

@@ -10,7 +10,7 @@ export default defineConfig({
strictPort: false, // Allow fallback to next available port
proxy: {
'/api': {
target: 'http://127.0.0.1:8001', // Use 127.0.0.1 instead of localhost
target: 'http://127.0.0.1:8000', // Use 127.0.0.1 instead of localhost
changeOrigin: true,
secure: false,
ws: true,

View File

@@ -14,7 +14,7 @@ import os
# NX Installation Directory
# Change this to update NX version across entire Atomizer codebase
NX_VERSION = "2412"
NX_VERSION = "2506"
NX_INSTALLATION_DIR = Path(f"C:/Program Files/Siemens/NX{NX_VERSION}")
# Derived NX Paths (automatically updated when NX_VERSION changes)

View File

@@ -1,6 +1,6 @@
# Atomizer Dashboard
**Last Updated**: December 3, 2025
**Last Updated**: December 5, 2025
---
@@ -127,6 +127,91 @@ Full-featured markdown report viewer:
- **Pruning diagnostics**: Tracks pruned trial params and causes
- **Database query**: Uses SQLite `state = 'PRUNED'` filter
### 8. Analytics Page (Cross-Study Comparisons)
**File**: `atomizer-dashboard/frontend/src/pages/Analytics.tsx`
Dedicated analytics page for comparing optimization studies:
#### Aggregate Statistics
- **Total Studies**: Count of all studies in the system
- **Running/Paused/Completed**: Status distribution breakdown
- **Total Trials**: Sum of trials across all studies
- **Avg Trials/Study**: Average trial count per study
- **Best Overall**: Best objective value across all studies with study ID
#### Study Comparison Table
- **Sortable columns**: Name, Status, Progress, Best Value
- **Status indicators**: Color-coded badges (running=green, paused=orange, completed=blue)
- **Progress bars**: Visual completion percentage with color coding
- **Quick actions**: Open button to navigate directly to a study's dashboard
- **Selected highlight**: Current study highlighted with "Selected" badge
- **Click-to-expand**: Row expansion for additional details
#### Status Distribution Chart
- Visual breakdown of studies by status
- Horizontal bar chart with percentage fill
- Color-coded: Running (green), Paused (orange), Completed (blue), Not Started (gray)
#### Top Performers Panel
- Ranking of top 5 studies by best objective value (assumes minimization)
- Medal-style numbering (gold, silver, bronze for top 3)
- Clickable rows to navigate to study
- Trial count display
**Usage**: Navigate to `/analytics` when a study is selected. Provides aggregate view across all studies.
### 9. Global Claude Terminal
**Files**:
- `atomizer-dashboard/frontend/src/components/GlobalClaudeTerminal.tsx`
- `atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx`
- `atomizer-dashboard/frontend/src/context/ClaudeTerminalContext.tsx`
Persistent AI assistant terminal:
- **Global persistence**: Terminal persists across page navigation
- **WebSocket connection**: Real-time communication with Claude Code backend
- **Context awareness**: Automatically includes current study context when available
- **New Study mode**: When no study selected, offers guided study creation wizard
- **Visual indicators**: Connection status shown in sidebar footer
- **Keyboard shortcut**: Open/close terminal from anywhere
**Modes**:
- **With Study Selected**: "Set Context" button loads study-specific context
- **No Study Selected**: "New Study" button starts guided wizard from `.claude/skills/guided-study-wizard.md`
### 10. Shared Markdown Renderer
**File**: `atomizer-dashboard/frontend/src/components/MarkdownRenderer.tsx`
Reusable markdown rendering component:
- **Syntax highlighting**: Prism-based code highlighting with `oneDark` theme
- **GitHub-flavored markdown**: Tables, task lists, strikethrough
- **LaTeX math support**: KaTeX rendering with `remark-math` and `rehype-katex`
- **Custom styling**: Dark theme typography optimized for dashboard
- **Used by**: Home page (README display), Results page (reports)
---
## Pages Structure
### Home Page (`/`)
- Study navigator and selector
- README.md display with full markdown rendering
- New study creation via Claude terminal
### Dashboard Page (`/dashboard`)
- Real-time live tracker for selected study
- Convergence plot, Pareto front, parameter importance
- Trial history table
### Reports Page (`/results`)
- AI-generated optimization report viewer
- Full markdown rendering with syntax highlighting and math
- Copy and download capabilities
### Analytics Page (`/analytics`)
- Cross-study comparison and aggregate statistics
- Study ranking and status distribution
- Quick navigation to individual studies
---
## API Endpoints
@@ -316,13 +401,26 @@ atomizer-dashboard/
│ │ │ ├── ConvergencePlot.tsx # Enhanced convergence chart
│ │ │ ├── ParameterImportanceChart.tsx # Correlation-based importance
│ │ │ ├── StudyReportViewer.tsx # Markdown report viewer
│ │ │ ├── MarkdownRenderer.tsx # Shared markdown renderer
│ │ │ ├── ClaudeTerminal.tsx # Claude AI terminal component
│ │ │ ├── GlobalClaudeTerminal.tsx # Global terminal wrapper
│ │ │ ├── common/
│ │ │ │ ── Card.tsx # Reusable card component
│ │ │ │ ── Card.tsx # Reusable card component
│ │ │ │ └── Button.tsx # Reusable button component
│ │ │ ├── layout/
│ │ │ │ ├── Sidebar.tsx # Navigation sidebar
│ │ │ │ └── MainLayout.tsx # Page layout wrapper
│ │ │ └── dashboard/
│ │ │ ├── MetricCard.tsx # KPI display
│ │ │ └── StudyCard.tsx # Study selector
│ │ ├── pages/
│ │ │ ── Dashboard.tsx # Main dashboard page
│ │ │ ── Home.tsx # Study selection & README
│ │ │ ├── Dashboard.tsx # Live optimization tracker
│ │ │ ├── Results.tsx # Report viewer
│ │ │ └── Analytics.tsx # Cross-study analytics
│ │ ├── context/
│ │ │ ├── StudyContext.tsx # Global study state
│ │ │ └── ClaudeTerminalContext.tsx # Terminal state
│ │ ├── hooks/
│ │ │ └── useWebSocket.ts # WebSocket connection
│ │ ├── api/
@@ -334,7 +432,8 @@ atomizer-dashboard/
└── api/
├── main.py # FastAPI app
└── routes/
── optimization.py # Optimization endpoints
── optimization.py # Optimization endpoints
└── terminal.py # Claude terminal WebSocket
```
## NPM Dependencies
@@ -415,6 +514,12 @@ if (!objectives || !designVariables) return <EmptyState />;
- [x] **Study Report Viewer**: Full markdown rendering with KaTeX math support
- [x] **Pruned Trials**: Real-time count from Optuna database (not JSON file)
- [x] **Chart Data Transformation**: Fixed `values` array mapping for single/multi-objective
- [x] **Analytics Page**: Dedicated cross-study comparison and aggregate statistics view
- [x] **Global Claude Terminal**: Persistent AI terminal with study context awareness
- [x] **Shared Markdown Renderer**: Reusable component with syntax highlighting and math support
- [x] **Study Session Persistence**: localStorage-based study selection that survives page refresh
- [x] **Paused Status Support**: Full support for paused optimization status throughout UI
- [x] **Guided Study Wizard**: Interactive wizard skill for creating new studies via Claude
### Future Enhancements
@@ -422,7 +527,6 @@ if (!objectives || !designVariables) return <EmptyState />;
- [ ] Advanced filtering and search in trial history
- [ ] Export results to CSV/JSON
- [ ] Custom parallel coordinates brushing/filtering
- [ ] Multi-study comparison view
- [ ] Hypervolume indicator tracking
- [ ] Interactive design variable sliders
- [ ] Constraint importance analysis

1027
docs/generated/EXTRACTORS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
## Extractor Quick Reference
| Physics | Extractor | Function Call |
|---------|-----------|---------------|
| Reaction forces | extract_spc_forces | `extract_spc_forces(op2_file, subcase)` |
| Reaction forces | extract_total_reaction_force | `extract_total_reaction_force(op2_file, subcase)` |
| Reaction forces | extract_reaction_component | `extract_reaction_component(op2_file, component)` |
| Reaction forces | check_force_equilibrium | `check_force_equilibrium(op2_file, applied_load)` |
| Displacement | extract_part_material | `extract_part_material(prt_file, properties_file)` |
| Displacement | extract_frequencies | `extract_frequencies(f06_file, n_modes)` |
| Mass | extract_part_mass_material | `extract_part_mass_material(prt_file, properties_file)` |
| Mass | extract_part_mass | `extract_part_mass(prt_file, properties_file)` |
| Natural frequency | extract_modal_mass | `extract_modal_mass(f06_file, mode)` |
| Natural frequency | get_first_frequency | `get_first_frequency(f06_file)` |
| Natural frequency | get_modal_mass_ratio | `get_modal_mass_ratio(f06_file, direction)` |
| Zernike WFE | extract_zernike_from_op2 | `extract_zernike_from_op2(op2_file, bdf_file)` |
| Zernike WFE | extract_zernike_filtered_rms | `extract_zernike_filtered_rms(op2_file, bdf_file)` |
| Zernike WFE | extract_zernike_relative_rms | `extract_zernike_relative_rms(op2_file, target_subcase)` |
| Strain energy | extract_strain_energy | `extract_strain_energy(op2_file, subcase)` |
| Strain energy | extract_total_strain_energy | `extract_total_strain_energy(op2_file, subcase)` |
| Strain energy | extract_strain_energy_density | `extract_strain_energy_density(op2_file, subcase)` |
| Von Mises stress | extract_solid_stress | `extract_solid_stress(op2_file, subcase)` |
| Von Mises stress | extract_principal_stress | `extract_principal_stress(op2_file, subcase)` |
| Von Mises stress | extract_max_principal_stress | `extract_max_principal_stress(op2_file, subcase)` |
| Von Mises stress | extract_min_principal_stress | `extract_min_principal_stress(op2_file, subcase)` |
| Temperature | extract_temperature | `extract_temperature(op2_file, subcase)` |
| Temperature | extract_temperature_gradient | `extract_temperature_gradient(op2_file, subcase)` |
| Temperature | extract_heat_flux | `extract_heat_flux(op2_file, subcase)` |
| Temperature | get_max_temperature | `get_max_temperature(op2_file, subcase)` |

187
docs/generated/TEMPLATES.md Normal file
View File

@@ -0,0 +1,187 @@
# Atomizer Study Templates
*Auto-generated: 2025-12-07 12:53*
Available templates for quick study creation.
---
## Template Reference
| Template | Objectives | Extractors |
|----------|------------|------------|
| `Multi-Objective Structural` | mass, stress, stiffness | E1, E3, E4 |
| `Frequency Optimization` | frequency, mass | E2, E4 |
| `Mass Minimization` | mass | E1, E3, E4 |
| `Mirror Wavefront Optimization` | zernike_rms | E8, E9, E10 |
| `Thermal-Structural Coupled` | max_temperature, thermal_stress | E3, E15, E16 |
| `Shell Structure Optimization` | mass, stress | E1, E3, E4 |
---
## Multi-Objective Structural
**Description**: NSGA-II optimization for structural analysis with mass, stress, and stiffness objectives
**Category**: structural
**Solver**: SOL 101
**Sampler**: NSGAIISampler
**Turbo Suitable**: Yes
**Example Study**: `bracket_pareto_3obj`
**Objectives**:
- mass (minimize) - Extractor: E4
- stress (minimize) - Extractor: E3
- stiffness (maximize) - Extractor: E1
**Extractors Used**:
- E1
- E3
- E4
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 20
- full: 50
- comprehensive: 100
---
## Frequency Optimization
**Description**: Maximize natural frequency while minimizing mass for vibration-sensitive structures
**Category**: dynamics
**Solver**: SOL 103
**Sampler**: NSGAIISampler
**Turbo Suitable**: Yes
**Example Study**: `uav_arm_optimization`
**Objectives**:
- frequency (maximize) - Extractor: E2
- mass (minimize) - Extractor: E4
**Extractors Used**:
- E2
- E4
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 20
- full: 50
---
## Mass Minimization
**Description**: Minimize mass subject to stress and displacement constraints
**Category**: structural
**Solver**: SOL 101
**Sampler**: TPESampler
**Turbo Suitable**: Yes
**Example Study**: `bracket_stiffness_optimization_V3`
**Objectives**:
- mass (minimize) - Extractor: E4
**Extractors Used**:
- E1
- E3
- E4
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 30
- full: 100
---
## Mirror Wavefront Optimization
**Description**: Minimize Zernike wavefront error for optical mirror deformation
**Category**: optics
**Solver**: SOL 101
**Sampler**: TPESampler
**Turbo Suitable**: No
**Example Study**: `m1_mirror_zernike_optimization`
**Objectives**:
- zernike_rms (minimize) - Extractor: E8
**Extractors Used**:
- E8
- E9
- E10
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 30
- full: 100
---
## Thermal-Structural Coupled
**Description**: Optimize for thermal and structural performance
**Category**: multiphysics
**Solver**: SOL 153/400
**Sampler**: NSGAIISampler
**Turbo Suitable**: No
**Example Study**: `None`
**Objectives**:
- max_temperature (minimize) - Extractor: E15
- thermal_stress (minimize) - Extractor: E3
**Extractors Used**:
- E3
- E15
- E16
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 20
- full: 50
---
## Shell Structure Optimization
**Description**: Optimize shell structures (CQUAD4/CTRIA3) for mass and stress
**Category**: structural
**Solver**: SOL 101
**Sampler**: NSGAIISampler
**Turbo Suitable**: Yes
**Example Study**: `beam_pareto_4var`
**Objectives**:
- mass (minimize) - Extractor: E4
- stress (minimize) - Extractor: E3
**Extractors Used**:
- E1
- E3
- E4
**Recommended Trials**:
- discovery: 1
- validation: 3
- quick: 20
- full: 50
---

View File

@@ -0,0 +1,717 @@
# Atomizer NX Open Automation Roadmap
## Plan de Match: Hooks, Extractors & Manipulators pour Optimisation FEA
**Date**: 2025-12-06
**Version**: 2.0 (merged with ATOMIZER_NXOPEN_MASTER_PLAN.md)
**Objectif**: Définir l'ensemble des fonctionnalités NX Open à implémenter pour un framework d'optimisation structurelle/thermique complet, basé sur la règle 80/20.
---
## Quick Reference: NX Open API Index
### Core Classes (verified via MCP)
| Class | Page ID | Primary Use |
|-------|---------|-------------|
| `NXOpen.Session` | a03318.html | Session singleton, part access |
| `NXOpen.Part` | a02434.html | Part operations, expressions |
| `NXOpen.BasePart` | a00266.html | Base class, common methods |
| `NXOpen.CAE.CaeSession` | a10510.html | CAE session, utilities |
| `NXOpen.CAE.FemPart` | - | FEM part, mesh access |
| `NXOpen.CAE.SimPart` | - | Simulation part, solutions |
### Key Collections & Managers (from NXOpen.Part)
| Manager | Access | Purpose |
|---------|--------|---------|
| `Expressions` | `part.Expressions` | Expression management |
| `MeasureManager` | `part.MeasureManager()` | Mass properties |
| `Bodies` | `part.Bodies()` | Body collection |
| `Features` | `part.Features()` | Feature collection |
| `MaterialManager` | `part.MaterialManager()` | Material assignment |
### CAE Managers (from NXOpen.CAE.CaeSession)
| Manager | Access | Purpose |
|---------|--------|---------|
| `MaterialUtils` | `cae_session.MaterialUtils()` | CAE material utilities |
| `AssociationUtils` | `cae_session.AssociationUtils()` | Geometry-FEM association |
| `PenetrationCheckManager` | `cae_session.PenetrationCheckManager()` | Contact check |
---
## 1. Analyse de l'Industrie MDO
### Fonctionnalités Standard (ce que font les concurrents)
| Fonctionnalité | HyperStudy | modeFRONTIER | HEEDS | OpenMDAO | Atomizer (actuel) |
|----------------|------------|--------------|-------|----------|-------------------|
| DOE (LHS, Sobol, etc.) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Optimisation mono-objectif | ✓ | ✓ | ✓ | ✓ | ✓ |
| Multi-objectif (Pareto) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Surrogate Models | ✓ | ✓ | ✓ | ✓ | ✓ (NN) |
| Kriging/Gaussian Process | ✓ | ✓ | ✓ | ✓ | ❌ |
| Robustesse/Fiabilité (RBDO) | ✓ | ✓ | ❌ | ✓ | ❌ |
| Sensibilité paramétrique | ✓ | ✓ | ✓ | ✓ | Partiel |
| Topology Optimization | ✓ | ❌ | ❌ | ❌ | ❌ |
| Workflow visuel | ✓ | ✓ | ✓ | ❌ | ❌ |
| Interface NX native | ❌ | ❌ | ✓ | ❌ | ✓ |
### Sources
- [Altair HyperStudy](https://altair.com/hyperstudy/)
- [modeFRONTIER](https://engineering.esteco.com/modefrontier/)
- [OpenMDAO](https://openmdao.org/)
- [M4 Engineering NXOpen Example](https://www.m4-engineering.com/automating-load-case-combination-and-enveloping-in-simcenter-3d-using-nxopen-python/)
---
## 2. Capacités Simcenter 13500
### Modules Disponibles avec ta Licence
| Module | Description | Utilisable pour Optimisation |
|--------|-------------|------------------------------|
| **NX Nastran Basic** | Static, Modal, Buckling | ✓ Priorité haute |
| **NX Nastran Dynamic** | Frequency/Transient Response | ✓ Priorité moyenne |
| **NX Nastran Thermal** | Heat Transfer (steady/transient) | ✓ Priorité haute |
| **NX Nastran Optimization** | SOL 200 (taille, forme) | ✓ Priorité moyenne |
| **Simcenter 3D Pre/Post** | Meshing, Results | ✓ Indispensable |
### Types d'Analyses Supportées
1. **Structurel Linéaire** (SOL 101)
- Static stress/displacement
- Reaction forces
2. **Modal** (SOL 103)
- Natural frequencies
- Mode shapes
- Modal effective mass
3. **Buckling** (SOL 105)
- Critical load factors
- Buckling mode shapes
4. **Thermique** (SOL 153/159)
- Steady-state heat transfer
- Transient thermal
- Thermal stress coupling
5. **Dynamique** (SOL 108/109/111/112)
- Frequency response
- Transient response
- Random response
---
## 3. Architecture des Hooks NX Open
### 3.1 Hooks de Manipulation CAD (Priorité 1)
```
optimization_engine/
└── hooks/
└── nx_cad/
├── __init__.py
├── part_manager.py # Open/Save/Close parts
├── expression_manager.py # Get/Set expressions
├── feature_manager.py # Suppress/Unsuppress features
├── geometry_query.py # Query geometry (mass, volume, area)
└── assembly_manager.py # Component positioning
```
| Hook | Description | API NX Open | Priorité |
|------|-------------|-------------|----------|
| `open_part(path)` | Ouvrir une pièce | `Session.Parts.OpenBase()` | P1 |
| `close_part(save=False)` | Fermer une pièce | `Part.Close()` | P1 |
| `set_expression(name, value)` | Modifier expression | `Expression.SetValue()` | P1 |
| `get_expression(name)` | Lire expression | `Expression.Value` | P1 |
| `update_model()` | Mettre à jour le modèle | `Session.UpdateManager.DoUpdate()` | P1 |
| `suppress_feature(name)` | Supprimer feature | `Feature.Suppress()` | P2 |
| `get_mass_properties()` | Masse, CG, inertie | `MeasureManager.NewMassProperties()` | P1 |
| `export_parasolid(path)` | Exporter géométrie | `Part.SaveAs()` | P2 |
### 3.2 Hooks FEM/Meshing (Priorité 2)
```
optimization_engine/
└── hooks/
└── nx_fem/
├── __init__.py
├── mesh_manager.py # Create/Update mesh
├── material_manager.py # Assign materials
├── property_manager.py # Shell/Solid properties
├── boundary_conditions.py # Loads & constraints
└── connection_manager.py # Connectors, contacts
```
| Hook | Description | API NX Open | Priorité |
|------|-------------|-------------|----------|
| `create_tet_mesh(body, size)` | Mailler en tétra | `MeshManager.CreateMesh3d()` | P1 |
| `update_mesh()` | Régénérer maillage | `FEModel.UpdateMesh()` | P1 |
| `set_material(mesh, mat_name)` | Assigner matériau | `PhysicalProperty.SetMaterial()` | P1 |
| `create_shell_property(t)` | Propriété shell | `PhysicalPropertyCollection.CreateShellProperty()` | P2 |
| `apply_force(nodes, vector)` | Appliquer force | `LoadCollection.CreateForce()` | P1 |
| `apply_constraint(nodes, dof)` | Appliquer contrainte | `ConstraintCollection.CreateConstraint()` | P1 |
| `create_contact(faces1, faces2)` | Contact surfaces | `ConnectionCollection.CreateSurfaceContact()` | P2 |
### 3.3 Hooks Simulation/Solve (Priorité 1)
```
optimization_engine/
└── hooks/
└── nx_sim/
├── __init__.py
├── solution_manager.py # Create/Run solutions
├── solve_manager.py # Submit solver jobs
└── result_manager.py # Access results
```
| Hook | Description | API NX Open | Priorité |
|------|-------------|-------------|----------|
| `create_solution(type, name)` | Créer solution | `SimSolutionCollection.CreateSolution()` | P1 |
| `solve(solution)` | Lancer solveur | `SimSolution.Solve()` | P1 |
| `solve_batch(bdf_path)` | Nastran en batch | `subprocess` + run_nastran | P1 |
| `get_solve_status()` | Statut du solve | `SimSolution.SolveStatus` | P1 |
| `export_bdf(path)` | Exporter deck Nastran | `SimSolution.ExportSolver()` | P1 |
---
## 4. Architecture des Extractors
### 4.0 Current Implementation Status (as of 2025-12-06)
```
optimization_engine/extractors/
├── __init__.py
├── extract_displacement.py # ✓ extract_displacement()
├── extract_von_mises_stress.py # ✓ extract_solid_stress()
├── extract_frequency.py # ✓ extract_frequency()
├── extract_mass.py # ✓ extract_generic()
├── extract_mass_from_bdf.py # ✓ extract_mass_from_bdf()
├── extract_mass_from_expression.py # ✓ extract_mass_from_expression()
├── extract_part_mass_material.py # ✓ PartMassExtractor (NX Open via journal)
├── bdf_mass_extractor.py # ✓ BDFMassExtractor class
├── op2_extractor.py # ✓ OP2Extractor class (mass, grid forces, loads)
├── field_data_extractor.py # ✓ FieldDataExtractor class
├── extract_zernike.py # ✓ ZernikeExtractor class (advanced)
├── extract_zernike_surface.py # ✓ SurfaceZernikeExtractor class
└── zernike_helpers.py # ✓ Helper functions
```
### 4.1 Extractors Structurels (Priorité 1)
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `extract_displacement(op2, subcase)` | mm | OP2 | P1 | ✓ | extract_displacement.py |
| `extract_solid_stress(op2, subcase, elem_type)` | MPa | OP2 | P1 | ✓ | extract_von_mises_stress.py |
| `extract_mass_from_bdf(bdf)` | kg | BDF | P1 | ✓ | bdf_mass_extractor.py |
| `extract_mass_from_op2(op2)` | kg | OP2 | P1 | ✓ | op2_extractor.py |
| `extract_grid_point_forces(op2)` | N | OP2 | P1 | ✓ | op2_extractor.py |
| `extract_displacement_field(op2)` | [mm] | OP2 | P1 | ✓ | field_data_extractor.py |
| `extract_principal_stress(elem)` | MPa | OP2 | P2 | ❌ | - |
| `extract_strain(elem)` | - | OP2 | P2 | ❌ | - |
| `extract_strain_energy(elem)` | J | OP2 | P2 | ❌ | - |
### 4.2 Extractors Modaux (Priorité 2)
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `extract_frequency(op2, subcase, mode)` | Hz | OP2 | P1 | ✓ | extract_frequency.py |
| `extract_modal_mass(mode)` | kg | F06 | P2 | ❌ | - |
| `extract_mode_shape(mode, nodes)` | [mm] | OP2 | P3 | ❌ | - |
| `extract_mac_matrix(modes)` | [0-1] | Calc | P3 | ❌ | - |
### 4.3 Extractors Thermiques (Priorité 2)
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `extract_temperature(node)` | °C/K | OP2/F06 | P2 | ❌ | - |
| `extract_max_temperature()` | °C/K | OP2/F06 | P2 | ❌ | - |
| `extract_heat_flux(elem)` | W/m² | OP2/F06 | P2 | ❌ | - |
| `extract_thermal_stress(elem)` | MPa | OP2/F06 | P2 | ❌ | - |
### 4.4 Extractors Géométriques (CAD) (Priorité 1) - NX Open
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `extract_part_mass_material(prt)` | kg, material | NX Open | P1 | ✓ | extract_part_mass_material.py |
| `extract_part_mass(prt)` | kg | NX Open | P1 | ✓ | extract_part_mass_material.py |
| `extract_part_material(prt)` | string | NX Open | P1 | ✓ | extract_part_mass_material.py |
| `extract_mass_from_expression(prt)` | kg | NX Open | P1 | ✓ | extract_mass_from_expression.py |
| `extract_volume()` | mm³ | NX Open | P2 | ❌ | - |
| `extract_surface_area()` | mm² | NX Open | P2 | ❌ | - |
| `extract_center_of_gravity()` | [mm] | NX Open | P2 | ❌ | - |
| `extract_inertia_tensor()` | kg·mm² | NX Open | P3 | ❌ | - |
**NX Open APIs for CAD Extraction**:
- `part.MeasureManager()` - Main entry point for mass properties
- `MeasureManager.NewMassProperties()` - Create mass measurement
- `MasProperties.Mass`, `.CenterOfGravity`, `.MomentsOfInertia`
### 4.5 Extractors Buckling (Priorité 3)
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `extract_buckling_factor(mode)` | - | F06 | P3 | ❌ | - |
| `extract_critical_load()` | N | F06 | P3 | ❌ | - |
### 4.6 Extractors Zernike (Spécialisé Optique) ✓
| Extractor | Output | Source | Priorité | Status | File |
|-----------|--------|--------|----------|--------|------|
| `ZernikeExtractor.extract_subcase()` | coeffs | OP2 | P1 | ✓ | extract_zernike.py |
| `ZernikeExtractor.extract_relative()` | delta_coeffs | OP2 | P1 | ✓ | extract_zernike.py |
| `extract_zernike_from_op2()` | coeffs | OP2 | P1 | ✓ | extract_zernike.py |
| `extract_zernike_filtered_rms()` | RMS(nm) | OP2 | P1 | ✓ | extract_zernike.py |
| `SurfaceZernikeExtractor.extract_from_op2()` | coeffs | OP2 | P1 | ✓ | extract_zernike_surface.py |
**Usage**: Mirror/lens deformation optimization using Zernike polynomial decomposition
---
## 5. Manipulateurs Avancés
### 5.1 Manipulateurs de Forme (Shape Optimization)
```
optimization_engine/
└── manipulators/
├── morpher.py # Morphing mesh/géométrie
├── ffd.py # Free-Form Deformation
└── surface_offset.py # Offset surfaces
```
| Manipulator | Description | Priorité |
|-------------|-------------|----------|
| `morph_nodes(nodes, displacements)` | Morphing direct | P3 |
| `ffd_box(control_points)` | Déformation FFD | P3 |
| `offset_faces(faces, distance)` | Offset paramétrique | P2 |
### 5.2 Manipulateurs Topologiques (Future)
| Manipulator | Description | Priorité |
|-------------|-------------|----------|
| `apply_density_filter(elements)` | SIMP filtering | P4 |
| `extract_iso_surface(density)` | Topology to geometry | P4 |
| `create_lattice_infill(region)` | Lattice génération | P4 |
---
## 6. Plan d'Implémentation 80/20
### Phase 1: Fondations ✓ COMPLETED
**Objectif**: Stabiliser le workflow de base
| # | Tâche | Fichier | Status |
|---|-------|---------|--------|
| 1.1 | Expression manipulation via .exp file | `nx_updater.py` | ✓ |
| 1.2 | Mass extraction from BDF | `bdf_mass_extractor.py` | ✓ |
| 1.3 | Mass extraction from NX Open | `extract_part_mass_material.py` | ✓ |
| 1.4 | Displacement extraction | `extract_displacement.py` | ✓ |
| 1.5 | Von Mises stress extraction | `extract_von_mises_stress.py` | ✓ |
| 1.6 | Frequency extraction | `extract_frequency.py` | ✓ |
### Phase 1b: NX Open Hooks ✓ COMPLETED (2025-12-06)
**Objectif**: Create direct NX Open Python hooks for CAD/FEM operations
**Location**: `optimization_engine/hooks/nx_cad/`
| # | Tâche | API (verified via MCP) | Status | File |
|---|-------|------------------------|--------|------|
| 1b.1 | Hook: `open_part` / `close_part` / `save_part` | `Session.Parts.OpenBase()`, `Part.Close()`, `Part.Save()` | ✓ | part_manager.py |
| 1b.2 | Hook: `get_expression` / `set_expression` / `set_expressions` | `part.Expressions`, `Expressions.Edit()` | ✓ | expression_manager.py |
| 1b.3 | Hook: `update_model` (integrated) | `Session.UpdateManager.DoUpdate()` | ✓ | expression_manager.py |
| 1b.4 | Hook: `get_mass_properties` / `get_bodies` / `get_volume` | `MeasureManager.NewMassProperties()` | ✓ | geometry_query.py |
| 1b.5 | Hook: `suppress_feature` / `unsuppress_feature` | `Feature.Suppress()`, `Feature.Unsuppress()` | ✓ | feature_manager.py |
| 1b.6 | Hook: `save_part_as` (export) | `Part.SaveAs()` | ✓ | part_manager.py |
### Phase 2: Workflow Complet ✓ COMPLETED
**Objectif**: Automatiser le cycle CAD → FEM → Results
| # | Tâche | API / Fichier | Status |
|---|-------|---------------|--------|
| 2.1 | BDF export via NX Open | `solver_manager.export_bdf()` | ✓ |
| 2.2 | Batch solver launch | `subprocess` + NX run_solver | ✓ (external) |
| 2.3 | Principal stress extraction | `extract_principal_stress()` | ✓ |
| 2.4 | Strain energy extraction | `extract_strain_energy()` | ✓ |
| 2.5 | Reaction force extraction | `extract_spc_forces()` | ✓ |
| 2.6 | **Model Introspection** | `model_introspection.py` | ✓ |
**Files Created (2025-12-06):**
- `optimization_engine/hooks/nx_cae/solver_manager.py` - BDF export & solve hooks
- `optimization_engine/extractors/extract_principal_stress.py` - Principal stress (σ1, σ2, σ3)
- `optimization_engine/extractors/extract_strain_energy.py` - Element strain energy
- `optimization_engine/extractors/extract_spc_forces.py` - Reaction forces at BCs
- `optimization_engine/hooks/nx_cad/model_introspection.py` - **Comprehensive model introspection**
**Phase 2 Introspection Feature (2025-12-06):**
The model introspection module provides comprehensive extraction of:
- **Part (.prt)**: Expressions, bodies, mass properties, features, materials
- **Simulation (.sim)**: Solutions, boundary conditions, loads, materials, mesh info, output requests
- **Results (.op2)**: Available results (displacement, stress, strain, SPC forces, frequencies), subcases
Usage:
```python
from optimization_engine.hooks.nx_cad.model_introspection import (
introspect_part,
introspect_simulation,
introspect_op2,
introspect_study
)
# Introspect entire study
study_info = introspect_study("studies/my_study/")
```
### Phase 3: Multi-Physique ✓ COMPLETED (Core Extractors)
**Objectif**: Support thermique et dynamique
**Priority**: P1 (High) - Extends optimization to thermal/dynamic domains
| # | Tâche | API / Fichier | Status | Priority |
|---|-------|---------------|--------|----------|
| 3.1 | Temperature extraction | `extract_temperature.py` | ✓ | P1 |
| 3.2 | Thermal gradient extraction | `extract_temperature_gradient()` | ✓ | P1 |
| 3.3 | Thermal stress extraction | OP2 + thermal subcase | ✓ (via E3) | P1 |
| 3.4 | Modal mass extraction | `extract_modal_mass.py` | ✓ | P1 |
| 3.5 | Heat flux extraction | `extract_heat_flux()` | ✓ | P2 |
| 3.6 | Thermal BC setup hook | `NXOpen.CAE.LoadCollection` | ❌ | P2 |
| 3.7 | Thermo-mechanical coupling | Multi-step solve | ❌ | P3 |
**Files Created (2025-12-06):**
- `optimization_engine/extractors/extract_temperature.py` - Temperature, gradient, heat flux (E15-E17)
- `optimization_engine/extractors/extract_modal_mass.py` - Modal effective mass from F06 (E18)
- `optimization_engine/extractors/test_phase3_extractors.py` - Phase 3 test suite
**Phase 3 Implementation Guide:**
#### 3.1 Temperature Extraction
```python
# Target API (pyNastran)
from pyNastran.op2.op2 import read_op2
op2 = read_op2(op2_file)
temperatures = op2.temperatures # TEMP subcase results
def extract_temperature(op2_file, subcase=1, nodes=None):
"""Extract nodal temperatures from thermal analysis.
Returns:
dict: {
'max_temperature': float (°C or K),
'min_temperature': float,
'avg_temperature': float,
'temperatures': {node_id: temp, ...}
}
"""
```
#### 3.2 Thermal Gradient Extraction
```python
def extract_thermal_gradient(op2_file, subcase=1):
"""Extract temperature gradients from thermal analysis.
Returns:
dict: {
'max_gradient': float (K/mm),
'avg_gradient': float,
'gradient_location': int (element_id)
}
"""
```
#### 3.3 Thermal Stress Extraction
```python
def extract_thermal_stress(op2_file, subcase=1, element_type='ctetra'):
"""Extract stress from thermal-mechanical analysis.
Notes:
- Requires coupled thermal-structural solution
- Uses temperature field as load
Returns:
dict: Similar to extract_solid_stress but from thermal loading
"""
```
#### 3.4 Modal Mass Extraction
```python
def extract_modal_mass(f06_file, mode_number=1):
"""Extract modal effective mass from F06 file.
Returns:
dict: {
'modal_mass_x': float (kg),
'modal_mass_y': float,
'modal_mass_z': float,
'participation_factor': float
}
"""
```
**Expected Outputs After Phase 3:**
- New extractors: `extract_temperature.py`, `extract_thermal_gradient.py`, `extract_modal_mass.py`
- Updated protocol SYS_12: Add E15-E18 for thermal extractors
- New study templates: Thermal optimization, thermo-mechanical optimization
### Phase 4: AtomizerField Integration (from Master Plan)
**Objectif**: Neural network surrogate for field prediction
| # | Tâche | Description | Status |
|---|-------|-------------|--------|
| 4.1 | Mesh Graph Builder | GNN graph from FEM mesh | ❌ |
| 4.2 | Training Data Exporter | Mesh + BC + results package | ❌ |
| 4.3 | Field Mapper | GNN predictions → NX format | ❌ |
| 4.4 | Sample Validation | Check convergence/quality | ❌ |
### Phase 5: Surrogates & Advanced
**Objectif**: Kriging, topology, lattice
| # | Tâche | Status |
|---|-------|--------|
| 5.1 | Gaussian Process / Kriging surrogate | ❌ |
| 5.2 | SOL 200 interface (native topo) | ❌ |
| 5.3 | Lattice infill generation | ❌ |
| 5.4 | FFD morphing | ❌ |
---
## 7. Classes NX Open Clés (Verified via MCP)
### Session & Part Access
```python
import NXOpen
# Session singleton
session = NXOpen.Session.GetSession()
# Part access
work_part = session.Parts.Work # Current work part (Part object)
display_part = session.Parts.Display # Display part
all_parts = session.Parts # PartCollection
# Key Part methods (from a02434.html):
expressions = work_part.Expressions # ExpressionCollection
bodies = work_part.Bodies() # BodyCollection
features = work_part.Features() # FeatureCollection
measure_mgr = work_part.MeasureManager() # For mass properties
material_mgr = work_part.MaterialManager() # Material assignment
```
### Expression Manipulation
```python
# Find expression by name
expr = work_part.Expressions.FindObject("width")
# Get value
value = expr.Value # float
units = expr.Units # Unit object
# Set value (with units)
unit = work_part.UnitCollection.FindObject("MilliMeter")
work_part.Expressions.EditWithUnits(expr, unit, "50.0")
# Import from .exp file (batch update - robust method)
work_part.Expressions.ImportFromFile(
exp_path,
NXOpen.ExpressionCollection.ExportMode.Replace
)
# Update model after changes
session.UpdateManager.DoUpdate(session.SetUndoMark(...))
```
### Mass Properties
```python
# Create mass measurement
mass_props = work_part.MeasureManager().NewMassProperties(
accuracy, # int: 0.97-0.99 typical
infoUnits, # MassPropertiesInfo for units
bodies # Array of Body objects
)
# Get properties
mass = mass_props.Mass # float (kg)
cog = mass_props.CenterOfGravity # Point3d (x,y,z)
inertia = mass_props.MomentsOfInertia # Matrix3x3
volume = mass_props.Volume # float (mm³)
area = mass_props.SurfaceArea # float (mm²)
```
### CAE/FEM Access
```python
from NXOpen.CAE import CaeSession
# CAE session utilities (from a10510.html)
cae_session = session.CaeSession() # Returns CaeSession
# Key CaeSession methods:
material_utils = cae_session.MaterialUtils() # MaterialUtilities
association_utils = cae_session.AssociationUtils() # AssociationUtilities
mesh_mapping = cae_session.MeshMappingUtils() # MeshMapping.Utils
penetration_mgr = cae_session.PenetrationCheckManager() # PenetrationCheck.Manager
# FEM/SIM parts
fem_part = work_part # When .fem is work part
sim_part = work_part # When .sim is work part
# Access mesh (from FemPart)
mesh_manager = fem_part.MeshManager
for mesh in mesh_manager.GetMeshes():
nodes = mesh.GetNodes()
elements = mesh.GetElements()
# Access solutions (from SimPart)
sim_simulation = sim_part.FindObject("Simulation")
for solution in sim_simulation.Solutions:
name = solution.Name
sol_type = solution.SolutionType
solution.Solve() # Run solver
```
### Feature Manipulation
```python
# Get feature by name
feature = work_part.Features.FindObject("FEATURE_NAME")
# Suppress/unsuppress
feature.Suppress()
feature.Unsuppress()
# Get expression-linked features
for expr in feature.GetExpressions():
print(expr.Name, expr.Value)
```
---
## 8. Structure de Fichiers Finale
```
optimization_engine/
├── hooks/
│ ├── __init__.py
│ ├── nx_cad/
│ │ ├── __init__.py
│ │ ├── part_manager.py
│ │ ├── expression_manager.py
│ │ ├── feature_manager.py
│ │ ├── geometry_query.py
│ │ └── assembly_manager.py
│ ├── nx_fem/
│ │ ├── __init__.py
│ │ ├── mesh_manager.py
│ │ ├── material_manager.py
│ │ ├── property_manager.py
│ │ ├── boundary_conditions.py
│ │ └── connection_manager.py
│ └── nx_sim/
│ ├── __init__.py
│ ├── solution_manager.py
│ ├── solve_manager.py
│ └── result_manager.py
├── extractors/
│ ├── __init__.py
│ ├── displacement.py # ✓
│ ├── stress.py # À améliorer
│ ├── strain.py # Nouveau
│ ├── frequency.py # ✓
│ ├── modal_mass.py # Nouveau
│ ├── temperature.py # Nouveau
│ ├── reaction_force.py # Nouveau
│ ├── cad_mass.py # Nouveau (NX Open)
│ ├── cad_volume.py # Nouveau
│ └── zernike/ # ✓
└── manipulators/
├── __init__.py
├── morpher.py # Future
└── ffd.py # Future
```
---
## 9. Métriques de Succès
### Phase 1 Complete When: ✓ ACHIEVED
- [x] Peut ouvrir/fermer pièce NX via Python ✓ (part_manager.py)
- [x] Peut modifier expressions et update ✓ (expression_manager.py)
- [x] Peut extraire masse depuis CAD (pas BDF) ✓ (geometry_query.py)
- [x] Extrait stress max fiable de tous les éléments ✓ (extract_von_mises_stress.py)
### Phase 2 Complete When: ✓ ACHIEVED (2025-12-06)
- [x] Workflow complet: expression → BDF → solve → extract ✓
- [x] Support de toutes les contraintes mécaniques ✓ (principal stress, SPC forces)
- [x] Extraction de strain energy fonctionnelle ✓ (extract_strain_energy.py)
- [x] **Model Introspection**: Full model analysis capability ✓ (model_introspection.py)
### Phase 3 Complete When: ✓ CORE ACHIEVED (2025-12-06)
- [x] Temperature extraction from OP2 functional ✓ (extract_temperature.py)
- [x] Thermal gradient extraction functional ✓ (extract_temperature_gradient)
- [x] Heat flux extraction functional ✓ (extract_heat_flux)
- [x] Modal mass extraction from F06 functional ✓ (extract_modal_mass.py)
- [ ] At least one thermal optimization study runs successfully
- [ ] Thermo-mechanical coupling documented
- [ ] Thermal BC setup hook (NXOpen.CAE)
---
## 10. Références
### Documentation NX Open (via MCP)
**MCP Tools for NX Open Documentation**:
```
mcp__siemens-docs__nxopen_get_class(className) # Get class doc
mcp__siemens-docs__nxopen_get_index(indexType) # Browse class index
mcp__siemens-docs__nxopen_fetch_page(pagePath) # Fetch any page
mcp__siemens-docs__siemens_docs_list() # List available resources
```
**Key Page IDs**:
| Class | Page ID | URL Path |
|-------|---------|----------|
| Session | a03318.html | `/nxopen_python_ref/a03318.html` |
| Part | a02434.html | `/nxopen_python_ref/a02434.html` |
| BasePart | a00266.html | `/nxopen_python_ref/a00266.html` |
| CaeSession | a10510.html | `/nxopen_python_ref/a10510.html` |
| Class Index | classes.html | `/nxopen_python_ref/classes.html` |
| Function Index | functions_*.html | `/nxopen_python_ref/functions_m.html` (etc.) |
### Ressources Externes
- [NXJournaling.com](https://nxjournaling.com/) - NX Open examples and tutorials
- [NXOpen TSE](https://nxopentsedocumentation.thescriptingengineer.com/) - The Scripting Engineer docs
- [PyNastran Documentation](https://pynastran-git.readthedocs.io/) - OP2/BDF parsing
- [NAFEMS Python FEA Course](https://www.nafems.org/training/e-learning/python-for-fea-automation-and-optimization/)
### Papers & Academic
- Kriging for FEA Optimization: [Springer](https://link.springer.com/article/10.1007/s00158-019-02211-z)
- OpenMDAO Framework: [NASA Technical Report](https://openmdao.org/)
- SIMP Topology Optimization: Bendsøe & Sigmund methods
### Related Atomizer Documentation
- [ATOMIZER_NXOPEN_MASTER_PLAN.md](../07_DEVELOPMENT/ATOMIZER_NXOPEN_MASTER_PLAN.md) - Detailed API specs
- [SYS_12_EXTRACTOR_LIBRARY.md](../protocols/system/SYS_12_EXTRACTOR_LIBRARY.md) - Extractor catalog
- [MCP Server README](../../mcp-server/README.md) - Siemens docs proxy setup
---
## 11. Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-06 | Initial roadmap from industry research |
| 2.0 | 2025-12-06 | Merged with ATOMIZER_NXOPEN_MASTER_PLAN.md, added verified MCP API mappings, updated extractor status |
| 2.1 | 2025-12-06 | **Phase 1b Complete**: NX Open hooks implemented (part_manager, expression_manager, geometry_query, feature_manager) |
| 2.2 | 2025-12-06 | **Phase 2 Complete**: Principal stress, strain energy, SPC forces extractors + solver_manager |
| 2.3 | 2025-12-06 | **Model Introspection**: Added comprehensive model_introspection.py for full part/sim/op2 analysis |
| 2.4 | 2025-12-06 | **Phase 3 Prepared**: Detailed roadmap for thermal/dynamic extractors with implementation guide |
| 2.5 | 2025-12-06 | **Phase 3 Core Complete**: Temperature (E15-E17) and modal mass (E18) extractors implemented |
---
*Generated with assistance from Claude Code using MCP Siemens Documentation tools*

160
docs/protocols/README.md Normal file
View File

@@ -0,0 +1,160 @@
# Atomizer Protocol Operating System (POS)
**Version**: 1.0
**Last Updated**: 2025-12-05
---
## Overview
This directory contains the **Protocol Operating System (POS)** - a 4-layer documentation architecture optimized for LLM consumption.
---
## Directory Structure
```
protocols/
├── README.md # This file
├── operations/ # Layer 2: How-to guides
│ ├── OP_01_CREATE_STUDY.md
│ ├── OP_02_RUN_OPTIMIZATION.md
│ ├── OP_03_MONITOR_PROGRESS.md
│ ├── OP_04_ANALYZE_RESULTS.md
│ ├── OP_05_EXPORT_TRAINING_DATA.md
│ └── OP_06_TROUBLESHOOT.md
├── system/ # Layer 3: Core specifications
│ ├── SYS_10_IMSO.md
│ ├── SYS_11_MULTI_OBJECTIVE.md
│ ├── SYS_12_EXTRACTOR_LIBRARY.md
│ ├── SYS_13_DASHBOARD_TRACKING.md
│ └── SYS_14_NEURAL_ACCELERATION.md
└── extensions/ # Layer 4: Extensibility guides
├── EXT_01_CREATE_EXTRACTOR.md
├── EXT_02_CREATE_HOOK.md
├── EXT_03_CREATE_PROTOCOL.md
├── EXT_04_CREATE_SKILL.md
└── templates/
```
---
## Layer Descriptions
### Layer 1: Bootstrap (`.claude/skills/`)
Entry point for LLM sessions. Contains:
- `00_BOOTSTRAP.md` - Quick orientation and task routing
- `01_CHEATSHEET.md` - "I want X → Use Y" lookup
- `02_CONTEXT_LOADER.md` - What to load per task
- `PROTOCOL_EXECUTION.md` - Meta-protocol for execution
### Layer 2: Operations (`operations/`)
Day-to-day how-to guides:
- **OP_01**: Create optimization study
- **OP_02**: Run optimization
- **OP_03**: Monitor progress
- **OP_04**: Analyze results
- **OP_05**: Export training data
- **OP_06**: Troubleshoot issues
### Layer 3: System (`system/`)
Core technical specifications:
- **SYS_10**: Intelligent Multi-Strategy Optimization (IMSO)
- **SYS_11**: Multi-Objective Support (MANDATORY)
- **SYS_12**: Extractor Library
- **SYS_13**: Real-Time Dashboard Tracking
- **SYS_14**: Neural Network Acceleration
### Layer 4: Extensions (`extensions/`)
Guides for extending Atomizer:
- **EXT_01**: Create new extractor
- **EXT_02**: Create lifecycle hook
- **EXT_03**: Create new protocol
- **EXT_04**: Create new skill
---
## Protocol Template
All protocols follow this structure:
```markdown
# {LAYER}_{NUMBER}_{NAME}.md
<!--
PROTOCOL: {Full Name}
LAYER: {Operations|System|Extensions}
VERSION: {Major.Minor}
STATUS: {Active|Draft|Deprecated}
LAST_UPDATED: {YYYY-MM-DD}
PRIVILEGE: {user|power_user|admin}
LOAD_WITH: [{dependencies}]
-->
## Overview
{1-3 sentence description}
## When to Use
| Trigger | Action |
|---------|--------|
## Quick Reference
{Tables, key parameters}
## Detailed Specification
{Full content}
## Examples
{Working examples}
## Troubleshooting
| Symptom | Cause | Solution |
## Cross-References
- Depends On: []
- Used By: []
```
---
## Quick Navigation
### By Task
| I want to... | Protocol |
|--------------|----------|
| Create a study | [OP_01](operations/OP_01_CREATE_STUDY.md) |
| Run optimization | [OP_02](operations/OP_02_RUN_OPTIMIZATION.md) |
| Check progress | [OP_03](operations/OP_03_MONITOR_PROGRESS.md) |
| Analyze results | [OP_04](operations/OP_04_ANALYZE_RESULTS.md) |
| Export neural data | [OP_05](operations/OP_05_EXPORT_TRAINING_DATA.md) |
| Fix errors | [OP_06](operations/OP_06_TROUBLESHOOT.md) |
| Add extractor | [EXT_01](extensions/EXT_01_CREATE_EXTRACTOR.md) |
### By Protocol Number
| # | Name | Layer |
|---|------|-------|
| 10 | IMSO | [System](system/SYS_10_IMSO.md) |
| 11 | Multi-Objective | [System](system/SYS_11_MULTI_OBJECTIVE.md) |
| 12 | Extractors | [System](system/SYS_12_EXTRACTOR_LIBRARY.md) |
| 13 | Dashboard | [System](system/SYS_13_DASHBOARD_TRACKING.md) |
| 14 | Neural | [System](system/SYS_14_NEURAL_ACCELERATION.md) |
---
## Privilege Levels
| Level | Operations | System | Extensions |
|-------|------------|--------|------------|
| user | All OP_* | Read SYS_* | None |
| power_user | All OP_* | Read SYS_* | EXT_01, EXT_02 |
| admin | All | All | All |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial Protocol Operating System |

View File

@@ -0,0 +1,395 @@
# EXT_01: Create New Extractor
<!--
PROTOCOL: Create New Physics Extractor
LAYER: Extensions
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: power_user
LOAD_WITH: [SYS_12_EXTRACTOR_LIBRARY]
-->
## Overview
This protocol guides you through creating a new physics extractor for the centralized extractor library. Follow this when you need to extract results not covered by existing extractors.
**Privilege Required**: power_user or admin
---
## When to Use
| Trigger | Action |
|---------|--------|
| Need physics not in library | Follow this protocol |
| "create extractor", "new extractor" | Follow this protocol |
| Custom result extraction needed | Follow this protocol |
**First**: Check [SYS_12_EXTRACTOR_LIBRARY](../system/SYS_12_EXTRACTOR_LIBRARY.md) - the functionality may already exist!
---
## Quick Reference
**Create in**: `optimization_engine/extractors/`
**Export from**: `optimization_engine/extractors/__init__.py`
**Document in**: Update SYS_12 and this protocol
**Template location**: `docs/protocols/extensions/templates/extractor_template.py`
---
## Step-by-Step Guide
### Step 1: Verify Need
Before creating:
1. Check existing extractors in [SYS_12](../system/SYS_12_EXTRACTOR_LIBRARY.md)
2. Search codebase: `grep -r "your_physics" optimization_engine/`
3. Confirm no existing solution
### Step 1.5: Research NX Open APIs (REQUIRED for NX extractors)
**If the extractor needs NX Open APIs** (not just pyNastran OP2 parsing):
```
# 1. Search for relevant NX Open APIs
siemens_docs_search("inertia properties NXOpen")
siemens_docs_search("mass properties body NXOpen.CAE")
# 2. Fetch detailed documentation for promising classes
siemens_docs_fetch("NXOpen.MeasureManager")
siemens_docs_fetch("NXOpen.UF.UFWeight")
# 3. Get method signatures
siemens_docs_search("AskMassProperties NXOpen")
```
**When to use NX Open vs pyNastran:**
| Data Source | Tool | Example |
|-------------|------|---------|
| OP2 results (stress, disp, freq) | pyNastran | `extract_displacement()` |
| CAD properties (mass, inertia) | NX Open | New extractor with NXOpen API |
| BDF data (mesh, properties) | pyNastran | `extract_mass_from_bdf()` |
| NX expressions | NX Open | `extract_mass_from_expression()` |
| FEM model data | NX Open CAE | Needs `NXOpen.CAE.*` APIs |
**Document the APIs used** in the extractor docstring:
```python
def extract_inertia(part_file: Path) -> Dict[str, Any]:
"""
Extract mass and inertia properties from NX part.
NX Open APIs Used:
- NXOpen.MeasureManager.NewMassProperties()
- NXOpen.MeasureBodies.InformationUnit
- NXOpen.UF.UFWeight.AskProps()
See: docs.sw.siemens.com for full API reference
"""
```
### Step 2: Create Extractor File
Create `optimization_engine/extractors/extract_{physics}.py`:
```python
"""
Extract {Physics Name} from FEA results.
Author: {Your Name}
Created: {Date}
Version: 1.0
"""
from pathlib import Path
from typing import Dict, Any, Optional, Union
from pyNastran.op2.op2 import OP2
def extract_{physics}(
op2_file: Union[str, Path],
subcase: int = 1,
# Add other parameters as needed
) -> Dict[str, Any]:
"""
Extract {physics description} from OP2 file.
Args:
op2_file: Path to the OP2 results file
subcase: Subcase number to extract (default: 1)
Returns:
Dictionary containing:
- '{main_result}': The primary result value
- '{secondary}': Additional result info
- 'subcase': The subcase extracted
Raises:
FileNotFoundError: If OP2 file doesn't exist
KeyError: If subcase not found in results
ValueError: If result data is invalid
Example:
>>> result = extract_{physics}('model.op2', subcase=1)
>>> print(result['{main_result}'])
123.45
"""
op2_file = Path(op2_file)
if not op2_file.exists():
raise FileNotFoundError(f"OP2 file not found: {op2_file}")
# Read OP2 file
op2 = OP2()
op2.read_op2(str(op2_file))
# Extract your physics
# TODO: Implement extraction logic
# Example for displacement-like result:
if subcase not in op2.displacements:
raise KeyError(f"Subcase {subcase} not found in results")
data = op2.displacements[subcase]
# Process data...
return {
'{main_result}': computed_value,
'{secondary}': secondary_value,
'subcase': subcase,
}
# Optional: Class-based extractor for complex cases
class {Physics}Extractor:
"""
Class-based extractor for {physics} with state management.
Use when extraction requires multiple steps or configuration.
"""
def __init__(self, op2_file: Union[str, Path], **config):
self.op2_file = Path(op2_file)
self.config = config
self._op2 = None
def _load_op2(self):
"""Lazy load OP2 file."""
if self._op2 is None:
self._op2 = OP2()
self._op2.read_op2(str(self.op2_file))
return self._op2
def extract(self, subcase: int = 1) -> Dict[str, Any]:
"""Extract results for given subcase."""
op2 = self._load_op2()
# Implementation here
pass
```
### Step 3: Add to __init__.py
Edit `optimization_engine/extractors/__init__.py`:
```python
# Add import
from .extract_{physics} import extract_{physics}
# Or for class
from .extract_{physics} import {Physics}Extractor
# Add to __all__
__all__ = [
# ... existing exports ...
'extract_{physics}',
'{Physics}Extractor',
]
```
### Step 4: Write Tests
Create `tests/test_extract_{physics}.py`:
```python
"""Tests for {physics} extractor."""
import pytest
from pathlib import Path
from optimization_engine.extractors import extract_{physics}
class TestExtract{Physics}:
"""Test suite for {physics} extraction."""
@pytest.fixture
def sample_op2(self, tmp_path):
"""Create or copy sample OP2 for testing."""
# Either copy existing test file or create mock
pass
def test_basic_extraction(self, sample_op2):
"""Test basic extraction works."""
result = extract_{physics}(sample_op2)
assert '{main_result}' in result
assert isinstance(result['{main_result}'], float)
def test_file_not_found(self):
"""Test error handling for missing file."""
with pytest.raises(FileNotFoundError):
extract_{physics}('nonexistent.op2')
def test_invalid_subcase(self, sample_op2):
"""Test error handling for invalid subcase."""
with pytest.raises(KeyError):
extract_{physics}(sample_op2, subcase=999)
```
### Step 5: Document
#### Update SYS_12_EXTRACTOR_LIBRARY.md
Add to Quick Reference table:
```markdown
| E{N} | {Physics} | `extract_{physics}()` | .op2 | {unit} |
```
Add detailed section:
```markdown
### E{N}: {Physics} Extraction
**Module**: `optimization_engine.extractors.extract_{physics}`
\`\`\`python
from optimization_engine.extractors import extract_{physics}
result = extract_{physics}(op2_file, subcase=1)
{main_result} = result['{main_result}']
\`\`\`
```
#### Update skills/modules/extractors-catalog.md
Add entry following existing pattern.
### Step 6: Validate
```bash
# Run tests
pytest tests/test_extract_{physics}.py -v
# Test import
python -c "from optimization_engine.extractors import extract_{physics}; print('OK')"
# Test with real file
python -c "
from optimization_engine.extractors import extract_{physics}
result = extract_{physics}('path/to/test.op2')
print(result)
"
```
---
## Extractor Design Guidelines
### Do's
- Return dictionaries with clear keys
- Include metadata (subcase, units, etc.)
- Handle edge cases gracefully
- Provide clear error messages
- Document all parameters and returns
- Write tests
### Don'ts
- Don't re-parse OP2 multiple times in one call
- Don't hardcode paths
- Don't swallow exceptions silently
- Don't return raw pyNastran objects
- Don't modify input files
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| File | `extract_{physics}.py` | `extract_thermal.py` |
| Function | `extract_{physics}` | `extract_thermal` |
| Class | `{Physics}Extractor` | `ThermalExtractor` |
| Return key | lowercase_with_underscores | `max_temperature` |
---
## Examples
### Example: Thermal Gradient Extractor
```python
"""Extract thermal gradients from temperature results."""
from pathlib import Path
from typing import Dict, Any
from pyNastran.op2.op2 import OP2
import numpy as np
def extract_thermal_gradient(
op2_file: Path,
subcase: int = 1,
direction: str = 'magnitude'
) -> Dict[str, Any]:
"""
Extract thermal gradient from temperature field.
Args:
op2_file: Path to OP2 file
subcase: Subcase number
direction: 'magnitude', 'x', 'y', or 'z'
Returns:
Dictionary with gradient results
"""
op2 = OP2()
op2.read_op2(str(op2_file))
temps = op2.temperatures[subcase]
# Calculate gradient...
return {
'max_gradient': max_grad,
'mean_gradient': mean_grad,
'max_gradient_location': location,
'direction': direction,
'subcase': subcase,
'unit': 'K/mm'
}
```
---
## Troubleshooting
| Issue | Cause | Solution |
|-------|-------|----------|
| Import error | Not added to __init__.py | Add export |
| "No module" | Wrong file location | Check path |
| KeyError | Wrong OP2 data structure | Debug OP2 contents |
| Tests fail | Missing test data | Create fixtures |
---
## Cross-References
- **Reference**: [SYS_12_EXTRACTOR_LIBRARY](../system/SYS_12_EXTRACTOR_LIBRARY.md)
- **Template**: `templates/extractor_template.py`
- **Related**: [EXT_02_CREATE_HOOK](./EXT_02_CREATE_HOOK.md)
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,366 @@
# EXT_02: Create Lifecycle Hook
<!--
PROTOCOL: Create Lifecycle Hook Plugin
LAYER: Extensions
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: power_user
LOAD_WITH: []
-->
## Overview
This protocol guides you through creating lifecycle hooks that execute at specific points during optimization. Hooks enable custom logic injection without modifying core code.
**Privilege Required**: power_user or admin
---
## When to Use
| Trigger | Action |
|---------|--------|
| Need custom logic at specific point | Follow this protocol |
| "create hook", "callback" | Follow this protocol |
| Want to log/validate/modify at runtime | Follow this protocol |
---
## Quick Reference
**Hook Points Available**:
| Hook Point | When It Runs | Use Case |
|------------|--------------|----------|
| `pre_mesh` | Before meshing | Validate geometry |
| `post_mesh` | After meshing | Check mesh quality |
| `pre_solve` | Before solver | Log trial start |
| `post_solve` | After solver | Validate results |
| `post_extraction` | After extraction | Custom metrics |
| `post_calculation` | After objectives | Derived quantities |
| `custom_objective` | Custom objective | Complex objectives |
**Create in**: `optimization_engine/plugins/{hook_point}/`
---
## Step-by-Step Guide
### Step 1: Identify Hook Point
Choose the appropriate hook point:
```
Trial Flow:
├─► PRE_MESH → Validate model before meshing
├─► POST_MESH → Check mesh quality
├─► PRE_SOLVE → Log trial start, validate inputs
├─► POST_SOLVE → Check solve success, capture timing
├─► POST_EXTRACTION → Compute derived quantities
├─► POST_CALCULATION → Final validation, logging
└─► CUSTOM_OBJECTIVE → Custom objective functions
```
### Step 2: Create Hook File
Create `optimization_engine/plugins/{hook_point}/{hook_name}.py`:
```python
"""
{Hook Description}
Author: {Your Name}
Created: {Date}
Version: 1.0
Hook Point: {hook_point}
"""
from typing import Dict, Any
def {hook_name}_hook(context: Dict[str, Any]) -> Dict[str, Any]:
"""
{Description of what this hook does}.
Args:
context: Dictionary containing:
- trial_number: Current trial number
- design_params: Current design parameters
- results: Results so far (if post-extraction)
- config: Optimization config
- working_dir: Path to working directory
Returns:
Dictionary with computed values or modifications.
Return empty dict if no modifications needed.
Example:
>>> result = {hook_name}_hook({'trial_number': 1, ...})
>>> print(result)
{'{computed_key}': 123.45}
"""
# Access context
trial_num = context.get('trial_number')
design_params = context.get('design_params', {})
results = context.get('results', {})
# Your logic here
# ...
# Return computed values
return {
'{computed_key}': computed_value,
}
def register_hooks(hook_manager):
"""
Register this hook with the hook manager.
This function is called automatically when plugins are loaded.
Args:
hook_manager: The HookManager instance
"""
hook_manager.register_hook(
hook_point='{hook_point}',
function={hook_name}_hook,
name='{hook_name}_hook',
description='{Brief description}',
priority=100, # Lower = runs earlier
enabled=True
)
```
### Step 3: Test Hook
```python
# Test in isolation
from optimization_engine.plugins.{hook_point}.{hook_name} import {hook_name}_hook
test_context = {
'trial_number': 1,
'design_params': {'thickness': 5.0},
'results': {'max_stress': 200.0},
}
result = {hook_name}_hook(test_context)
print(result)
```
### Step 4: Enable Hook
Hooks are auto-discovered from the plugins directory. To verify:
```python
from optimization_engine.plugins.hook_manager import HookManager
manager = HookManager()
manager.discover_plugins()
print(manager.list_hooks())
```
---
## Hook Examples
### Example 1: Safety Factor Calculator (post_calculation)
```python
"""Calculate safety factor after stress extraction."""
def safety_factor_hook(context):
"""Calculate safety factor from stress results."""
results = context.get('results', {})
config = context.get('config', {})
max_stress = results.get('max_von_mises', 0)
yield_strength = config.get('material', {}).get('yield_strength', 250)
if max_stress > 0:
safety_factor = yield_strength / max_stress
else:
safety_factor = float('inf')
return {
'safety_factor': safety_factor,
'yield_strength': yield_strength,
}
def register_hooks(hook_manager):
hook_manager.register_hook(
hook_point='post_calculation',
function=safety_factor_hook,
name='safety_factor_hook',
description='Calculate safety factor from stress',
priority=100,
enabled=True
)
```
### Example 2: Trial Logger (pre_solve)
```python
"""Log trial information before solve."""
import json
from datetime import datetime
from pathlib import Path
def trial_logger_hook(context):
"""Log trial start information."""
trial_num = context.get('trial_number')
design_params = context.get('design_params', {})
working_dir = context.get('working_dir', Path('.'))
log_entry = {
'trial': trial_num,
'timestamp': datetime.now().isoformat(),
'params': design_params,
}
log_file = working_dir / 'trial_log.jsonl'
with open(log_file, 'a') as f:
f.write(json.dumps(log_entry) + '\n')
return {} # No modifications
def register_hooks(hook_manager):
hook_manager.register_hook(
hook_point='pre_solve',
function=trial_logger_hook,
name='trial_logger_hook',
description='Log trial parameters before solve',
priority=10, # Run early
enabled=True
)
```
### Example 3: Mesh Quality Check (post_mesh)
```python
"""Validate mesh quality after meshing."""
def mesh_quality_hook(context):
"""Check mesh quality metrics."""
mesh_file = context.get('mesh_file')
# Check quality metrics
quality_issues = []
# ... quality checks ...
if quality_issues:
context['warnings'] = context.get('warnings', []) + quality_issues
return {
'mesh_quality_passed': len(quality_issues) == 0,
'mesh_issues': quality_issues,
}
def register_hooks(hook_manager):
hook_manager.register_hook(
hook_point='post_mesh',
function=mesh_quality_hook,
name='mesh_quality_hook',
description='Validate mesh quality',
priority=50,
enabled=True
)
```
---
## Hook Context Reference
### Standard Context Keys
| Key | Type | Available At | Description |
|-----|------|--------------|-------------|
| `trial_number` | int | All | Current trial number |
| `design_params` | dict | All | Design parameter values |
| `config` | dict | All | Optimization config |
| `working_dir` | Path | All | Study working directory |
| `model_file` | Path | pre_mesh+ | NX model file path |
| `mesh_file` | Path | post_mesh+ | Mesh file path |
| `op2_file` | Path | post_solve+ | Results file path |
| `results` | dict | post_extraction+ | Extracted results |
| `objectives` | dict | post_calculation | Computed objectives |
### Priority Guidelines
| Priority Range | Use For |
|----------------|---------|
| 1-50 | Critical hooks that must run first |
| 50-100 | Standard hooks |
| 100-150 | Logging and monitoring |
| 150+ | Cleanup and finalization |
---
## Managing Hooks
### Enable/Disable at Runtime
```python
hook_manager.disable_hook('my_hook')
hook_manager.enable_hook('my_hook')
```
### Check Hook Status
```python
hooks = hook_manager.list_hooks()
for hook in hooks:
print(f"{hook['name']}: {'enabled' if hook['enabled'] else 'disabled'}")
```
### Hook Execution Order
Hooks at the same point run in priority order (lower first):
```
Priority 10: trial_logger_hook
Priority 50: mesh_quality_hook
Priority 100: safety_factor_hook
```
---
## Troubleshooting
| Issue | Cause | Solution |
|-------|-------|----------|
| Hook not running | Not registered | Check `register_hooks` function |
| Wrong hook point | Misnamed directory | Check directory name matches hook point |
| Context missing key | Wrong hook point | Use appropriate hook point for data needed |
| Hook error crashes trial | Unhandled exception | Add try/except in hook |
---
## Cross-References
- **Related**: [EXT_01_CREATE_EXTRACTOR](./EXT_01_CREATE_EXTRACTOR.md)
- **System**: `optimization_engine/plugins/hook_manager.py`
- **Template**: `templates/hook_template.py`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,263 @@
# EXT_03: Create New Protocol
<!--
PROTOCOL: Create New Protocol Document
LAYER: Extensions
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: admin
LOAD_WITH: []
-->
## Overview
This protocol guides you through creating new protocol documents for the Atomizer Protocol Operating System (POS). Use this when adding significant new system capabilities.
**Privilege Required**: admin
---
## When to Use
| Trigger | Action |
|---------|--------|
| Adding major new system capability | Follow this protocol |
| "create protocol", "new protocol" | Follow this protocol |
| Need to document architectural pattern | Follow this protocol |
---
## Protocol Types
| Layer | Prefix | Purpose | Example |
|-------|--------|---------|---------|
| Operations | OP_ | How-to guides | OP_01_CREATE_STUDY |
| System | SYS_ | Core specifications | SYS_10_IMSO |
| Extensions | EXT_ | Extensibility guides | EXT_01_CREATE_EXTRACTOR |
---
## Step-by-Step Guide
### Step 1: Determine Protocol Type
- **Operations (OP_)**: User-facing procedures
- **System (SYS_)**: Technical specifications
- **Extensions (EXT_)**: Developer guides
### Step 2: Assign Protocol Number
**Operations**: Sequential (OP_01, OP_02, ...)
**System**: By feature area (SYS_10=optimization, SYS_11=multi-obj, etc.)
**Extensions**: Sequential (EXT_01, EXT_02, ...)
Check existing protocols to avoid conflicts.
### Step 3: Create Protocol File
Use the template from `templates/protocol_template.md`:
```markdown
# {LAYER}_{NUMBER}_{NAME}.md
<!--
PROTOCOL: {Full Name}
LAYER: {Operations|System|Extensions}
VERSION: 1.0
STATUS: Active
LAST_UPDATED: {YYYY-MM-DD}
PRIVILEGE: {user|power_user|admin}
LOAD_WITH: [{dependencies}]
-->
## Overview
{1-3 sentence description of what this protocol does}
---
## When to Use
| Trigger | Action |
|---------|--------|
| {keyword or condition} | Follow this protocol |
---
## Quick Reference
{Tables with key parameters, commands, or mappings}
---
## Detailed Specification
### Section 1: {Topic}
{Content}
### Section 2: {Topic}
{Content}
---
## Examples
### Example 1: {Scenario}
{Complete working example}
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| {error} | {why} | {fix} |
---
## Cross-References
- **Depends On**: [{protocol}]({path})
- **Used By**: [{protocol}]({path})
- **See Also**: [{related}]({path})
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | {DATE} | Initial release |
```
### Step 4: Write Content
**Required Sections**:
1. Overview - What does this protocol do?
2. When to Use - Trigger conditions
3. Quick Reference - Fast lookup
4. Detailed Specification - Full content
5. Examples - Working examples
6. Troubleshooting - Common issues
7. Cross-References - Related protocols
8. Version History - Changes over time
**Writing Guidelines**:
- Front-load important information
- Use tables for structured data
- Include complete code examples
- Provide troubleshooting for common issues
### Step 5: Update Navigation
**docs/protocols/README.md**:
```markdown
| {NUM} | {Name} | [{Layer}]({layer}/{filename}) |
```
**.claude/skills/01_CHEATSHEET.md**:
```markdown
| {task} | {LAYER}_{NUM} | {key info} |
```
**.claude/skills/02_CONTEXT_LOADER.md**:
Add loading rules if needed.
### Step 6: Update Cross-References
Add references in related protocols:
- "Depends On" in new protocol
- "Used By" or "See Also" in existing protocols
### Step 7: Validate
```bash
# Check markdown syntax
# Verify all links work
# Test code examples
# Ensure consistent formatting
```
---
## Protocol Metadata
### Header Comment Block
```markdown
<!--
PROTOCOL: Full Protocol Name
LAYER: Operations|System|Extensions
VERSION: Major.Minor
STATUS: Active|Draft|Deprecated
LAST_UPDATED: YYYY-MM-DD
PRIVILEGE: user|power_user|admin
LOAD_WITH: [SYS_10, SYS_11]
-->
```
### Status Values
| Status | Meaning |
|--------|---------|
| Draft | In development, not ready for use |
| Active | Production ready |
| Deprecated | Being phased out |
### Privilege Levels
| Level | Who Can Use |
|-------|-------------|
| user | All users |
| power_user | Developers who can extend |
| admin | Full system access |
---
## Versioning
### Semantic Versioning
- **Major (X.0)**: Breaking changes
- **Minor (1.X)**: New features, backward compatible
- **Patch (1.0.X)**: Bug fixes (usually omit for docs)
### Version History Format
```markdown
| Version | Date | Changes |
|---------|------|---------|
| 2.0 | 2025-12-15 | Redesigned architecture |
| 1.1 | 2025-12-05 | Added neural support |
| 1.0 | 2025-11-20 | Initial release |
```
---
## Troubleshooting
| Issue | Cause | Solution |
|-------|-------|----------|
| Protocol not found | Wrong path | Check location and README |
| LLM not loading | Missing from context loader | Update 02_CONTEXT_LOADER.md |
| Broken links | Path changed | Update cross-references |
---
## Cross-References
- **Template**: `templates/protocol_template.md`
- **Navigation**: `docs/protocols/README.md`
- **Context Loading**: `.claude/skills/02_CONTEXT_LOADER.md`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,331 @@
# EXT_04: Create New Skill
<!--
PROTOCOL: Create New Skill or Module
LAYER: Extensions
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: admin
LOAD_WITH: []
-->
## Overview
This protocol guides you through creating new skills or skill modules for the LLM instruction system. Skills provide task-specific guidance to Claude sessions.
**Privilege Required**: admin
---
## When to Use
| Trigger | Action |
|---------|--------|
| Need new LLM capability | Follow this protocol |
| "create skill", "new skill" | Follow this protocol |
| Task pattern needs documentation | Follow this protocol |
---
## Skill Types
| Type | Location | Purpose | Example |
|------|----------|---------|---------|
| Bootstrap | `.claude/skills/0X_*.md` | LLM orientation | 00_BOOTSTRAP.md |
| Core | `.claude/skills/core/` | Always-load skills | study-creation-core.md |
| Module | `.claude/skills/modules/` | Optional, load-on-demand | extractors-catalog.md |
| Dev | `.claude/skills/DEV_*.md` | Developer workflows | DEV_DOCUMENTATION.md |
---
## Step-by-Step Guide
### Step 1: Determine Skill Type
**Bootstrap (0X_)**: System-level LLM guidance
- Task classification
- Context loading rules
- Execution patterns
**Core**: Essential task skills that are always loaded
- Study creation
- Run optimization (basic)
**Module**: Specialized skills loaded on demand
- Specific extractors
- Domain-specific (Zernike, neural)
- Advanced features
**Dev (DEV_)**: Developer-facing workflows
- Documentation maintenance
- Testing procedures
- Contribution guides
### Step 2: Create Skill File
#### For Core/Module Skills
```markdown
# {Skill Name}
**Version**: 1.0
**Purpose**: {One-line description}
---
## Overview
{What this skill enables Claude to do}
---
## When to Load
This skill should be loaded when:
- {Condition 1}
- {Condition 2}
---
## Quick Reference
{Tables with key patterns, commands}
---
## Detailed Instructions
### Pattern 1: {Name}
{Step-by-step instructions}
**Example**:
\`\`\`python
{code example}
\`\`\`
### Pattern 2: {Name}
{Step-by-step instructions}
---
## Code Templates
### Template 1: {Name}
\`\`\`python
{copy-paste ready code}
\`\`\`
---
## Validation
Before completing:
- [ ] {Check 1}
- [ ] {Check 2}
---
## Related
- **Protocol**: [{related}]({path})
- **Module**: [{related}]({path})
```
### Step 3: Register Skill
#### For Bootstrap Skills
Add to `00_BOOTSTRAP.md` task classification tree.
#### For Core Skills
Add to `02_CONTEXT_LOADER.md`:
```yaml
{TASK_TYPE}:
always_load:
- core/{skill_name}.md
```
#### For Modules
Add to `02_CONTEXT_LOADER.md`:
```yaml
{TASK_TYPE}:
load_if:
- modules/{skill_name}.md: "{condition}"
```
### Step 4: Update Navigation
Add to `01_CHEATSHEET.md` if relevant to common tasks.
### Step 5: Test
Test with fresh Claude session:
1. Start new conversation
2. Describe task that should trigger skill
3. Verify correct skill is loaded
4. Verify skill instructions are followed
---
## Skill Design Guidelines
### Structure
- **Front-load**: Most important info first
- **Tables**: Use for structured data
- **Code blocks**: Complete, copy-paste ready
- **Checklists**: For validation steps
### Content
- **Task-focused**: What should Claude DO?
- **Prescriptive**: Clear instructions, not options
- **Examples**: Show expected patterns
- **Validation**: How to verify success
### Length Guidelines
| Skill Type | Target Lines | Rationale |
|------------|--------------|-----------|
| Bootstrap | 100-200 | Quick orientation |
| Core | 500-1000 | Comprehensive task guide |
| Module | 150-400 | Focused specialization |
### Avoid
- Duplicating protocol content (reference instead)
- Vague instructions ("consider" → "do")
- Missing examples
- Untested code
---
## Module vs Protocol
**Skills** teach Claude HOW to interact:
- Conversation patterns
- Code templates
- Validation steps
- User interaction
**Protocols** document WHAT exists:
- Technical specifications
- Configuration options
- Architecture details
- Troubleshooting
Skills REFERENCE protocols, don't duplicate them.
---
## Examples
### Example: Domain-Specific Module
`modules/thermal-optimization.md`:
```markdown
# Thermal Optimization Module
**Version**: 1.0
**Purpose**: Specialized guidance for thermal FEA optimization
---
## When to Load
Load when:
- "thermal", "temperature", "heat" in user request
- Optimizing for thermal properties
---
## Quick Reference
| Physics | Extractor | Unit |
|---------|-----------|------|
| Max temp | E11 | K |
| Gradient | E12 | K/mm |
| Heat flux | E13 | W/m² |
---
## Objective Patterns
### Minimize Max Temperature
\`\`\`python
from optimization_engine.extractors import extract_temperature
def objective(trial):
# ... run simulation ...
temp_result = extract_temperature(op2_file)
return temp_result['max_temperature']
\`\`\`
### Minimize Thermal Gradient
\`\`\`python
from optimization_engine.extractors import extract_thermal_gradient
def objective(trial):
# ... run simulation ...
grad_result = extract_thermal_gradient(op2_file)
return grad_result['max_gradient']
\`\`\`
---
## Configuration Example
\`\`\`json
{
"objectives": [
{
"name": "max_temperature",
"type": "minimize",
"unit": "K",
"description": "Maximum temperature in component"
}
]
}
\`\`\`
---
## Related
- **Extractors**: E11, E12, E13 in SYS_12
- **Protocol**: See OP_01 for study creation
```
---
## Troubleshooting
| Issue | Cause | Solution |
|-------|-------|----------|
| Skill not loaded | Not in context loader | Add loading rule |
| Wrong skill loaded | Ambiguous triggers | Refine conditions |
| Instructions not followed | Too vague | Make prescriptive |
---
## Cross-References
- **Context Loader**: `.claude/skills/02_CONTEXT_LOADER.md`
- **Bootstrap**: `.claude/skills/00_BOOTSTRAP.md`
- **Related**: [EXT_03_CREATE_PROTOCOL](./EXT_03_CREATE_PROTOCOL.md)
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,186 @@
"""
Extract {Physics Name} from FEA results.
This is a template for creating new physics extractors.
Copy this file to optimization_engine/extractors/extract_{physics}.py
and customize for your specific physics extraction.
Author: {Your Name}
Created: {Date}
Version: 1.0
"""
from pathlib import Path
from typing import Dict, Any, Optional, Union
from pyNastran.op2.op2 import OP2
def extract_{physics}(
op2_file: Union[str, Path],
subcase: int = 1,
# Add other parameters specific to your physics
) -> Dict[str, Any]:
"""
Extract {physics description} from OP2 file.
Args:
op2_file: Path to the OP2 results file
subcase: Subcase number to extract (default: 1)
# Document other parameters
Returns:
Dictionary containing:
- '{main_result}': The primary result value ({unit})
- '{secondary_result}': Secondary result info
- 'subcase': The subcase extracted
- 'unit': Unit of the result
Raises:
FileNotFoundError: If OP2 file doesn't exist
KeyError: If subcase not found in results
ValueError: If result data is invalid
Example:
>>> result = extract_{physics}('model.op2', subcase=1)
>>> print(result['{main_result}'])
123.45
>>> print(result['unit'])
'{unit}'
"""
# Convert to Path for consistency
op2_file = Path(op2_file)
# Validate file exists
if not op2_file.exists():
raise FileNotFoundError(f"OP2 file not found: {op2_file}")
# Read OP2 file
op2 = OP2()
op2.read_op2(str(op2_file))
# =========================================
# CUSTOMIZE: Your extraction logic here
# =========================================
# Example: Access displacement data
# if subcase not in op2.displacements:
# raise KeyError(f"Subcase {subcase} not found in displacement results")
# data = op2.displacements[subcase]
# Example: Access stress data
# if subcase not in op2.cquad4_stress:
# raise KeyError(f"Subcase {subcase} not found in stress results")
# stress_data = op2.cquad4_stress[subcase]
# Example: Process data
# values = data.data # numpy array
# max_value = values.max()
# max_index = values.argmax()
# =========================================
# Replace with your actual computation
# =========================================
main_result = 0.0 # TODO: Compute actual value
secondary_result = 0 # TODO: Compute actual value
return {
'{main_result}': main_result,
'{secondary_result}': secondary_result,
'subcase': subcase,
'unit': '{unit}',
}
# Optional: Class-based extractor for complex cases
class {Physics}Extractor:
"""
Class-based extractor for {physics} with state management.
Use this pattern when:
- Extraction requires multiple steps
- You need to cache the OP2 data
- Configuration is complex
Example:
>>> extractor = {Physics}Extractor('model.op2', config={'option': value})
>>> result = extractor.extract(subcase=1)
>>> print(result)
"""
def __init__(
self,
op2_file: Union[str, Path],
bdf_file: Optional[Union[str, Path]] = None,
**config
):
"""
Initialize the extractor.
Args:
op2_file: Path to OP2 results file
bdf_file: Optional path to BDF mesh file (for node coordinates)
**config: Additional configuration options
"""
self.op2_file = Path(op2_file)
self.bdf_file = Path(bdf_file) if bdf_file else None
self.config = config
self._op2 = None # Lazy-loaded
def _load_op2(self) -> OP2:
"""Lazy load OP2 file (caches result)."""
if self._op2 is None:
self._op2 = OP2()
self._op2.read_op2(str(self.op2_file))
return self._op2
def extract(self, subcase: int = 1) -> Dict[str, Any]:
"""
Extract results for given subcase.
Args:
subcase: Subcase number
Returns:
Dictionary with extraction results
"""
op2 = self._load_op2()
# TODO: Implement your extraction logic
# Use self.config for configuration options
return {
'{main_result}': 0.0,
'subcase': subcase,
}
def extract_all_subcases(self) -> Dict[int, Dict[str, Any]]:
"""
Extract results for all available subcases.
Returns:
Dictionary mapping subcase number to results
"""
op2 = self._load_op2()
# TODO: Find available subcases
# available_subcases = list(op2.displacements.keys())
results = {}
# for sc in available_subcases:
# results[sc] = self.extract(subcase=sc)
return results
# =========================================
# After creating your extractor:
# 1. Add to optimization_engine/extractors/__init__.py:
# from .extract_{physics} import extract_{physics}
# __all__ = [..., 'extract_{physics}']
#
# 2. Update docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md
# - Add to Quick Reference table
# - Add detailed section with example
#
# 3. Create test file: tests/test_extract_{physics}.py
# =========================================

View File

@@ -0,0 +1,213 @@
"""
{Hook Name} - Lifecycle Hook Plugin
This is a template for creating new lifecycle hooks.
Copy this file to optimization_engine/plugins/{hook_point}/{hook_name}.py
Available hook points:
- pre_mesh: Before meshing
- post_mesh: After meshing
- pre_solve: Before solver execution
- post_solve: After solver completion
- post_extraction: After result extraction
- post_calculation: After objective calculation
- custom_objective: Custom objective functions
Author: {Your Name}
Created: {Date}
Version: 1.0
Hook Point: {hook_point}
"""
from typing import Dict, Any, Optional
from pathlib import Path
import json
from datetime import datetime
def {hook_name}_hook(context: Dict[str, Any]) -> Dict[str, Any]:
"""
{Description of what this hook does}.
This hook runs at the {hook_point} stage of the optimization trial.
Args:
context: Dictionary containing trial context:
- trial_number (int): Current trial number
- design_params (dict): Current design parameter values
- config (dict): Optimization configuration
- working_dir (Path): Study working directory
For post_solve and later:
- op2_file (Path): Path to OP2 results file
- solve_success (bool): Whether solve succeeded
- solve_time (float): Solve duration in seconds
For post_extraction and later:
- results (dict): Extracted results so far
For post_calculation:
- objectives (dict): Computed objective values
- constraints (dict): Constraint values
Returns:
Dictionary with computed values or modifications.
These values are added to the trial context.
Return empty dict {} if no modifications needed.
Raises:
Exception: Any exception will be logged but won't stop the trial
unless you want it to (raise optuna.TrialPruned instead)
Example:
>>> context = {'trial_number': 1, 'design_params': {'x': 5.0}}
>>> result = {hook_name}_hook(context)
>>> print(result)
{{'{computed_key}': 123.45}}
"""
# =========================================
# Access context values
# =========================================
trial_num = context.get('trial_number', 0)
design_params = context.get('design_params', {})
config = context.get('config', {})
working_dir = context.get('working_dir', Path('.'))
# For post_solve hooks and later:
# op2_file = context.get('op2_file')
# solve_success = context.get('solve_success', False)
# For post_extraction hooks and later:
# results = context.get('results', {})
# For post_calculation hooks:
# objectives = context.get('objectives', {})
# constraints = context.get('constraints', {})
# =========================================
# Your hook logic here
# =========================================
# Example: Log trial start (pre_solve hook)
# print(f"[Hook] Trial {trial_num} starting with params: {design_params}")
# Example: Compute derived quantity (post_extraction hook)
# max_stress = results.get('max_von_mises', 0)
# yield_strength = config.get('material', {}).get('yield_strength', 250)
# safety_factor = yield_strength / max(max_stress, 1e-6)
# Example: Write log file (post_calculation hook)
# log_entry = {
# 'trial': trial_num,
# 'timestamp': datetime.now().isoformat(),
# 'objectives': context.get('objectives', {}),
# }
# with open(working_dir / 'trial_log.jsonl', 'a') as f:
# f.write(json.dumps(log_entry) + '\n')
# =========================================
# Return computed values
# =========================================
# Values returned here are added to the context
# and can be accessed by later hooks or the optimizer
return {
# '{computed_key}': computed_value,
}
def register_hooks(hook_manager) -> None:
"""
Register this hook with the hook manager.
This function is called automatically when plugins are discovered.
It must be named exactly 'register_hooks' and take one argument.
Args:
hook_manager: The HookManager instance from optimization_engine
"""
hook_manager.register_hook(
hook_point='{hook_point}', # pre_mesh, post_mesh, pre_solve, etc.
function={hook_name}_hook,
name='{hook_name}_hook',
description='{Brief description of what this hook does}',
priority=100, # Lower number = runs earlier (1-200 typical range)
enabled=True # Set to False to disable by default
)
# =========================================
# Optional: Helper functions
# =========================================
def _helper_function(data: Any) -> Any:
"""
Private helper function for the hook.
Keep hook logic clean by extracting complex operations
into helper functions.
"""
pass
# =========================================
# After creating your hook:
#
# 1. Place in correct directory:
# optimization_engine/plugins/{hook_point}/{hook_name}.py
#
# 2. Hook is auto-discovered - no __init__.py changes needed
#
# 3. Test the hook:
# python -c "
# from optimization_engine.plugins.hook_manager import HookManager
# hm = HookManager()
# hm.discover_plugins()
# print(hm.list_hooks())
# "
#
# 4. Update documentation if significant:
# - Add to EXT_02_CREATE_HOOK.md examples section
# =========================================
# =========================================
# Example hooks for reference
# =========================================
def example_logger_hook(context: Dict[str, Any]) -> Dict[str, Any]:
"""Example: Simple trial logger for pre_solve."""
trial = context.get('trial_number', 0)
params = context.get('design_params', {})
print(f"[LOG] Trial {trial} starting: {params}")
return {}
def example_safety_factor_hook(context: Dict[str, Any]) -> Dict[str, Any]:
"""Example: Safety factor calculator for post_extraction."""
results = context.get('results', {})
max_stress = results.get('max_von_mises', 0)
if max_stress > 0:
safety_factor = 250.0 / max_stress # Assuming 250 MPa yield
else:
safety_factor = float('inf')
return {'safety_factor': safety_factor}
def example_validator_hook(context: Dict[str, Any]) -> Dict[str, Any]:
"""Example: Result validator for post_solve."""
import optuna
solve_success = context.get('solve_success', False)
op2_file = context.get('op2_file')
if not solve_success:
raise optuna.TrialPruned("Solve failed")
if op2_file and not Path(op2_file).exists():
raise optuna.TrialPruned("OP2 file not generated")
return {'validation_passed': True}

View File

@@ -0,0 +1,112 @@
# {LAYER}_{NUMBER}_{NAME}
<!--
PROTOCOL: {Full Protocol Name}
LAYER: {Operations|System|Extensions}
VERSION: 1.0
STATUS: Active
LAST_UPDATED: {YYYY-MM-DD}
PRIVILEGE: {user|power_user|admin}
LOAD_WITH: [{dependency_protocols}]
-->
## Overview
{1-3 sentence description of what this protocol does and why it exists.}
---
## When to Use
| Trigger | Action |
|---------|--------|
| {keyword or user intent} | Follow this protocol |
| {condition} | Follow this protocol |
---
## Quick Reference
{Key information in table format for fast lookup}
| Parameter | Default | Description |
|-----------|---------|-------------|
| {param} | {value} | {description} |
---
## Detailed Specification
### Section 1: {Topic}
{Detailed content}
```python
# Code example if applicable
```
### Section 2: {Topic}
{Detailed content}
---
## Configuration
{If applicable, show configuration examples}
```json
{
"setting": "value"
}
```
---
## Examples
### Example 1: {Scenario Name}
{Complete working example with context}
```python
# Full working code example
```
### Example 2: {Scenario Name}
{Another example showing different use case}
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| {error message or symptom} | {root cause} | {how to fix} |
| {symptom} | {cause} | {solution} |
---
## Cross-References
- **Depends On**: [{protocol_name}]({relative_path})
- **Used By**: [{protocol_name}]({relative_path})
- **See Also**: [{related_doc}]({path})
---
## Implementation Files
{If applicable, list the code files that implement this protocol}
- `path/to/file.py` - {description}
- `path/to/other.py` - {description}
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | {YYYY-MM-DD} | Initial release |

View File

@@ -0,0 +1,403 @@
# OP_01: Create Optimization Study
<!--
PROTOCOL: Create Optimization Study
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: [core/study-creation-core.md]
-->
## Overview
This protocol guides you through creating a complete Atomizer optimization study from scratch. It covers gathering requirements, generating configuration files, and validating setup.
**Skill to Load**: `.claude/skills/core/study-creation-core.md`
---
## When to Use
| Trigger | Action |
|---------|--------|
| "new study", "create study" | Follow this protocol |
| "set up optimization" | Follow this protocol |
| "optimize my design" | Follow this protocol |
| User provides NX model | Assess and follow this protocol |
---
## Quick Reference
**Required Outputs**:
| File | Purpose | Location |
|------|---------|----------|
| `optimization_config.json` | Design vars, objectives, constraints | `1_setup/` |
| `run_optimization.py` | Execution script | Study root |
| `README.md` | Engineering documentation | Study root |
| `STUDY_REPORT.md` | Results template | Study root |
**Study Structure**:
```
studies/{study_name}/
├── 1_setup/
│ ├── model/ # NX files (.prt, .sim, .fem)
│ └── optimization_config.json
├── 2_results/ # Created during run
├── README.md # MANDATORY
├── STUDY_REPORT.md # MANDATORY
└── run_optimization.py
```
---
## Detailed Steps
### Step 1: Gather Requirements
**Ask the user**:
1. What are you trying to optimize? (objective)
2. What can you change? (design variables)
3. What limits must be respected? (constraints)
4. Where are your NX files?
**Example Dialog**:
```
User: "I want to optimize my bracket"
You: "What should I optimize for - minimum mass, maximum stiffness,
target frequency, or something else?"
User: "Minimize mass while keeping stress below 250 MPa"
```
### Step 2: Analyze Model (Introspection)
**MANDATORY**: When user provides NX files, run comprehensive introspection:
```python
from optimization_engine.hooks.nx_cad.model_introspection import (
introspect_part,
introspect_simulation,
introspect_op2,
introspect_study
)
# Introspect the part file to get expressions, mass, features
part_info = introspect_part("C:/path/to/model.prt")
# Introspect the simulation to get solutions, BCs, loads
sim_info = introspect_simulation("C:/path/to/model.sim")
# If OP2 exists, check what results are available
op2_info = introspect_op2("C:/path/to/results.op2")
# Or introspect entire study directory at once
study_info = introspect_study("studies/my_study/")
```
**Introspection Report Contents**:
| Source | Information Extracted |
|--------|----------------------|
| `.prt` | Expressions (count, values, types), bodies, mass, material, features |
| `.sim` | Solutions, boundary conditions, loads, materials, mesh info, output requests |
| `.op2` | Available results (displacement, stress, strain, SPC forces, etc.), subcases |
**Generate Introspection Report** at study creation:
1. Save report to `studies/{study_name}/MODEL_INTROSPECTION.md`
2. Include summary of what's available for optimization
3. List potential design variables (expressions)
4. List extractable results (from OP2)
**Key Questions Answered by Introspection**:
- What expressions exist? (potential design variables)
- What solution types? (static, modal, etc.)
- What results are available in OP2? (displacement, stress, SPC forces)
- Multi-solution required? (static + modal = set `solution_name=None`)
### Step 3: Select Protocol
Based on objectives:
| Scenario | Protocol | Sampler |
|----------|----------|---------|
| Single objective | Protocol 10 (IMSO) | TPE, CMA-ES, or GP |
| 2-3 objectives | Protocol 11 | NSGA-II |
| >50 trials, need speed | Protocol 14 | + Neural acceleration |
See [SYS_10_IMSO](../system/SYS_10_IMSO.md), [SYS_11_MULTI_OBJECTIVE](../system/SYS_11_MULTI_OBJECTIVE.md).
### Step 4: Select Extractors
Match physics to extractors from [SYS_12_EXTRACTOR_LIBRARY](../system/SYS_12_EXTRACTOR_LIBRARY.md):
| Need | Extractor ID | Function |
|------|--------------|----------|
| Max displacement | E1 | `extract_displacement()` |
| Natural frequency | E2 | `extract_frequency()` |
| Von Mises stress | E3 | `extract_solid_stress()` |
| Mass from BDF | E4 | `extract_mass_from_bdf()` |
| Mass from NX | E5 | `extract_mass_from_expression()` |
| Wavefront error | E8-E10 | Zernike extractors |
### Step 5: Generate Configuration
Create `optimization_config.json`:
```json
{
"study_name": "bracket_optimization",
"description": "Minimize bracket mass while meeting stress constraint",
"design_variables": [
{
"name": "thickness",
"type": "continuous",
"min": 2.0,
"max": 10.0,
"unit": "mm",
"description": "Wall thickness"
}
],
"objectives": [
{
"name": "mass",
"type": "minimize",
"unit": "kg",
"description": "Total bracket mass"
}
],
"constraints": [
{
"name": "max_stress",
"type": "less_than",
"value": 250.0,
"unit": "MPa",
"description": "Maximum allowable von Mises stress"
}
],
"simulation": {
"model_file": "1_setup/model/bracket.prt",
"sim_file": "1_setup/model/bracket.sim",
"solver": "nastran",
"solution_name": null
},
"optimization_settings": {
"protocol": "protocol_10_single_objective",
"sampler": "TPESampler",
"n_trials": 50
}
}
```
### Step 6: Generate run_optimization.py
```python
#!/usr/bin/env python
"""
{study_name} - Optimization Runner
Generated by Atomizer LLM
"""
import sys
from pathlib import Path
# Add optimization engine to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from optimization_engine.nx_solver import NXSolver
from optimization_engine.extractors import extract_displacement, extract_solid_stress
# Paths
STUDY_DIR = Path(__file__).parent
MODEL_DIR = STUDY_DIR / "1_setup" / "model"
RESULTS_DIR = STUDY_DIR / "2_results"
def objective(trial):
"""Optimization objective function."""
# Sample design variables
thickness = trial.suggest_float("thickness", 2.0, 10.0)
# Update NX model and solve
nx_solver = NXSolver(...)
result = nx_solver.run_simulation(
sim_file=MODEL_DIR / "bracket.sim",
working_dir=MODEL_DIR,
expression_updates={"thickness": thickness}
)
if not result['success']:
raise optuna.TrialPruned("Simulation failed")
# Extract results using library extractors
op2_file = result['op2_file']
stress_result = extract_solid_stress(op2_file)
max_stress = stress_result['max_von_mises']
# Check constraint
if max_stress > 250.0:
raise optuna.TrialPruned(f"Stress constraint violated: {max_stress} MPa")
# Return objective
mass = extract_mass(...)
return mass
if __name__ == "__main__":
# Run optimization
import optuna
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=50)
```
### Step 7: Generate Documentation
**README.md** (11 sections required):
1. Engineering Problem
2. Mathematical Formulation
3. Optimization Algorithm
4. Simulation Pipeline
5. Result Extraction Methods
6. Neural Acceleration (if applicable)
7. Study File Structure
8. Results Location
9. Quick Start
10. Configuration Reference
11. References
**STUDY_REPORT.md** (template):
```markdown
# Study Report: {study_name}
## Executive Summary
- Trials completed: _pending_
- Best objective: _pending_
- Constraint satisfaction: _pending_
## Optimization Progress
_To be filled after run_
## Best Designs Found
_To be filled after run_
## Recommendations
_To be filled after analysis_
```
### Step 8: Validate NX Model File Chain
**CRITICAL**: NX simulation files have parent-child dependencies. ALL linked files must be copied to the study folder.
**Required File Chain Check**:
```
.sim (Simulation)
└── .fem (FEM)
└── _i.prt (Idealized Part) ← OFTEN MISSING!
└── .prt (Geometry Part)
```
**Validation Steps**:
1. Open the `.sim` file in NX
2. Go to **Assemblies → Assembly Navigator** or check **Part Navigator**
3. Identify ALL child components (especially `*_i.prt` idealized parts)
4. Copy ALL linked files to `1_setup/model/`
**Common Issue**: The `_i.prt` (idealized part) is often forgotten. Without it:
- `UpdateFemodel()` runs but mesh doesn't change
- Geometry changes don't propagate to FEM
- All optimization trials produce identical results
**File Checklist**:
| File Pattern | Description | Required |
|--------------|-------------|----------|
| `*.prt` | Geometry part | ✅ Always |
| `*_i.prt` | Idealized part | ✅ If FEM uses idealization |
| `*.fem` | FEM file | ✅ Always |
| `*.sim` | Simulation file | ✅ Always |
**Introspection should report**:
- List of all parts referenced by .sim
- Warning if any referenced parts are missing from study folder
### Step 9: Final Validation Checklist
Before running:
- [ ] NX files exist in `1_setup/model/`
- [ ] **ALL child parts copied** (especially `*_i.prt`)
- [ ] Expression names match model
- [ ] Config validates (JSON schema)
- [ ] `run_optimization.py` has no syntax errors
- [ ] README.md has all 11 sections
- [ ] STUDY_REPORT.md template exists
---
## Examples
### Example 1: Simple Bracket
```
User: "Optimize my bracket.prt for minimum mass, stress < 250 MPa"
Generated config:
- 1 design variable (thickness)
- 1 objective (minimize mass)
- 1 constraint (stress < 250)
- Protocol 10, TPE sampler
- 50 trials
```
### Example 2: Multi-Objective Beam
```
User: "Minimize mass AND maximize stiffness for my beam"
Generated config:
- 2 design variables (width, height)
- 2 objectives (minimize mass, maximize stiffness)
- Protocol 11, NSGA-II sampler
- 50 trials (Pareto front)
```
### Example 3: Telescope Mirror
```
User: "Minimize wavefront error at 40deg vs 20deg reference"
Generated config:
- Multiple design variables (mount positions)
- 1 objective (minimize relative WFE)
- Zernike extractor E9
- Protocol 10
```
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "Expression not found" | Name mismatch | Verify expression names in NX |
| "No feasible designs" | Constraints too tight | Relax constraint values |
| Config validation fails | Missing required field | Check JSON schema |
| Import error | Wrong path | Check sys.path setup |
---
## Cross-References
- **Depends On**: [SYS_12_EXTRACTOR_LIBRARY](../system/SYS_12_EXTRACTOR_LIBRARY.md)
- **Next Step**: [OP_02_RUN_OPTIMIZATION](./OP_02_RUN_OPTIMIZATION.md)
- **Skill**: `.claude/skills/core/study-creation-core.md`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,297 @@
# OP_02: Run Optimization
<!--
PROTOCOL: Run Optimization
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
This protocol covers executing optimization runs, including pre-flight validation, execution modes, monitoring, and handling common issues.
---
## When to Use
| Trigger | Action |
|---------|--------|
| "start", "run", "execute" | Follow this protocol |
| "begin optimization" | Follow this protocol |
| Study setup complete | Execute this protocol |
---
## Quick Reference
**Start Command**:
```bash
conda activate atomizer
cd studies/{study_name}
python run_optimization.py
```
**Common Options**:
| Flag | Purpose |
|------|---------|
| `--n-trials 100` | Override trial count |
| `--resume` | Continue interrupted run |
| `--test` | Run single trial for validation |
| `--export-training` | Export data for neural training |
---
## Pre-Flight Checklist
Before running, verify:
- [ ] **Environment**: `conda activate atomizer`
- [ ] **Config exists**: `1_setup/optimization_config.json`
- [ ] **Script exists**: `run_optimization.py`
- [ ] **Model files**: NX files in `1_setup/model/`
- [ ] **No conflicts**: No other optimization running on same study
- [ ] **Disk space**: Sufficient for results
**Quick Validation**:
```bash
python run_optimization.py --test
```
This runs a single trial to verify setup.
---
## Execution Modes
### 1. Standard Run
```bash
python run_optimization.py
```
Uses settings from `optimization_config.json`.
### 2. Override Trials
```bash
python run_optimization.py --n-trials 100
```
Override trial count from config.
### 3. Resume Interrupted
```bash
python run_optimization.py --resume
```
Continues from last completed trial.
### 4. Neural Acceleration
```bash
python run_optimization.py --neural
```
Requires trained surrogate model.
### 5. Export Training Data
```bash
python run_optimization.py --export-training
```
Saves BDF/OP2 for neural network training.
---
## Monitoring Progress
### Option 1: Console Output
The script prints progress:
```
Trial 15/50 complete. Best: 0.234 kg
Trial 16/50 complete. Best: 0.234 kg
```
### Option 2: Dashboard
See [SYS_13_DASHBOARD_TRACKING](../system/SYS_13_DASHBOARD_TRACKING.md).
```bash
# Start dashboard (separate terminal)
cd atomizer-dashboard/backend && python -m uvicorn api.main:app --port 8000
cd atomizer-dashboard/frontend && npm run dev
# Open browser
http://localhost:3000
```
### Option 3: Query Database
```bash
python -c "
import optuna
study = optuna.load_study('study_name', 'sqlite:///2_results/study.db')
print(f'Trials: {len(study.trials)}')
print(f'Best value: {study.best_value}')
"
```
### Option 4: Optuna Dashboard
```bash
optuna-dashboard sqlite:///2_results/study.db
# Open http://localhost:8080
```
---
## During Execution
### What Happens Per Trial
1. **Sample parameters**: Optuna suggests design variable values
2. **Update model**: NX expressions updated via journal
3. **Solve**: NX Nastran runs FEA simulation
4. **Extract results**: Extractors read OP2 file
5. **Evaluate**: Check constraints, compute objectives
6. **Record**: Trial stored in Optuna database
### Normal Output
```
[2025-12-05 10:15:30] Trial 1 started
[2025-12-05 10:17:45] NX solve complete (135.2s)
[2025-12-05 10:17:46] Extraction complete
[2025-12-05 10:17:46] Trial 1 complete: mass=0.342 kg, stress=198.5 MPa
[2025-12-05 10:17:47] Trial 2 started
...
```
### Expected Timing
| Operation | Typical Time |
|-----------|--------------|
| NX solve | 30s - 30min |
| Extraction | <1s |
| Per trial total | 1-30 min |
| 50 trials | 1-24 hours |
---
## Handling Issues
### Trial Failed / Pruned
```
[WARNING] Trial 12 pruned: Stress constraint violated (312.5 MPa > 250 MPa)
```
**Normal behavior** - optimizer learns from failures.
### NX Session Timeout
```
[ERROR] NX session timeout after 600s
```
**Solution**: Increase timeout in config or simplify model.
### Expression Not Found
```
[ERROR] Expression 'thicknes' not found in model
```
**Solution**: Check spelling, verify expression exists in NX.
### OP2 File Missing
```
[ERROR] OP2 file not found: model.op2
```
**Solution**: Check NX solve completed. Review NX log file.
### Database Locked
```
[ERROR] Database is locked
```
**Solution**: Another process using database. Wait or kill stale process.
---
## Stopping and Resuming
### Graceful Stop
Press `Ctrl+C` once. Current trial completes, then exits.
### Force Stop
Press `Ctrl+C` twice. Immediate exit (may lose current trial).
### Resume
```bash
python run_optimization.py --resume
```
Continues from last completed trial. Same study database used.
---
## Post-Run Actions
After optimization completes:
1. **Check results**:
```bash
python -c "import optuna; s=optuna.load_study(...); print(s.best_params)"
```
2. **View in dashboard**: `http://localhost:3000`
3. **Generate report**: See [OP_04_ANALYZE_RESULTS](./OP_04_ANALYZE_RESULTS.md)
4. **Update STUDY_REPORT.md**: Fill in results template
---
## Protocol Integration
### With Protocol 10 (IMSO)
If enabled, optimization runs in two phases:
1. Characterization (10-30 trials)
2. Optimization (remaining trials)
Dashboard shows phase transitions.
### With Protocol 11 (Multi-Objective)
If 2+ objectives, uses NSGA-II. Returns Pareto front, not single best.
### With Protocol 13 (Dashboard)
Writes `optimizer_state.json` every trial for real-time updates.
### With Protocol 14 (Neural)
If `--neural` flag, uses trained surrogate for fast evaluation.
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "ModuleNotFoundError" | Wrong environment | `conda activate atomizer` |
| All trials pruned | Constraints too tight | Relax constraints |
| Very slow | Model too complex | Simplify mesh, increase timeout |
| No improvement | Wrong sampler | Try different algorithm |
| "NX license error" | License unavailable | Check NX license server |
---
## Cross-References
- **Preceded By**: [OP_01_CREATE_STUDY](./OP_01_CREATE_STUDY.md)
- **Followed By**: [OP_03_MONITOR_PROGRESS](./OP_03_MONITOR_PROGRESS.md), [OP_04_ANALYZE_RESULTS](./OP_04_ANALYZE_RESULTS.md)
- **Integrates With**: [SYS_10_IMSO](../system/SYS_10_IMSO.md), [SYS_13_DASHBOARD_TRACKING](../system/SYS_13_DASHBOARD_TRACKING.md)
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,246 @@
# OP_03: Monitor Progress
<!--
PROTOCOL: Monitor Optimization Progress
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: [SYS_13_DASHBOARD_TRACKING]
-->
## Overview
This protocol covers monitoring optimization progress through console output, dashboard, database queries, and Optuna's built-in tools.
---
## When to Use
| Trigger | Action |
|---------|--------|
| "status", "progress" | Follow this protocol |
| "how many trials" | Query database |
| "what's happening" | Check console or dashboard |
| "is it running" | Check process status |
---
## Quick Reference
| Method | Command/URL | Best For |
|--------|-------------|----------|
| Console | Watch terminal output | Quick check |
| Dashboard | `http://localhost:3000` | Visual monitoring |
| Database query | Python one-liner | Scripted checks |
| Optuna Dashboard | `http://localhost:8080` | Detailed analysis |
---
## Monitoring Methods
### 1. Console Output
If running in foreground, watch terminal:
```
[10:15:30] Trial 15/50 started
[10:17:45] Trial 15/50 complete: mass=0.234 kg (best: 0.212 kg)
[10:17:46] Trial 16/50 started
```
### 2. Atomizer Dashboard
**Start Dashboard** (if not running):
```bash
# Terminal 1: Backend
cd atomizer-dashboard/backend
python -m uvicorn api.main:app --reload --port 8000
# Terminal 2: Frontend
cd atomizer-dashboard/frontend
npm run dev
```
**View at**: `http://localhost:3000`
**Features**:
- Real-time trial progress bar
- Current optimizer phase (if Protocol 10)
- Pareto front visualization (if multi-objective)
- Parallel coordinates plot
- Convergence chart
### 3. Database Query
**Quick status**:
```bash
python -c "
import optuna
study = optuna.load_study(
study_name='my_study',
storage='sqlite:///studies/my_study/2_results/study.db'
)
print(f'Trials completed: {len(study.trials)}')
print(f'Best value: {study.best_value}')
print(f'Best params: {study.best_params}')
"
```
**Detailed status**:
```python
import optuna
study = optuna.load_study(
study_name='my_study',
storage='sqlite:///studies/my_study/2_results/study.db'
)
# Trial counts by state
from collections import Counter
states = Counter(t.state.name for t in study.trials)
print(f"Complete: {states.get('COMPLETE', 0)}")
print(f"Pruned: {states.get('PRUNED', 0)}")
print(f"Failed: {states.get('FAIL', 0)}")
print(f"Running: {states.get('RUNNING', 0)}")
# Best trials
if len(study.directions) > 1:
print(f"Pareto front size: {len(study.best_trials)}")
else:
print(f"Best value: {study.best_value}")
```
### 4. Optuna Dashboard
```bash
optuna-dashboard sqlite:///studies/my_study/2_results/study.db
# Open http://localhost:8080
```
**Features**:
- Trial history table
- Parameter importance
- Optimization history plot
- Slice plot (parameter vs objective)
### 5. Check Running Processes
```bash
# Linux/Mac
ps aux | grep run_optimization
# Windows
tasklist | findstr python
```
---
## Key Metrics to Monitor
### Trial Progress
- Completed trials vs target
- Completion rate (trials/hour)
- Estimated time remaining
### Objective Improvement
- Current best value
- Improvement trend
- Plateau detection
### Constraint Satisfaction
- Feasibility rate (% passing constraints)
- Most violated constraint
### For Protocol 10 (IMSO)
- Current phase (Characterization vs Optimization)
- Current strategy (TPE, GP, CMA-ES)
- Characterization confidence
### For Protocol 11 (Multi-Objective)
- Pareto front size
- Hypervolume indicator
- Spread of solutions
---
## Interpreting Results
### Healthy Optimization
```
Trial 45/50: mass=0.198 kg (best: 0.195 kg)
Feasibility rate: 78%
```
- Progress toward target
- Reasonable feasibility rate (60-90%)
- Gradual improvement
### Potential Issues
**All Trials Pruned**:
```
Trial 20 pruned: constraint violated
Trial 21 pruned: constraint violated
...
```
→ Constraints too tight. Consider relaxing.
**No Improvement**:
```
Trial 30: best=0.234 (unchanged since trial 8)
Trial 31: best=0.234 (unchanged since trial 8)
```
→ May have converged, or stuck in local minimum.
**High Failure Rate**:
```
Failed: 15/50 (30%)
```
→ Model issues. Check NX logs.
---
## Real-Time State File
If using Protocol 10, check:
```bash
cat studies/my_study/2_results/intelligent_optimizer/optimizer_state.json
```
```json
{
"timestamp": "2025-12-05T10:15:30",
"trial_number": 29,
"total_trials": 50,
"current_phase": "adaptive_optimization",
"current_strategy": "GP_UCB",
"is_multi_objective": false
}
```
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| Dashboard shows old data | Backend not running | Start backend |
| "No study found" | Wrong path | Check study name and path |
| Trial count not increasing | Process stopped | Check if still running |
| Dashboard not updating | Polling issue | Refresh browser |
---
## Cross-References
- **Preceded By**: [OP_02_RUN_OPTIMIZATION](./OP_02_RUN_OPTIMIZATION.md)
- **Followed By**: [OP_04_ANALYZE_RESULTS](./OP_04_ANALYZE_RESULTS.md)
- **Integrates With**: [SYS_13_DASHBOARD_TRACKING](../system/SYS_13_DASHBOARD_TRACKING.md)
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,302 @@
# OP_04: Analyze Results
<!--
PROTOCOL: Analyze Optimization Results
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
This protocol covers analyzing optimization results, including extracting best solutions, generating reports, comparing designs, and interpreting Pareto fronts.
---
## When to Use
| Trigger | Action |
|---------|--------|
| "results", "what did we find" | Follow this protocol |
| "best design" | Extract best trial |
| "compare", "trade-off" | Pareto analysis |
| "report" | Generate summary |
| Optimization complete | Analyze and document |
---
## Quick Reference
**Key Outputs**:
| Output | Location | Purpose |
|--------|----------|---------|
| Best parameters | `study.best_params` | Optimal design |
| Pareto front | `study.best_trials` | Trade-off solutions |
| Trial history | `study.trials` | Full exploration |
| Intelligence report | `intelligent_optimizer/` | Algorithm insights |
---
## Analysis Methods
### 1. Single-Objective Results
```python
import optuna
study = optuna.load_study(
study_name='my_study',
storage='sqlite:///2_results/study.db'
)
# Best result
print(f"Best value: {study.best_value}")
print(f"Best parameters: {study.best_params}")
print(f"Best trial: #{study.best_trial.number}")
# Get full best trial details
best = study.best_trial
print(f"User attributes: {best.user_attrs}")
```
### 2. Multi-Objective Results (Pareto Front)
```python
import optuna
study = optuna.load_study(
study_name='my_study',
storage='sqlite:///2_results/study.db'
)
# All Pareto-optimal solutions
pareto_trials = study.best_trials
print(f"Pareto front size: {len(pareto_trials)}")
# Print all Pareto solutions
for trial in pareto_trials:
print(f"Trial {trial.number}: {trial.values} - {trial.params}")
# Find extremes
# Assuming objectives: [stiffness (max), mass (min)]
best_stiffness = max(pareto_trials, key=lambda t: t.values[0])
lightest = min(pareto_trials, key=lambda t: t.values[1])
print(f"Best stiffness: Trial {best_stiffness.number}")
print(f"Lightest: Trial {lightest.number}")
```
### 3. Parameter Importance
```python
import optuna
study = optuna.load_study(...)
# Parameter importance (which parameters matter most)
importance = optuna.importance.get_param_importances(study)
for param, score in importance.items():
print(f"{param}: {score:.3f}")
```
### 4. Constraint Analysis
```python
# Find feasibility rate
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
pruned = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED]
feasibility_rate = len(completed) / (len(completed) + len(pruned))
print(f"Feasibility rate: {feasibility_rate:.1%}")
# Analyze why trials were pruned
for trial in pruned[:5]: # First 5 pruned
reason = trial.user_attrs.get('pruning_reason', 'Unknown')
print(f"Trial {trial.number}: {reason}")
```
---
## Visualization
### Using Optuna Dashboard
```bash
optuna-dashboard sqlite:///2_results/study.db
# Open http://localhost:8080
```
**Available Plots**:
- Optimization history
- Parameter importance
- Slice plot (parameter vs objective)
- Parallel coordinates
- Contour plot (2D parameter interaction)
### Using Atomizer Dashboard
Navigate to `http://localhost:3000` and select study.
**Features**:
- Pareto front plot with normalization
- Parallel coordinates with selection
- Real-time convergence chart
### Custom Visualization
```python
import matplotlib.pyplot as plt
import optuna
study = optuna.load_study(...)
# Plot optimization history
fig = optuna.visualization.plot_optimization_history(study)
fig.show()
# Plot parameter importance
fig = optuna.visualization.plot_param_importances(study)
fig.show()
# Plot Pareto front (multi-objective)
if len(study.directions) > 1:
fig = optuna.visualization.plot_pareto_front(study)
fig.show()
```
---
## Generate Reports
### Update STUDY_REPORT.md
After analysis, fill in the template:
```markdown
# Study Report: bracket_optimization
## Executive Summary
- **Trials completed**: 50
- **Best mass**: 0.195 kg
- **Best parameters**: thickness=4.2mm, width=25.8mm
- **Constraint satisfaction**: All constraints met
## Optimization Progress
- Initial best: 0.342 kg (trial 1)
- Final best: 0.195 kg (trial 38)
- Improvement: 43%
## Best Designs Found
### Design 1 (Overall Best)
| Parameter | Value |
|-----------|-------|
| thickness | 4.2 mm |
| width | 25.8 mm |
| Metric | Value | Constraint |
|--------|-------|------------|
| Mass | 0.195 kg | - |
| Max stress | 238.5 MPa | < 250 MPa ✓ |
## Engineering Recommendations
1. Recommended design: Trial 38 parameters
2. Safety margin: 4.6% on stress constraint
3. Consider manufacturing tolerance analysis
```
### Export to CSV
```python
import pandas as pd
# All trials to DataFrame
trials_data = []
for trial in study.trials:
if trial.state == optuna.trial.TrialState.COMPLETE:
row = {'trial': trial.number, 'value': trial.value}
row.update(trial.params)
trials_data.append(row)
df = pd.DataFrame(trials_data)
df.to_csv('optimization_results.csv', index=False)
```
### Export Best Design for FEA Validation
```python
# Get best parameters
best_params = study.best_params
# Format for NX expression update
for name, value in best_params.items():
print(f"{name} = {value}")
# Or save as JSON
import json
with open('best_design.json', 'w') as f:
json.dump(best_params, f, indent=2)
```
---
## Intelligence Report (Protocol 10)
If using Protocol 10, check intelligence files:
```bash
# Landscape analysis
cat 2_results/intelligent_optimizer/intelligence_report.json
# Characterization progress
cat 2_results/intelligent_optimizer/characterization_progress.json
```
**Key Insights**:
- Landscape classification (smooth/rugged, unimodal/multimodal)
- Algorithm recommendation rationale
- Parameter correlations
- Confidence metrics
---
## Validation Checklist
Before finalizing results:
- [ ] Best solution satisfies all constraints
- [ ] Results are physically reasonable
- [ ] Parameter values within manufacturing limits
- [ ] Consider re-running FEA on best design to confirm
- [ ] Document any anomalies or surprises
- [ ] Update STUDY_REPORT.md
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| Best value seems wrong | Constraint not enforced | Check objective function |
| No Pareto solutions | All trials failed | Check constraints |
| Unexpected best params | Local minimum | Try different starting points |
| Can't load study | Wrong path | Verify database location |
---
## Cross-References
- **Preceded By**: [OP_02_RUN_OPTIMIZATION](./OP_02_RUN_OPTIMIZATION.md), [OP_03_MONITOR_PROGRESS](./OP_03_MONITOR_PROGRESS.md)
- **Related**: [SYS_11_MULTI_OBJECTIVE](../system/SYS_11_MULTI_OBJECTIVE.md) for Pareto analysis
- **Skill**: `.claude/skills/generate-report.md`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,294 @@
# OP_05: Export Training Data
<!--
PROTOCOL: Export Neural Network Training Data
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: [SYS_14_NEURAL_ACCELERATION]
-->
## Overview
This protocol covers exporting FEA simulation data for training neural network surrogates. Proper data export enables Protocol 14 (Neural Acceleration).
---
## When to Use
| Trigger | Action |
|---------|--------|
| "export training data" | Follow this protocol |
| "neural network data" | Follow this protocol |
| Planning >50 trials | Consider export for acceleration |
| Want to train surrogate | Follow this protocol |
---
## Quick Reference
**Export Command**:
```bash
python run_optimization.py --export-training
```
**Output Structure**:
```
atomizer_field_training_data/{study_name}/
├── trial_0001/
│ ├── input/model.bdf
│ ├── output/model.op2
│ └── metadata.json
├── trial_0002/
│ └── ...
└── study_summary.json
```
**Recommended Data Volume**:
| Complexity | Training Samples | Validation Samples |
|------------|-----------------|-------------------|
| Simple (2-3 params) | 50-100 | 20-30 |
| Medium (4-6 params) | 100-200 | 30-50 |
| Complex (7+ params) | 200-500 | 50-100 |
---
## Configuration
### Enable Export in Config
Add to `optimization_config.json`:
```json
{
"training_data_export": {
"enabled": true,
"export_dir": "atomizer_field_training_data/my_study",
"export_bdf": true,
"export_op2": true,
"export_fields": ["displacement", "stress"],
"include_failed": false
}
}
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | false | Enable export |
| `export_dir` | string | - | Output directory |
| `export_bdf` | bool | true | Save Nastran input |
| `export_op2` | bool | true | Save binary results |
| `export_fields` | list | all | Which result fields |
| `include_failed` | bool | false | Include failed trials |
---
## Export Workflow
### Step 1: Run with Export Enabled
```bash
conda activate atomizer
cd studies/my_study
python run_optimization.py --export-training
```
Or run standard optimization with config export enabled.
### Step 2: Verify Export
```bash
ls atomizer_field_training_data/my_study/
# Should see trial_0001/, trial_0002/, etc.
# Check a trial
ls atomizer_field_training_data/my_study/trial_0001/
# input/model.bdf
# output/model.op2
# metadata.json
```
### Step 3: Check Metadata
```bash
cat atomizer_field_training_data/my_study/trial_0001/metadata.json
```
```json
{
"trial_number": 1,
"design_parameters": {
"thickness": 5.2,
"width": 30.0
},
"objectives": {
"mass": 0.234,
"max_stress": 198.5
},
"constraints_satisfied": true,
"simulation_time": 145.2
}
```
### Step 4: Check Study Summary
```bash
cat atomizer_field_training_data/my_study/study_summary.json
```
```json
{
"study_name": "my_study",
"total_trials": 50,
"successful_exports": 47,
"failed_exports": 3,
"design_parameters": ["thickness", "width"],
"objectives": ["mass", "max_stress"],
"export_timestamp": "2025-12-05T15:30:00"
}
```
---
## Data Quality Checks
### Verify Sample Count
```python
from pathlib import Path
import json
export_dir = Path("atomizer_field_training_data/my_study")
trials = list(export_dir.glob("trial_*"))
print(f"Exported trials: {len(trials)}")
# Check for missing files
for trial_dir in trials:
bdf = trial_dir / "input" / "model.bdf"
op2 = trial_dir / "output" / "model.op2"
meta = trial_dir / "metadata.json"
if not all([bdf.exists(), op2.exists(), meta.exists()]):
print(f"Missing files in {trial_dir}")
```
### Check Parameter Coverage
```python
import json
import numpy as np
# Load all metadata
params = []
for trial_dir in export_dir.glob("trial_*"):
with open(trial_dir / "metadata.json") as f:
meta = json.load(f)
params.append(meta["design_parameters"])
# Check coverage
import pandas as pd
df = pd.DataFrame(params)
print(df.describe())
# Look for gaps
for col in df.columns:
print(f"{col}: min={df[col].min():.2f}, max={df[col].max():.2f}")
```
---
## Space-Filling Sampling
For best neural network training, use space-filling designs:
### Latin Hypercube Sampling
```python
from scipy.stats import qmc
# Generate space-filling samples
n_samples = 100
n_params = 4
sampler = qmc.LatinHypercube(d=n_params)
samples = sampler.random(n=n_samples)
# Scale to parameter bounds
lower = [2.0, 20.0, 5.0, 1.0]
upper = [10.0, 50.0, 15.0, 5.0]
scaled = qmc.scale(samples, lower, upper)
```
### Sobol Sequence
```python
sampler = qmc.Sobol(d=n_params)
samples = sampler.random(n=n_samples)
scaled = qmc.scale(samples, lower, upper)
```
---
## Next Steps After Export
### 1. Parse to Neural Format
```bash
cd atomizer-field
python batch_parser.py ../atomizer_field_training_data/my_study
```
### 2. Split Train/Validation
```python
from sklearn.model_selection import train_test_split
# 80/20 split
train_trials, val_trials = train_test_split(
all_trials,
test_size=0.2,
random_state=42
)
```
### 3. Train Model
```bash
python train_parametric.py \
--train_dir ../training_data/parsed \
--val_dir ../validation_data/parsed \
--epochs 200
```
See [SYS_14_NEURAL_ACCELERATION](../system/SYS_14_NEURAL_ACCELERATION.md) for full training workflow.
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| No export directory | Export not enabled | Add `training_data_export` to config |
| Missing OP2 files | Solve failed | Check `include_failed: false` |
| Incomplete metadata | Extraction error | Check extractor logs |
| Low sample count | Too many failures | Relax constraints |
---
## Cross-References
- **Related**: [SYS_14_NEURAL_ACCELERATION](../system/SYS_14_NEURAL_ACCELERATION.md)
- **Preceded By**: [OP_02_RUN_OPTIMIZATION](./OP_02_RUN_OPTIMIZATION.md)
- **Skill**: `.claude/skills/modules/neural-acceleration.md`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,437 @@
# OP_06: Troubleshoot
<!--
PROTOCOL: Troubleshoot Optimization Issues
LAYER: Operations
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
This protocol provides systematic troubleshooting for common optimization issues, covering NX errors, extraction failures, database problems, and performance issues.
---
## When to Use
| Trigger | Action |
|---------|--------|
| "error", "failed" | Follow this protocol |
| "not working", "crashed" | Follow this protocol |
| "help", "stuck" | Follow this protocol |
| Unexpected behavior | Follow this protocol |
---
## Quick Diagnostic
```bash
# 1. Check environment
conda activate atomizer
python --version # Should be 3.9+
# 2. Check study structure
ls studies/my_study/
# Should have: 1_setup/, run_optimization.py
# 3. Check model files
ls studies/my_study/1_setup/model/
# Should have: .prt, .sim files
# 4. Test single trial
python run_optimization.py --test
```
---
## Error Categories
### 1. Environment Errors
#### "ModuleNotFoundError: No module named 'optuna'"
**Cause**: Wrong Python environment
**Solution**:
```bash
conda activate atomizer
# Verify
conda list | grep optuna
```
#### "Python version mismatch"
**Cause**: Wrong Python version
**Solution**:
```bash
python --version # Need 3.9+
conda activate atomizer
```
---
### 2. NX Model Setup Errors
#### "All optimization trials produce identical results"
**Cause**: Missing idealized part (`*_i.prt`) or broken file chain
**Symptoms**:
- Journal shows "FE model updated" but results don't change
- DAT files have same node coordinates with different expressions
- OP2 file timestamps update but values are identical
**Root Cause**: NX simulation files have a parent-child hierarchy:
```
.sim → .fem → _i.prt → .prt (geometry)
```
If the `_i.prt` (idealized part) is missing or not properly linked, `UpdateFemodel()` runs but the mesh doesn't regenerate because:
- FEM mesh is tied to idealized geometry, not master geometry
- Without idealized part updating, FEM has nothing new to mesh against
**Solution**:
1. **Check file chain in NX**:
- Open `.sim` file
- Go to **Part Navigator** or **Assembly Navigator**
- List ALL referenced parts
2. **Copy ALL linked files** to study folder:
```bash
# Typical file set needed:
Model.prt # Geometry
Model_fem1_i.prt # Idealized part ← OFTEN MISSING!
Model_fem1.fem # FEM file
Model_sim1.sim # Simulation file
```
3. **Verify links are intact**:
- Open model in NX after copying
- Check that updates propagate: Geometry → Idealized → FEM → Sim
4. **CRITICAL CODE FIX** (already implemented in `solve_simulation.py`):
The idealized part MUST be explicitly loaded before `UpdateFemodel()`:
```python
# Load idealized part BEFORE updating FEM
for filename in os.listdir(working_dir):
if '_i.prt' in filename.lower():
idealized_part, status = theSession.Parts.Open(path)
break
# Now UpdateFemodel() will work correctly
feModel.UpdateFemodel()
```
Without loading the `_i.prt`, NX cannot propagate geometry changes to the mesh.
**Prevention**: Always use introspection to list all parts referenced by a simulation.
---
### 3. NX/Solver Errors
#### "NX session timeout after 600s"
**Cause**: Model too complex or NX stuck
**Solution**:
1. Increase timeout in config:
```json
"simulation": {
"timeout": 1200
}
```
2. Simplify mesh if possible
3. Check NX license availability
#### "Expression 'xxx' not found in model"
**Cause**: Expression name mismatch
**Solution**:
1. Open model in NX
2. Go to Tools → Expressions
3. Verify exact expression name (case-sensitive)
4. Update config to match
#### "NX license error"
**Cause**: License server unavailable
**Solution**:
1. Check license server status
2. Wait and retry
3. Contact IT if persistent
#### "NX solve failed - check log"
**Cause**: Nastran solver error
**Solution**:
1. Find log file: `1_setup/model/*.log` or `*.f06`
2. Search for "FATAL" or "ERROR"
3. Common causes:
- Singular stiffness matrix (constraints issue)
- Bad mesh (distorted elements)
- Missing material properties
---
### 3. Extraction Errors
#### "OP2 file not found"
**Cause**: Solve didn't produce output
**Solution**:
1. Check if solve completed
2. Look for `.op2` file in model directory
3. Check NX log for solve errors
#### "No displacement data for subcase X"
**Cause**: Wrong subcase number
**Solution**:
1. Check available subcases in OP2:
```python
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2('model.op2')
print(op2.displacements.keys())
```
2. Update subcase in extractor call
#### "Element type 'xxx' not supported"
**Cause**: Extractor doesn't support element type
**Solution**:
1. Check available types in extractor
2. Common types: `cquad4`, `ctria3`, `ctetra`, `chexa`
3. May need different extractor
---
### 4. Database Errors
#### "Database is locked"
**Cause**: Another process using database
**Solution**:
1. Check for running processes:
```bash
ps aux | grep run_optimization
```
2. Kill stale process if needed
3. Wait for other optimization to finish
#### "Study 'xxx' not found"
**Cause**: Wrong study name or path
**Solution**:
1. Check exact study name in database:
```python
import optuna
storage = optuna.storages.RDBStorage('sqlite:///study.db')
print(storage.get_all_study_summaries())
```
2. Use correct name when loading
#### "IntegrityError: UNIQUE constraint failed"
**Cause**: Duplicate trial number
**Solution**:
1. Don't run multiple optimizations on same study simultaneously
2. Use `--resume` flag for continuation
---
### 5. Constraint/Feasibility Errors
#### "All trials pruned"
**Cause**: No feasible region
**Solution**:
1. Check constraint values:
```python
# In objective function, print constraint values
print(f"Stress: {stress}, limit: 250")
```
2. Relax constraints
3. Widen design variable bounds
#### "No improvement after N trials"
**Cause**: Stuck in local minimum or converged
**Solution**:
1. Check if truly converged (good result)
2. Try different starting region
3. Use different sampler
4. Increase exploration (lower `n_startup_trials`)
---
### 6. Performance Issues
#### "Trials running very slowly"
**Cause**: Complex model or inefficient extraction
**Solution**:
1. Profile time per component:
```python
import time
start = time.time()
# ... operation ...
print(f"Took: {time.time() - start:.1f}s")
```
2. Simplify mesh if NX is slow
3. Check extraction isn't re-parsing OP2 multiple times
#### "Memory error"
**Cause**: Large OP2 file or many trials
**Solution**:
1. Clear Python memory between trials
2. Don't store all results in memory
3. Use database for persistence
---
## Diagnostic Commands
### Quick Health Check
```bash
# Environment
conda activate atomizer
python -c "import optuna; print('Optuna OK')"
python -c "import pyNastran; print('pyNastran OK')"
# Study structure
ls -la studies/my_study/
# Config validity
python -c "
import json
with open('studies/my_study/1_setup/optimization_config.json') as f:
config = json.load(f)
print('Config OK')
print(f'Objectives: {len(config.get(\"objectives\", []))}')
"
# Database status
python -c "
import optuna
study = optuna.load_study('my_study', 'sqlite:///studies/my_study/2_results/study.db')
print(f'Trials: {len(study.trials)}')
"
```
### NX Log Analysis
```bash
# Find latest log
ls -lt studies/my_study/1_setup/model/*.log | head -1
# Search for errors
grep -i "error\|fatal\|fail" studies/my_study/1_setup/model/*.log
```
### Trial Failure Analysis
```python
import optuna
study = optuna.load_study(...)
# Failed trials
failed = [t for t in study.trials
if t.state == optuna.trial.TrialState.FAIL]
print(f"Failed: {len(failed)}")
for t in failed[:5]:
print(f"Trial {t.number}: {t.user_attrs}")
# Pruned trials
pruned = [t for t in study.trials
if t.state == optuna.trial.TrialState.PRUNED]
print(f"Pruned: {len(pruned)}")
```
---
## Recovery Actions
### Reset Study (Start Fresh)
```bash
# Backup first
cp -r studies/my_study/2_results studies/my_study/2_results_backup
# Delete results
rm -rf studies/my_study/2_results/*
# Run fresh
python run_optimization.py
```
### Resume Interrupted Study
```bash
python run_optimization.py --resume
```
### Restore from Backup
```bash
cp -r studies/my_study/2_results_backup/* studies/my_study/2_results/
```
---
## Getting Help
### Information to Provide
When asking for help, include:
1. Error message (full traceback)
2. Config file contents
3. Study structure (`ls -la`)
4. What you tried
5. NX log excerpt (if NX error)
### Log Locations
| Log | Location |
|-----|----------|
| Optimization | Console output or redirect to file |
| NX Solve | `1_setup/model/*.log`, `*.f06` |
| Database | `2_results/study.db` (query with optuna) |
| Intelligence | `2_results/intelligent_optimizer/*.json` |
---
## Cross-References
- **Related**: All operation protocols
- **System**: [SYS_10_IMSO](../system/SYS_10_IMSO.md), [SYS_12_EXTRACTOR_LIBRARY](../system/SYS_12_EXTRACTOR_LIBRARY.md)
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial release |

View File

@@ -0,0 +1,341 @@
# SYS_10: Intelligent Multi-Strategy Optimization (IMSO)
<!--
PROTOCOL: Intelligent Multi-Strategy Optimization
LAYER: System
VERSION: 2.1
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
Protocol 10 implements adaptive optimization that automatically characterizes the problem landscape and selects the best optimization algorithm. This two-phase approach combines automated landscape analysis with algorithm-specific optimization.
**Key Innovation**: Adaptive characterization phase that intelligently determines when enough exploration has been done, then transitions to the optimal algorithm.
---
## When to Use
| Trigger | Action |
|---------|--------|
| Single-objective optimization | Use this protocol |
| "adaptive", "intelligent", "IMSO" mentioned | Load this protocol |
| User unsure which algorithm to use | Recommend this protocol |
| Complex landscape suspected | Use this protocol |
**Do NOT use when**: Multi-objective optimization needed (use SYS_11 instead)
---
## Quick Reference
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `min_trials` | 10 | 5-50 | Minimum characterization trials |
| `max_trials` | 30 | 10-100 | Maximum characterization trials |
| `confidence_threshold` | 0.85 | 0.0-1.0 | Stopping confidence level |
| `check_interval` | 5 | 1-10 | Trials between checks |
**Landscape → Algorithm Mapping**:
| Landscape Type | Primary Strategy | Fallback |
|----------------|------------------|----------|
| smooth_unimodal | GP-BO | CMA-ES |
| smooth_multimodal | GP-BO | TPE |
| rugged_unimodal | TPE | CMA-ES |
| rugged_multimodal | TPE | - |
| noisy | TPE | - |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ PHASE 1: ADAPTIVE CHARACTERIZATION STUDY │
│ ───────────────────────────────────────────────────────── │
│ Sampler: Random/Sobol (unbiased exploration) │
│ Trials: 10-30 (adapts to problem complexity) │
│ │
│ Every 5 trials: │
│ → Analyze landscape metrics │
│ → Check metric convergence │
│ → Calculate characterization confidence │
│ → Decide if ready to stop │
│ │
│ Stop when: │
│ ✓ Confidence ≥ 85% │
│ ✓ OR max trials reached (30) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ TRANSITION: LANDSCAPE ANALYSIS & STRATEGY SELECTION │
│ ───────────────────────────────────────────────────────── │
│ Analyze: │
│ - Smoothness (0-1) │
│ - Multimodality (number of modes) │
│ - Parameter correlation │
│ - Noise level │
│ │
│ Classify & Recommend: │
│ smooth_unimodal → GP-BO (best) or CMA-ES │
│ smooth_multimodal → GP-BO │
│ rugged_multimodal → TPE │
│ rugged_unimodal → TPE or CMA-ES │
│ noisy → TPE (most robust) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PHASE 2: OPTIMIZATION STUDY │
│ ───────────────────────────────────────────────────────── │
│ Sampler: Recommended from Phase 1 │
│ Warm Start: Initialize from best characterization point │
│ Trials: User-specified (default 50) │
└─────────────────────────────────────────────────────────────┘
```
---
## Core Components
### 1. Adaptive Characterization (`adaptive_characterization.py`)
**Confidence Calculation**:
```python
confidence = (
0.40 * metric_stability_score + # Are metrics converging?
0.30 * parameter_coverage_score + # Explored enough space?
0.20 * sample_adequacy_score + # Enough samples for complexity?
0.10 * landscape_clarity_score # Clear classification?
)
```
**Stopping Criteria**:
- **Minimum trials**: 10 (baseline data requirement)
- **Maximum trials**: 30 (prevent over-characterization)
- **Confidence threshold**: 85% (high confidence required)
- **Check interval**: Every 5 trials
**Adaptive Behavior**:
```python
# Simple problem (smooth, unimodal, low noise):
if smoothness > 0.6 and unimodal and noise < 0.3:
required_samples = 10 + dimensionality
# Stops at ~10-15 trials
# Complex problem (multimodal with N modes):
if multimodal and n_modes > 2:
required_samples = 10 + 5 * n_modes + 2 * dimensionality
# Continues to ~20-30 trials
```
### 2. Landscape Analyzer (`landscape_analyzer.py`)
**Metrics Computed**:
| Metric | Method | Interpretation |
|--------|--------|----------------|
| Smoothness (0-1) | Spearman correlation | >0.6: Good for CMA-ES, GP-BO |
| Multimodality | DBSCAN clustering | Detects distinct good regions |
| Correlation | Parameter-objective correlation | Identifies influential params |
| Noise (0-1) | Local consistency check | True simulation instability |
**Landscape Classifications**:
- `smooth_unimodal`: Single smooth bowl
- `smooth_multimodal`: Multiple smooth regions
- `rugged_unimodal`: Single rugged region
- `rugged_multimodal`: Multiple rugged regions
- `noisy`: High noise level
### 3. Strategy Selector (`strategy_selector.py`)
**Algorithm Characteristics**:
**GP-BO (Gaussian Process Bayesian Optimization)**:
- Best for: Smooth, expensive functions (like FEA)
- Explicit surrogate model with uncertainty quantification
- Acquisition function balances exploration/exploitation
**CMA-ES (Covariance Matrix Adaptation Evolution Strategy)**:
- Best for: Smooth unimodal problems
- Fast convergence to local optimum
- Adapts search distribution to landscape
**TPE (Tree-structured Parzen Estimator)**:
- Best for: Multimodal, rugged, or noisy problems
- Robust to noise and discontinuities
- Good global exploration
### 4. Intelligent Optimizer (`intelligent_optimizer.py`)
**Workflow**:
1. Create characterization study (Random/Sobol sampler)
2. Run adaptive characterization with stopping criterion
3. Analyze final landscape
4. Select optimal strategy
5. Create optimization study with recommended sampler
6. Warm-start from best characterization point
7. Run optimization
8. Generate intelligence report
---
## Configuration
Add to `optimization_config.json`:
```json
{
"intelligent_optimization": {
"enabled": true,
"characterization": {
"min_trials": 10,
"max_trials": 30,
"confidence_threshold": 0.85,
"check_interval": 5
},
"landscape_analysis": {
"min_trials_for_analysis": 10
},
"strategy_selection": {
"allow_cmaes": true,
"allow_gpbo": true,
"allow_tpe": true
}
},
"trials": {
"n_trials": 50
}
}
```
---
## Usage Example
```python
from pathlib import Path
from optimization_engine.intelligent_optimizer import IntelligentOptimizer
# Create optimizer
optimizer = IntelligentOptimizer(
study_name="my_optimization",
study_dir=Path("studies/my_study/2_results"),
config=optimization_config,
verbose=True
)
# Define design variables
design_vars = {
'parameter1': (lower_bound, upper_bound),
'parameter2': (lower_bound, upper_bound)
}
# Run Protocol 10
results = optimizer.optimize(
objective_function=my_objective,
design_variables=design_vars,
n_trials=50,
target_value=target,
tolerance=0.1
)
```
---
## Performance Benefits
**Efficiency**:
- **Simple problems**: Early stop at ~10-15 trials (33% reduction)
- **Complex problems**: Extended characterization at ~20-30 trials
- **Right algorithm**: Uses optimal strategy for landscape type
**Example Performance** (Circular Plate Frequency Tuning):
- TPE alone: ~95 trials to target
- Random search: ~150+ trials
- **Protocol 10**: ~56 trials (**41% reduction**)
---
## Intelligence Reports
Protocol 10 generates three tracking files:
| File | Purpose |
|------|---------|
| `characterization_progress.json` | Metric evolution, confidence progression, stopping decision |
| `intelligence_report.json` | Final landscape classification, parameter correlations, strategy recommendation |
| `strategy_transitions.json` | Phase transitions, algorithm switches, performance metrics |
**Location**: `studies/{study_name}/2_results/intelligent_optimizer/`
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| Characterization takes too long | Complex landscape | Increase `max_trials` or accept longer characterization |
| Wrong algorithm selected | Insufficient exploration | Lower `confidence_threshold` or increase `min_trials` |
| Poor convergence | Mismatch between landscape and algorithm | Review `intelligence_report.json`, consider manual override |
| "No characterization data" | Study not using Protocol 10 | Enable `intelligent_optimization.enabled: true` |
---
## Cross-References
- **Depends On**: None
- **Used By**: [OP_01_CREATE_STUDY](../operations/OP_01_CREATE_STUDY.md), [OP_02_RUN_OPTIMIZATION](../operations/OP_02_RUN_OPTIMIZATION.md)
- **Integrates With**: [SYS_13_DASHBOARD_TRACKING](./SYS_13_DASHBOARD_TRACKING.md)
- **See Also**: [SYS_11_MULTI_OBJECTIVE](./SYS_11_MULTI_OBJECTIVE.md) for multi-objective optimization
---
## Implementation Files
- `optimization_engine/intelligent_optimizer.py` - Main orchestrator
- `optimization_engine/adaptive_characterization.py` - Stopping criterion
- `optimization_engine/landscape_analyzer.py` - Landscape metrics
- `optimization_engine/strategy_selector.py` - Algorithm recommendation
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 2.1 | 2025-11-20 | Fixed strategy selector timing, multimodality detection, added simulation validation |
| 2.0 | 2025-11-20 | Added adaptive characterization, two-study architecture |
| 1.0 | 2025-11-19 | Initial implementation |
### Version 2.1 Bug Fixes Detail
**Fix #1: Strategy Selector - Use Characterization Trial Count**
*Problem*: Strategy selector used total trial count (including pruned) instead of characterization trial count, causing wrong algorithm selection after characterization.
*Solution* (`strategy_selector.py`): Use `char_trials = landscape.get('total_trials', trials_completed)` for decisions.
**Fix #2: Improved Multimodality Detection**
*Problem*: False multimodality detected on smooth continuous surfaces (2 modes detected when problem was unimodal).
*Solution* (`landscape_analyzer.py`): Added heuristic - if only 2 modes with smoothness > 0.6 and noise < 0.2, reclassify as unimodal (smooth continuous manifold).
**Fix #3: Simulation Validation**
*Problem*: 20% pruning rate due to extreme parameters causing mesh/solver failures.
*Solution*: Created `simulation_validator.py` with:
- Hard limits (reject invalid parameters)
- Soft limits (warn about risky parameters)
- Aspect ratio checks
- Model-specific validation rules
*Impact*: Reduced pruning rate from 20% to ~5%.

View File

@@ -0,0 +1,338 @@
# SYS_11: Multi-Objective Support
<!--
PROTOCOL: Multi-Objective Optimization Support
LAYER: System
VERSION: 1.0
STATUS: Active (MANDATORY)
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
**ALL** optimization engines in Atomizer **MUST** support both single-objective and multi-objective optimization without requiring code changes. This protocol ensures system robustness and prevents runtime failures when handling Pareto optimization.
**Key Requirement**: Code must work with both `study.best_trial` (single) and `study.best_trials` (multi) APIs.
---
## When to Use
| Trigger | Action |
|---------|--------|
| 2+ objectives defined in config | Use NSGA-II sampler |
| "pareto", "multi-objective" mentioned | Load this protocol |
| "tradeoff", "competing goals" | Suggest multi-objective approach |
| "minimize X AND maximize Y" | Configure as multi-objective |
---
## Quick Reference
**Single vs. Multi-Objective API**:
| Operation | Single-Objective | Multi-Objective |
|-----------|-----------------|-----------------|
| Best trial | `study.best_trial` | `study.best_trials[0]` |
| Best params | `study.best_params` | `trial.params` |
| Best value | `study.best_value` | `trial.values` (tuple) |
| Direction | `direction='minimize'` | `directions=['minimize', 'maximize']` |
| Sampler | TPE, CMA-ES, GP | NSGA-II (mandatory) |
---
## The Problem This Solves
Previously, optimization components only supported single-objective. When used with multi-objective studies:
1. Trials run successfully
2. Trials saved to database
3. **CRASH** when compiling results
- `study.best_trial` raises RuntimeError
- No tracking files generated
- Silent failures
**Root Cause**: Optuna has different APIs:
```python
# Single-Objective (works)
study.best_trial # Returns Trial object
study.best_params # Returns dict
study.best_value # Returns float
# Multi-Objective (RAISES RuntimeError)
study.best_trial # ❌ RuntimeError
study.best_params # ❌ RuntimeError
study.best_value # ❌ RuntimeError
study.best_trials # ✓ Returns LIST of Pareto-optimal trials
```
---
## Solution Pattern
### 1. Always Check Study Type
```python
is_multi_objective = len(study.directions) > 1
```
### 2. Use Conditional Access
```python
if is_multi_objective:
best_trials = study.best_trials
if best_trials:
# Select representative trial (e.g., first Pareto solution)
representative_trial = best_trials[0]
best_params = representative_trial.params
best_value = representative_trial.values # Tuple
best_trial_num = representative_trial.number
else:
best_params = {}
best_value = None
best_trial_num = None
else:
# Single-objective: safe to use standard API
best_params = study.best_params
best_value = study.best_value
best_trial_num = study.best_trial.number
```
### 3. Return Rich Metadata
Always include in results:
```python
{
'best_params': best_params,
'best_value': best_value, # float or tuple
'best_trial': best_trial_num,
'is_multi_objective': is_multi_objective,
'pareto_front_size': len(study.best_trials) if is_multi_objective else 1,
}
```
---
## Implementation Checklist
When creating or modifying any optimization component:
- [ ] **Study Creation**: Support `directions` parameter
```python
if len(objectives) > 1:
directions = [obj['type'] for obj in objectives] # ['minimize', 'maximize']
study = optuna.create_study(directions=directions, ...)
else:
study = optuna.create_study(direction='minimize', ...)
```
- [ ] **Result Compilation**: Check `len(study.directions) > 1`
- [ ] **Best Trial Access**: Use conditional logic
- [ ] **Logging**: Print Pareto front size for multi-objective
- [ ] **Reports**: Handle tuple objectives in visualization
- [ ] **Testing**: Test with BOTH single and multi-objective cases
---
## Configuration
**Multi-Objective Config Example**:
```json
{
"objectives": [
{
"name": "stiffness",
"type": "maximize",
"description": "Structural stiffness (N/mm)",
"unit": "N/mm"
},
{
"name": "mass",
"type": "minimize",
"description": "Total mass (kg)",
"unit": "kg"
}
],
"optimization_settings": {
"sampler": "NSGAIISampler",
"n_trials": 50
}
}
```
**Objective Function Return Format**:
```python
# Single-objective: return float
def objective_single(trial):
# ... compute ...
return objective_value # float
# Multi-objective: return tuple
def objective_multi(trial):
# ... compute ...
return (stiffness, mass) # tuple of floats
```
---
## Semantic Directions
Use semantic direction values - no negative tricks:
```python
# ✅ CORRECT: Semantic directions
objectives = [
{"name": "stiffness", "type": "maximize"},
{"name": "mass", "type": "minimize"}
]
# Return: (stiffness, mass) - both positive values
# ❌ WRONG: Negative trick
def objective(trial):
return (-stiffness, mass) # Don't negate to fake maximize
```
Optuna handles directions correctly when you specify `directions=['maximize', 'minimize']`.
---
## Testing Protocol
Before marking any optimization component complete:
### Test 1: Single-Objective
```python
# Config with 1 objective
directions = None # or ['minimize']
# Run optimization
# Verify: completes without errors
```
### Test 2: Multi-Objective
```python
# Config with 2+ objectives
directions = ['minimize', 'minimize']
# Run optimization
# Verify: completes without errors
# Verify: ALL tracking files generated
```
### Test 3: Verify Outputs
- `2_results/study.db` exists
- `2_results/intelligent_optimizer/` has tracking files
- `2_results/optimization_summary.json` exists
- No RuntimeError in logs
---
## NSGA-II Configuration
For multi-objective optimization, use NSGA-II:
```python
import optuna
from optuna.samplers import NSGAIISampler
sampler = NSGAIISampler(
population_size=50, # Pareto front population
mutation_prob=None, # Auto-computed
crossover_prob=0.9, # Recombination rate
swapping_prob=0.5, # Gene swapping probability
seed=42 # Reproducibility
)
study = optuna.create_study(
directions=['maximize', 'minimize'],
sampler=sampler,
study_name="multi_objective_study",
storage="sqlite:///study.db"
)
```
---
## Pareto Front Handling
### Accessing Pareto Solutions
```python
if is_multi_objective:
pareto_trials = study.best_trials
print(f"Found {len(pareto_trials)} Pareto-optimal solutions")
for trial in pareto_trials:
print(f"Trial {trial.number}: {trial.values}")
print(f" Params: {trial.params}")
```
### Selecting Representative Solution
```python
# Option 1: First Pareto solution
representative = study.best_trials[0]
# Option 2: Weighted selection
def weighted_selection(trials, weights):
best_score = float('inf')
best_trial = None
for trial in trials:
score = sum(w * v for w, v in zip(weights, trial.values))
if score < best_score:
best_score = score
best_trial = trial
return best_trial
# Option 3: Knee point (maximum distance from ideal line)
# Requires more complex computation
```
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| RuntimeError on `best_trial` | Multi-objective study using single API | Use conditional check pattern |
| Empty Pareto front | No feasible solutions | Check constraints, relax if needed |
| Only 1 Pareto solution | Objectives not conflicting | Verify objectives are truly competing |
| NSGA-II with single objective | Wrong config | Use TPE/CMA-ES for single-objective |
---
## Cross-References
- **Depends On**: None (mandatory for all)
- **Used By**: All optimization components
- **Integrates With**:
- [SYS_10_IMSO](./SYS_10_IMSO.md) (selects NSGA-II for multi-objective)
- [SYS_13_DASHBOARD_TRACKING](./SYS_13_DASHBOARD_TRACKING.md) (Pareto visualization)
- **See Also**: [OP_04_ANALYZE_RESULTS](../operations/OP_04_ANALYZE_RESULTS.md) for Pareto analysis
---
## Implementation Files
Files that implement this protocol:
- `optimization_engine/intelligent_optimizer.py` - `_compile_results()` method
- `optimization_engine/study_continuation.py` - Result handling
- `optimization_engine/hybrid_study_creator.py` - Study creation
Files requiring this protocol:
- [ ] `optimization_engine/study_continuation.py`
- [ ] `optimization_engine/hybrid_study_creator.py`
- [ ] `optimization_engine/intelligent_setup.py`
- [ ] `optimization_engine/llm_optimization_runner.py`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-11-20 | Initial release, mandatory for all engines |

View File

@@ -0,0 +1,610 @@
# SYS_12: Extractor Library
<!--
PROTOCOL: Centralized Extractor Library
LAYER: System
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: []
-->
## Overview
The Extractor Library provides centralized, reusable functions for extracting physics results from FEA output files. **Always use these extractors instead of writing custom extraction code** in studies.
**Key Principle**: If you're writing >20 lines of extraction code in `run_optimization.py`, stop and check this library first.
---
## When to Use
| Trigger | Action |
|---------|--------|
| Need to extract displacement | Use E1 `extract_displacement` |
| Need to extract frequency | Use E2 `extract_frequency` |
| Need to extract stress | Use E3 `extract_solid_stress` |
| Need to extract mass | Use E4 or E5 |
| Need Zernike/wavefront | Use E8, E9, or E10 |
| Need custom physics | Check library first, then EXT_01 |
---
## Quick Reference
| ID | Physics | Function | Input | Output |
|----|---------|----------|-------|--------|
| E1 | Displacement | `extract_displacement()` | .op2 | mm |
| E2 | Frequency | `extract_frequency()` | .op2 | Hz |
| E3 | Von Mises Stress | `extract_solid_stress()` | .op2 | MPa |
| E4 | BDF Mass | `extract_mass_from_bdf()` | .bdf/.dat | kg |
| E5 | CAD Expression Mass | `extract_mass_from_expression()` | .prt | kg |
| E6 | Field Data | `FieldDataExtractor()` | .fld/.csv | varies |
| E7 | Stiffness | `StiffnessCalculator()` | .fld + .op2 | N/mm |
| E8 | Zernike WFE | `extract_zernike_from_op2()` | .op2 + .bdf | nm |
| E9 | Zernike Relative | `extract_zernike_relative_rms()` | .op2 + .bdf | nm |
| E10 | Zernike Builder | `ZernikeObjectiveBuilder()` | .op2 | nm |
| E11 | Part Mass & Material | `extract_part_mass_material()` | .prt | kg + dict |
| **Phase 2 (2025-12-06)** | | | | |
| E12 | Principal Stress | `extract_principal_stress()` | .op2 | MPa |
| E13 | Strain Energy | `extract_strain_energy()` | .op2 | J |
| E14 | SPC Forces | `extract_spc_forces()` | .op2 | N |
| **Phase 3 (2025-12-06)** | | | | |
| E15 | Temperature | `extract_temperature()` | .op2 | K/°C |
| E16 | Thermal Gradient | `extract_temperature_gradient()` | .op2 | K/mm |
| E17 | Heat Flux | `extract_heat_flux()` | .op2 | W/mm² |
| E18 | Modal Mass | `extract_modal_mass()` | .f06 | kg |
---
## Extractor Details
### E1: Displacement Extraction
**Module**: `optimization_engine.extractors.extract_displacement`
```python
from optimization_engine.extractors.extract_displacement import extract_displacement
result = extract_displacement(op2_file, subcase=1)
# Returns: {
# 'max_displacement': float, # mm
# 'max_disp_node': int,
# 'max_disp_x': float,
# 'max_disp_y': float,
# 'max_disp_z': float
# }
max_displacement = result['max_displacement'] # mm
```
### E2: Frequency Extraction
**Module**: `optimization_engine.extractors.extract_frequency`
```python
from optimization_engine.extractors.extract_frequency import extract_frequency
result = extract_frequency(op2_file, subcase=1, mode_number=1)
# Returns: {
# 'frequency': float, # Hz
# 'mode_number': int,
# 'eigenvalue': float,
# 'all_frequencies': list # All modes
# }
frequency = result['frequency'] # Hz
```
### E3: Von Mises Stress Extraction
**Module**: `optimization_engine.extractors.extract_von_mises_stress`
```python
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
# For shell elements (CQUAD4, CTRIA3)
result = extract_solid_stress(op2_file, subcase=1, element_type='cquad4')
# For solid elements (CTETRA, CHEXA)
result = extract_solid_stress(op2_file, subcase=1, element_type='ctetra')
# Returns: {
# 'max_von_mises': float, # MPa
# 'max_stress_element': int
# }
max_stress = result['max_von_mises'] # MPa
```
### E4: BDF Mass Extraction
**Module**: `optimization_engine.extractors.bdf_mass_extractor`
```python
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
mass_kg = extract_mass_from_bdf(str(bdf_file)) # kg
```
**Note**: Reads mass directly from BDF/DAT file material and element definitions.
### E5: CAD Expression Mass
**Module**: `optimization_engine.extractors.extract_mass_from_expression`
```python
from optimization_engine.extractors.extract_mass_from_expression import extract_mass_from_expression
mass_kg = extract_mass_from_expression(model_file, expression_name="p173") # kg
```
**Note**: Requires `_temp_mass.txt` to be written by solve journal. Uses NX expression system.
### E11: Part Mass & Material Extraction
**Module**: `optimization_engine.extractors.extract_part_mass_material`
Extracts mass, volume, surface area, center of gravity, and material properties directly from NX .prt files using NXOpen.MeasureManager.
**Prerequisites**: Run the NX journal first to create the temp file:
```bash
run_journal.exe nx_journals/extract_part_mass_material.py model.prt
```
```python
from optimization_engine.extractors import extract_part_mass_material, extract_part_mass
# Full extraction with all properties
result = extract_part_mass_material(prt_file)
# Returns: {
# 'mass_kg': float, # Mass in kg
# 'mass_g': float, # Mass in grams
# 'volume_mm3': float, # Volume in mm^3
# 'surface_area_mm2': float, # Surface area in mm^2
# 'center_of_gravity_mm': [x, y, z], # CoG in mm
# 'moments_of_inertia': {'Ixx', 'Iyy', 'Izz', 'unit'}, # or None
# 'material': {
# 'name': str or None, # Material name if assigned
# 'density': float or None, # Density in kg/mm^3
# 'density_unit': str
# },
# 'num_bodies': int
# }
mass = result['mass_kg'] # kg
material_name = result['material']['name'] # e.g., "Aluminum_6061"
# Simple mass-only extraction
mass_kg = extract_part_mass(prt_file) # kg
```
**Class-based version** for caching:
```python
from optimization_engine.extractors import PartMassExtractor
extractor = PartMassExtractor(prt_file)
mass = extractor.mass_kg # Extracts and caches
material = extractor.material_name
```
**NX Open APIs Used** (by journal):
- `NXOpen.MeasureManager.NewMassProperties()`
- `NXOpen.MeasureBodies`
- `NXOpen.Body.GetBodies()`
- `NXOpen.PhysicalMaterial`
### E6: Field Data Extraction
**Module**: `optimization_engine.extractors.field_data_extractor`
```python
from optimization_engine.extractors.field_data_extractor import FieldDataExtractor
extractor = FieldDataExtractor(
field_file="results.fld",
result_column="Temperature",
aggregation="max" # or "min", "mean", "std"
)
result = extractor.extract()
# Returns: {
# 'value': float,
# 'stats': dict
# }
```
### E7: Stiffness Calculation
**Module**: `optimization_engine.extractors.stiffness_calculator`
```python
from optimization_engine.extractors.stiffness_calculator import StiffnessCalculator
calculator = StiffnessCalculator(
field_file=field_file,
op2_file=op2_file,
force_component="FZ",
displacement_component="UZ"
)
result = calculator.calculate()
# Returns: {
# 'stiffness': float, # N/mm
# 'displacement': float,
# 'force': float
# }
```
**Simple Alternative** (when force is known):
```python
applied_force = 1000.0 # N - MUST MATCH MODEL'S APPLIED LOAD
stiffness = applied_force / max(abs(max_displacement), 1e-6) # N/mm
```
### E8: Zernike Wavefront Error (Single Subcase)
**Module**: `optimization_engine.extractors.extract_zernike`
```python
from optimization_engine.extractors.extract_zernike import extract_zernike_from_op2
result = extract_zernike_from_op2(
op2_file,
bdf_file=None, # Auto-detect from op2 location
subcase="20", # Subcase label (e.g., "20" = 20 deg elevation)
displacement_unit="mm"
)
# Returns: {
# 'global_rms_nm': float, # Total surface RMS in nm
# 'filtered_rms_nm': float, # RMS with low orders removed
# 'coefficients': list, # 50 Zernike coefficients
# 'r_squared': float,
# 'subcase': str
# }
filtered_rms = result['filtered_rms_nm'] # nm
```
### E9: Zernike Relative RMS (Between Subcases)
**Module**: `optimization_engine.extractors.extract_zernike`
```python
from optimization_engine.extractors.extract_zernike import extract_zernike_relative_rms
result = extract_zernike_relative_rms(
op2_file,
bdf_file=None,
target_subcase="40", # Target orientation
reference_subcase="20", # Reference (usually polishing orientation)
displacement_unit="mm"
)
# Returns: {
# 'relative_filtered_rms_nm': float, # Differential WFE in nm
# 'delta_coefficients': list, # Coefficient differences
# 'target_subcase': str,
# 'reference_subcase': str
# }
relative_rms = result['relative_filtered_rms_nm'] # nm
```
### E10: Zernike Objective Builder (Multi-Subcase)
**Module**: `optimization_engine.extractors.zernike_helpers`
```python
from optimization_engine.extractors.zernike_helpers import ZernikeObjectiveBuilder
builder = ZernikeObjectiveBuilder(
op2_finder=lambda: model_dir / "ASSY_M1-solution_1.op2"
)
# Add relative objectives (target vs reference)
builder.add_relative_objective("40", "20", metric="relative_filtered_rms_nm", weight=5.0)
builder.add_relative_objective("60", "20", metric="relative_filtered_rms_nm", weight=5.0)
# Add absolute objective for polishing orientation
builder.add_subcase_objective("90", metric="rms_filter_j1to3", weight=1.0)
# Evaluate all at once (efficient - parses OP2 only once)
results = builder.evaluate_all()
# Returns: {'rel_40_vs_20': 4.2, 'rel_60_vs_20': 8.7, 'rms_90': 15.3}
```
---
## Code Reuse Protocol
### The 20-Line Rule
If you're writing a function longer than ~20 lines in `run_optimization.py`:
1. **STOP** - This is a code smell
2. **SEARCH** - Check this library
3. **IMPORT** - Use existing extractor
4. **Only if truly new** - Create via EXT_01
### Correct Pattern
```python
# ✅ CORRECT: Import and use
from optimization_engine.extractors import extract_displacement, extract_frequency
def objective(trial):
# ... run simulation ...
disp_result = extract_displacement(op2_file)
freq_result = extract_frequency(op2_file)
return disp_result['max_displacement']
```
```python
# ❌ WRONG: Duplicate code in study
def objective(trial):
# ... run simulation ...
# Don't write 50 lines of OP2 parsing here
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(str(op2_file))
# ... 40 more lines ...
```
---
## Adding New Extractors
If needed physics isn't in library:
1. Check [EXT_01_CREATE_EXTRACTOR](../extensions/EXT_01_CREATE_EXTRACTOR.md)
2. Create in `optimization_engine/extractors/new_extractor.py`
3. Add to `optimization_engine/extractors/__init__.py`
4. Update this document
**Do NOT** add extraction code directly to `run_optimization.py`.
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "No displacement data found" | Wrong subcase number | Check subcase in OP2 |
| "OP2 file not found" | Solve failed | Check NX logs |
| "Unknown element type: auto" | Element type not specified | Specify `element_type='cquad4'` or `'ctetra'` |
| "No stress results in OP2" | Wrong element type specified | Use correct type for your mesh |
| Import error | Module not exported | Check `__init__.py` exports |
### Element Type Selection Guide
**Critical**: You must specify the correct element type for stress extraction based on your mesh:
| Mesh Type | Elements | `element_type=` |
|-----------|----------|-----------------|
| **Shell** (thin structures) | CQUAD4, CTRIA3 | `'cquad4'` or `'ctria3'` |
| **Solid** (3D volumes) | CTETRA, CHEXA | `'ctetra'` or `'chexa'` |
**How to check your mesh type:**
1. Open .dat/.bdf file
2. Search for element cards (CQUAD4, CTETRA, etc.)
3. Use the dominant element type
**Common models:**
- **Bracket (solid)**: Uses CTETRA → `element_type='ctetra'`
- **Beam (shell)**: Uses CQUAD4 → `element_type='cquad4'`
- **Mirror (shell)**: Uses CQUAD4 → `element_type='cquad4'`
**Von Mises column mapping** (handled automatically):
- Shell elements (8 columns): von Mises at column 7
- Solid elements (10 columns): von Mises at column 9
---
## Cross-References
- **Depends On**: pyNastran for OP2 parsing
- **Used By**: All optimization studies
- **Extended By**: [EXT_01_CREATE_EXTRACTOR](../extensions/EXT_01_CREATE_EXTRACTOR.md)
- **See Also**: [modules/extractors-catalog.md](../../.claude/skills/modules/extractors-catalog.md)
---
## Phase 2 Extractors (2025-12-06)
### E12: Principal Stress Extraction
**Module**: `optimization_engine.extractors.extract_principal_stress`
```python
from optimization_engine.extractors import extract_principal_stress
result = extract_principal_stress(op2_file, subcase=1, element_type='ctetra')
# Returns: {
# 'success': bool,
# 'sigma1_max': float, # Maximum principal stress (MPa)
# 'sigma2_max': float, # Intermediate principal stress
# 'sigma3_min': float, # Minimum principal stress
# 'element_count': int
# }
```
### E13: Strain Energy Extraction
**Module**: `optimization_engine.extractors.extract_strain_energy`
```python
from optimization_engine.extractors import extract_strain_energy, extract_total_strain_energy
result = extract_strain_energy(op2_file, subcase=1)
# Returns: {
# 'success': bool,
# 'total_strain_energy': float, # J
# 'max_element_energy': float,
# 'max_element_id': int
# }
# Convenience function
total_energy = extract_total_strain_energy(op2_file) # J
```
### E14: SPC Forces (Reaction Forces)
**Module**: `optimization_engine.extractors.extract_spc_forces`
```python
from optimization_engine.extractors import extract_spc_forces, extract_total_reaction_force
result = extract_spc_forces(op2_file, subcase=1)
# Returns: {
# 'success': bool,
# 'total_force_magnitude': float, # N
# 'total_force_x': float,
# 'total_force_y': float,
# 'total_force_z': float,
# 'node_count': int
# }
# Convenience function
total_reaction = extract_total_reaction_force(op2_file) # N
```
---
## Phase 3 Extractors (2025-12-06)
### E15: Temperature Extraction
**Module**: `optimization_engine.extractors.extract_temperature`
For SOL 153 (Steady-State) and SOL 159 (Transient) thermal analyses.
```python
from optimization_engine.extractors import extract_temperature, get_max_temperature
result = extract_temperature(op2_file, subcase=1, return_field=False)
# Returns: {
# 'success': bool,
# 'max_temperature': float, # K or °C
# 'min_temperature': float,
# 'avg_temperature': float,
# 'max_node_id': int,
# 'node_count': int,
# 'unit': str
# }
# Convenience function for constraints
max_temp = get_max_temperature(op2_file) # Returns inf on failure
```
### E16: Thermal Gradient Extraction
**Module**: `optimization_engine.extractors.extract_temperature`
```python
from optimization_engine.extractors import extract_temperature_gradient
result = extract_temperature_gradient(op2_file, subcase=1)
# Returns: {
# 'success': bool,
# 'max_gradient': float, # K/mm (approximation)
# 'temperature_range': float, # Max - Min temperature
# 'gradient_location': tuple # (max_node, min_node)
# }
```
### E17: Heat Flux Extraction
**Module**: `optimization_engine.extractors.extract_temperature`
```python
from optimization_engine.extractors import extract_heat_flux
result = extract_heat_flux(op2_file, subcase=1)
# Returns: {
# 'success': bool,
# 'max_heat_flux': float, # W/mm²
# 'avg_heat_flux': float,
# 'element_count': int
# }
```
### E18: Modal Mass Extraction
**Module**: `optimization_engine.extractors.extract_modal_mass`
For SOL 103 (Normal Modes) F06 files with MEFFMASS output.
```python
from optimization_engine.extractors import (
extract_modal_mass,
extract_frequencies,
get_first_frequency,
get_modal_mass_ratio
)
# Get all modes
result = extract_modal_mass(f06_file, mode=None)
# Returns: {
# 'success': bool,
# 'mode_count': int,
# 'frequencies': list, # Hz
# 'modes': list of mode dicts
# }
# Get specific mode
result = extract_modal_mass(f06_file, mode=1)
# Returns: {
# 'success': bool,
# 'frequency': float, # Hz
# 'modal_mass_x': float, # kg
# 'modal_mass_y': float,
# 'modal_mass_z': float,
# 'participation_x': float # 0-1
# }
# Convenience functions
freq = get_first_frequency(f06_file) # Hz
ratio = get_modal_mass_ratio(f06_file, direction='z', n_modes=10) # 0-1
```
---
## Implementation Files
```
optimization_engine/extractors/
├── __init__.py # Exports all extractors
├── extract_displacement.py # E1
├── extract_frequency.py # E2
├── extract_von_mises_stress.py # E3
├── bdf_mass_extractor.py # E4
├── extract_mass_from_expression.py # E5
├── field_data_extractor.py # E6
├── stiffness_calculator.py # E7
├── extract_zernike.py # E8, E9
├── zernike_helpers.py # E10
├── extract_part_mass_material.py # E11 (Part mass & material)
├── extract_zernike_surface.py # Surface utilities
├── op2_extractor.py # Low-level OP2 access
├── extract_principal_stress.py # E12 (Phase 2)
├── extract_strain_energy.py # E13 (Phase 2)
├── extract_spc_forces.py # E14 (Phase 2)
├── extract_temperature.py # E15, E16, E17 (Phase 3)
├── extract_modal_mass.py # E18 (Phase 3)
├── test_phase2_extractors.py # Phase 2 tests
└── test_phase3_extractors.py # Phase 3 tests
nx_journals/
└── extract_part_mass_material.py # E11 NX journal (prereq)
```
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-12-05 | Initial consolidation from scattered docs |
| 1.1 | 2025-12-06 | Added Phase 2: E12 (principal stress), E13 (strain energy), E14 (SPC forces) |
| 1.2 | 2025-12-06 | Added Phase 3: E15-E17 (thermal), E18 (modal mass) |
| 1.3 | 2025-12-07 | Added Element Type Selection Guide; documented shell vs solid stress columns |

View File

@@ -0,0 +1,435 @@
# SYS_13: Real-Time Dashboard Tracking
<!--
PROTOCOL: Real-Time Dashboard Tracking
LAYER: System
VERSION: 1.0
STATUS: Active
LAST_UPDATED: 2025-12-05
PRIVILEGE: user
LOAD_WITH: [SYS_10_IMSO, SYS_11_MULTI_OBJECTIVE]
-->
## Overview
Protocol 13 implements a comprehensive real-time web dashboard for monitoring optimization studies. It provides live visualization of optimizer state, Pareto fronts, parallel coordinates, and trial history with automatic updates every trial.
**Key Feature**: Every trial completion writes state to JSON, enabling live browser updates.
---
## When to Use
| Trigger | Action |
|---------|--------|
| "dashboard", "visualization" mentioned | Load this protocol |
| "real-time", "monitoring" requested | Enable dashboard tracking |
| Multi-objective study | Dashboard shows Pareto front |
| Want to see progress visually | Point to `localhost:3000` |
---
## Quick Reference
**Dashboard URLs**:
| Service | URL | Purpose |
|---------|-----|---------|
| Frontend | `http://localhost:3000` | Main dashboard |
| Backend API | `http://localhost:8000` | REST API |
| Optuna Dashboard | `http://localhost:8080` | Alternative viewer |
**Start Commands**:
```bash
# Backend
cd atomizer-dashboard/backend
python -m uvicorn api.main:app --reload --port 8000
# Frontend
cd atomizer-dashboard/frontend
npm run dev
```
---
## Architecture
```
Trial Completion (Optuna)
Realtime Callback (optimization_engine/realtime_tracking.py)
Write optimizer_state.json
Backend API /optimizer-state endpoint
Frontend Components (2s polling)
User sees live updates in browser
```
---
## Backend Components
### 1. Real-Time Tracking System (`realtime_tracking.py`)
**Purpose**: Write JSON state files after every trial completion.
**Integration** (in `intelligent_optimizer.py`):
```python
from optimization_engine.realtime_tracking import create_realtime_callback
# Create callback
callback = create_realtime_callback(
tracking_dir=results_dir / "intelligent_optimizer",
optimizer_ref=self,
verbose=True
)
# Register with Optuna
study.optimize(objective, n_trials=n_trials, callbacks=[callback])
```
**Data Structure** (`optimizer_state.json`):
```json
{
"timestamp": "2025-11-21T15:27:28.828930",
"trial_number": 29,
"total_trials": 50,
"current_phase": "adaptive_optimization",
"current_strategy": "GP_UCB",
"is_multi_objective": true,
"study_directions": ["maximize", "minimize"]
}
```
### 2. REST API Endpoints
**Base**: `/api/optimization/studies/{study_id}/`
| Endpoint | Method | Returns |
|----------|--------|---------|
| `/metadata` | GET | Objectives, design vars, constraints with units |
| `/optimizer-state` | GET | Current phase, strategy, progress |
| `/pareto-front` | GET | Pareto-optimal solutions (multi-objective) |
| `/history` | GET | All trial history |
| `/` | GET | List all studies |
**Unit Inference**:
```python
def _infer_objective_unit(objective: Dict) -> str:
name = objective.get("name", "").lower()
desc = objective.get("description", "").lower()
if "frequency" in name or "hz" in desc:
return "Hz"
elif "stiffness" in name or "n/mm" in desc:
return "N/mm"
elif "mass" in name or "kg" in desc:
return "kg"
# ... more patterns
```
---
## Frontend Components
### 1. OptimizerPanel (`components/OptimizerPanel.tsx`)
**Displays**:
- Current phase (Characterization, Exploration, Exploitation, Adaptive)
- Current strategy (TPE, GP, NSGA-II, etc.)
- Progress bar with trial count
- Multi-objective indicator
```
┌─────────────────────────────────┐
│ Intelligent Optimizer Status │
├─────────────────────────────────┤
│ Phase: [Adaptive Optimization] │
│ Strategy: [GP_UCB] │
│ Progress: [████████░░] 29/50 │
│ Multi-Objective: ✓ │
└─────────────────────────────────┘
```
### 2. ParetoPlot (`components/ParetoPlot.tsx`)
**Features**:
- Scatter plot of Pareto-optimal solutions
- Pareto front line connecting optimal points
- **3 Normalization Modes**:
- **Raw**: Original engineering values
- **Min-Max**: Scales to [0, 1]
- **Z-Score**: Standardizes to mean=0, std=1
- Tooltip shows raw values regardless of normalization
- Color-coded: green=feasible, red=infeasible
### 3. ParallelCoordinatesPlot (`components/ParallelCoordinatesPlot.tsx`)
**Features**:
- High-dimensional visualization (objectives + design variables)
- Interactive trial selection
- Normalized [0, 1] axes
- Color coding: green (feasible), red (infeasible), yellow (selected)
```
Stiffness Mass support_angle tip_thickness
│ │ │ │
│ ╱─────╲
╲─────────╱ │
╲ │
```
### 4. Dashboard Layout
```
┌──────────────────────────────────────────────────┐
│ Study Selection │
├──────────────────────────────────────────────────┤
│ Metrics Grid (Best, Avg, Trials, Pruned) │
├──────────────────────────────────────────────────┤
│ [OptimizerPanel] [ParetoPlot] │
├──────────────────────────────────────────────────┤
│ [ParallelCoordinatesPlot - Full Width] │
├──────────────────────────────────────────────────┤
│ [Convergence] [Parameter Space] │
├──────────────────────────────────────────────────┤
│ [Recent Trials Table] │
└──────────────────────────────────────────────────┘
```
---
## Configuration
**In `optimization_config.json`**:
```json
{
"dashboard_settings": {
"enabled": true,
"port": 8000,
"realtime_updates": true
}
}
```
**Study Requirements**:
- Must use Protocol 10 (IntelligentOptimizer) for optimizer state
- Must have `optimization_config.json` with objectives and design_variables
- Real-time tracking enabled automatically with Protocol 10
---
## Usage Workflow
### 1. Start Dashboard
```bash
# Terminal 1: Backend
cd atomizer-dashboard/backend
python -m uvicorn api.main:app --reload --port 8000
# Terminal 2: Frontend
cd atomizer-dashboard/frontend
npm run dev
```
### 2. Start Optimization
```bash
cd studies/my_study
conda activate atomizer
python run_optimization.py --n-trials 50
```
### 3. View Dashboard
- Open browser to `http://localhost:3000`
- Select study from dropdown
- Watch real-time updates every trial
### 4. Interact with Plots
- Toggle normalization on Pareto plot
- Click lines in parallel coordinates to select trials
- Hover for detailed trial information
---
## Performance
| Metric | Value |
|--------|-------|
| Backend endpoint latency | ~10ms |
| Frontend polling interval | 2 seconds |
| Real-time write overhead | <5ms per trial |
| Dashboard initial load | <500ms |
---
## Integration with Other Protocols
### Protocol 10 Integration
- Real-time callback integrated into `IntelligentOptimizer.optimize()`
- Tracks phase transitions (characterization → adaptive optimization)
- Reports strategy changes
### Protocol 11 Integration
- Pareto front endpoint checks `len(study.directions) > 1`
- Dashboard conditionally renders Pareto plots
- Uses Optuna's `study.best_trials` for Pareto front
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "No Pareto front data yet" | Single-objective or no trials | Wait for trials, check objectives |
| OptimizerPanel shows "Not available" | Not using Protocol 10 | Enable IntelligentOptimizer |
| Units not showing | Missing unit in config | Add `unit` field or use pattern in description |
| Dashboard not updating | Backend not running | Start backend with uvicorn |
| CORS errors | Backend/frontend mismatch | Check ports, restart both |
---
## Cross-References
- **Depends On**: [SYS_10_IMSO](./SYS_10_IMSO.md), [SYS_11_MULTI_OBJECTIVE](./SYS_11_MULTI_OBJECTIVE.md)
- **Used By**: [OP_03_MONITOR_PROGRESS](../operations/OP_03_MONITOR_PROGRESS.md)
- **See Also**: Optuna Dashboard for alternative visualization
---
## Implementation Files
**Backend**:
- `atomizer-dashboard/backend/api/main.py` - FastAPI app
- `atomizer-dashboard/backend/api/routes/optimization.py` - Endpoints
- `optimization_engine/realtime_tracking.py` - Callback system
**Frontend**:
- `atomizer-dashboard/frontend/src/pages/Dashboard.tsx` - Main page
- `atomizer-dashboard/frontend/src/components/OptimizerPanel.tsx`
- `atomizer-dashboard/frontend/src/components/ParetoPlot.tsx`
- `atomizer-dashboard/frontend/src/components/ParallelCoordinatesPlot.tsx`
---
## Implementation Details
### Backend API Example (FastAPI)
```python
@router.get("/studies/{study_id}/pareto-front")
async def get_pareto_front(study_id: str):
"""Get Pareto-optimal solutions for multi-objective studies."""
study = optuna.load_study(study_name=study_id, storage=storage)
if len(study.directions) == 1:
return {"is_multi_objective": False}
return {
"is_multi_objective": True,
"pareto_front": [
{
"trial_number": t.number,
"values": t.values,
"params": t.params,
"user_attrs": dict(t.user_attrs)
}
for t in study.best_trials
]
}
```
### Frontend OptimizerPanel (React/TypeScript)
```typescript
export function OptimizerPanel({ studyId }: { studyId: string }) {
const [state, setState] = useState<OptimizerState | null>(null);
useEffect(() => {
const fetchState = async () => {
const res = await fetch(`/api/optimization/studies/${studyId}/optimizer-state`);
setState(await res.json());
};
fetchState();
const interval = setInterval(fetchState, 1000);
return () => clearInterval(interval);
}, [studyId]);
return (
<Card title="Optimizer Status">
<div>Phase: {state?.current_phase}</div>
<div>Strategy: {state?.current_strategy}</div>
<ProgressBar value={state?.trial_number} max={state?.total_trials} />
</Card>
);
}
```
### Callback Integration
**CRITICAL**: Every `study.optimize()` call must include the realtime callback:
```python
# In IntelligentOptimizer
self.realtime_callback = create_realtime_callback(
tracking_dir=self.tracking_dir,
optimizer_ref=self,
verbose=self.verbose
)
# Register with ALL optimize calls
self.study.optimize(
objective_function,
n_trials=check_interval,
callbacks=[self.realtime_callback] # Required for real-time updates
)
```
---
## Chart Library Options
The dashboard supports two chart libraries:
| Feature | Recharts | Plotly |
|---------|----------|--------|
| Load Speed | Fast | Slower (lazy loaded) |
| Interactivity | Basic | Advanced |
| Export | Screenshot | PNG/SVG native |
| 3D Support | No | Yes |
| Real-time Updates | Better | Good |
**Recommendation**: Use Recharts during active optimization, Plotly for post-analysis.
### Quick Start
```bash
# Both backend and frontend
python start_dashboard.py
# Or manually:
cd atomizer-dashboard/backend && python -m uvicorn main:app --port 8000
cd atomizer-dashboard/frontend && npm run dev
```
Access at: `http://localhost:3003`
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.2 | 2025-12-05 | Added chart library options |
| 1.1 | 2025-12-05 | Added implementation code snippets |
| 1.0 | 2025-11-21 | Initial release with real-time tracking |

View File

@@ -0,0 +1,685 @@
# SYS_14: Neural Network Acceleration
<!--
PROTOCOL: Neural Network Surrogate Acceleration
LAYER: System
VERSION: 2.0
STATUS: Active
LAST_UPDATED: 2025-12-06
PRIVILEGE: user
LOAD_WITH: [SYS_10_IMSO, SYS_11_MULTI_OBJECTIVE]
-->
## Overview
Atomizer provides **neural network surrogate acceleration** enabling 100-1000x faster optimization by replacing expensive FEA evaluations with instant neural predictions.
**Two approaches available**:
1. **MLP Surrogate** (Simple, integrated) - 4-layer MLP trained on FEA data, runs within study
2. **GNN Field Predictor** (Advanced) - Graph neural network for full field predictions
**Key Innovation**: Train once on FEA data, then explore 5,000-50,000+ designs in the time it takes to run 50 FEA trials.
---
## When to Use
| Trigger | Action |
|---------|--------|
| >50 trials needed | Consider neural acceleration |
| "neural", "surrogate", "NN" mentioned | Load this protocol |
| "fast", "acceleration", "speed" needed | Suggest neural acceleration |
| Training data available | Enable surrogate |
---
## Quick Reference
**Performance Comparison**:
| Metric | Traditional FEA | Neural Network | Improvement |
|--------|-----------------|----------------|-------------|
| Time per evaluation | 10-30 minutes | 4.5 milliseconds | **2,000-500,000x** |
| Trials per hour | 2-6 | 800,000+ | **1000x** |
| Design exploration | ~50 designs | ~50,000 designs | **1000x** |
**Model Types**:
| Model | Purpose | Use When |
|-------|---------|----------|
| **MLP Surrogate** | Direct objective prediction | Simple studies, quick setup |
| Field Predictor GNN | Full displacement/stress fields | Need field visualization |
| Parametric Predictor GNN | Direct objective prediction | Complex geometry, need accuracy |
| Ensemble | Uncertainty quantification | Need confidence bounds |
---
## MLP Surrogate (Recommended for Quick Start)
### Overview
The MLP (Multi-Layer Perceptron) surrogate is a simple but effective neural network that predicts objectives directly from design parameters. It's integrated into the study workflow via `run_nn_optimization.py`.
### Architecture
```
Input Layer (N design variables)
Linear(N, 64) + ReLU + BatchNorm + Dropout(0.1)
Linear(64, 128) + ReLU + BatchNorm + Dropout(0.1)
Linear(128, 128) + ReLU + BatchNorm + Dropout(0.1)
Linear(128, 64) + ReLU + BatchNorm + Dropout(0.1)
Linear(64, M objectives)
```
**Parameters**: ~34,000 trainable
### Workflow Modes
#### 1. Standard Hybrid Mode (`--all`)
Run all phases sequentially:
```bash
python run_nn_optimization.py --all
```
Phases:
1. **Export**: Extract training data from existing FEA trials
2. **Train**: Train MLP surrogate (300 epochs default)
3. **NN-Optimize**: Run 1000 NN trials with NSGA-II
4. **Validate**: Validate top 10 candidates with FEA
#### 2. Hybrid Loop Mode (`--hybrid-loop`)
Iterative refinement:
```bash
python run_nn_optimization.py --hybrid-loop --iterations 5 --nn-trials 500
```
Each iteration:
1. Train/retrain surrogate from current FEA data
2. Run NN optimization
3. Validate top candidates with FEA
4. Add validated results to training set
5. Repeat until convergence (max error < 5%)
#### 3. Turbo Mode (`--turbo`) ⚡ RECOMMENDED
Aggressive single-best validation:
```bash
python run_nn_optimization.py --turbo --nn-trials 5000 --batch-size 100 --retrain-every 10
```
Strategy:
- Run NN in small batches (100 trials)
- Validate ONLY the single best candidate with FEA
- Add to training data immediately
- Retrain surrogate every N FEA validations
- Repeat until total NN budget exhausted
**Example**: 5,000 NN trials with batch=100 → 50 FEA validations in ~12 minutes
### Configuration
```json
{
"neural_acceleration": {
"enabled": true,
"min_training_points": 50,
"auto_train": true,
"epochs": 300,
"validation_split": 0.2,
"nn_trials": 1000,
"validate_top_n": 10,
"model_file": "surrogate_best.pt",
"separate_nn_database": true
}
}
```
**Important**: `separate_nn_database: true` stores NN trials in `nn_study.db` instead of `study.db` to avoid overloading the dashboard with thousands of NN-only results.
### Typical Accuracy
| Objective | Expected Error |
|-----------|----------------|
| Mass | 1-5% |
| Stress | 1-4% |
| Stiffness | 5-15% |
### Output Files
```
2_results/
├── study.db # Main FEA + validated results (dashboard)
├── nn_study.db # NN-only results (not in dashboard)
├── surrogate_best.pt # Trained model weights
├── training_data.json # Normalized training data
├── nn_optimization_state.json # NN optimization state
├── nn_pareto_front.json # NN-predicted Pareto front
├── validation_report.json # FEA validation results
└── turbo_report.json # Turbo mode results (if used)
```
---
## Zernike GNN (Mirror Optimization)
### Overview
The **Zernike GNN** is a specialized Graph Neural Network for mirror surface optimization. Unlike the MLP surrogate that predicts objectives directly, the Zernike GNN predicts the full displacement field, then computes Zernike coefficients and objectives via differentiable layers.
**Why GNN over MLP for Zernike?**
1. **Spatial awareness**: GNN learns smooth deformation fields via message passing
2. **Correct relative computation**: Predicts fields, then subtracts (like FEA)
3. **Multi-task learning**: Field + objective supervision
4. **Physics-informed**: Edge structure respects mirror geometry
### Architecture
```
Design Variables [11]
Design Encoder [11 → 128]
└──────────────────┐
Node Features │
[r, θ, x, y] │
│ │
▼ │
Node Encoder │
[4 → 128] │
│ │
└─────────┬────────┘
┌─────────────────────────────┐
│ Design-Conditioned │
│ Message Passing (× 6) │
│ │
│ • Polar-aware edges │
│ • Design modulates messages │
│ • Residual connections │
└─────────────┬───────────────┘
Per-Node Decoder [128 → 4]
Z-Displacement Field [3000, 4]
(one value per node per subcase)
┌─────────────────────────────┐
│ DifferentiableZernikeFit │
│ (GPU-accelerated) │
└─────────────┬───────────────┘
Zernike Coefficients → Objectives
```
### Module Structure
```
optimization_engine/gnn/
├── __init__.py # Public API
├── polar_graph.py # PolarMirrorGraph - fixed polar grid
├── zernike_gnn.py # ZernikeGNN model (design-conditioned conv)
├── differentiable_zernike.py # GPU Zernike fitting & objective layers
├── extract_displacement_field.py # OP2 → HDF5 field extraction
├── train_zernike_gnn.py # ZernikeGNNTrainer pipeline
├── gnn_optimizer.py # ZernikeGNNOptimizer for turbo mode
└── backfill_field_data.py # Extract fields from existing trials
```
### Training Workflow
```bash
# Step 1: Extract displacement fields from FEA trials
python -m optimization_engine.gnn.backfill_field_data V11
# Step 2: Train GNN on extracted data
python -m optimization_engine.gnn.train_zernike_gnn V11 V12 --epochs 200
# Step 3: Run GNN-accelerated optimization
python run_gnn_turbo.py --trials 5000
```
### Key Classes
| Class | Purpose |
|-------|---------|
| `PolarMirrorGraph` | Fixed 3000-node polar grid for mirror surface |
| `ZernikeGNN` | Main model with design-conditioned message passing |
| `DifferentiableZernikeFit` | GPU-accelerated Zernike coefficient computation |
| `ZernikeObjectiveLayer` | Compute rel_rms objectives from coefficients |
| `ZernikeGNNTrainer` | Complete training pipeline with multi-task loss |
| `ZernikeGNNOptimizer` | Turbo optimization with GNN predictions |
### Calibration
GNN predictions require calibration against FEA ground truth. Use the full FEA dataset (not just validation samples) for robust calibration:
```python
# compute_full_calibration.py
# Computes calibration factors: GNN_pred * factor ≈ FEA_truth
calibration_factors = {
'rel_filtered_rms_40_vs_20': 1.15, # GNN underpredicts by ~15%
'rel_filtered_rms_60_vs_20': 1.08,
'mfg_90_optician_workload': 0.95, # GNN overpredicts by ~5%
}
```
### Performance
| Metric | FEA | Zernike GNN |
|--------|-----|-------------|
| Time per eval | 8-10 min | 4 ms |
| Trials per hour | 6-7 | 900,000 |
| Typical accuracy | Ground truth | 5-15% error |
---
## GNN Field Predictor (Generic)
### Core Components
| Component | File | Purpose |
|-----------|------|---------|
| BDF/OP2 Parser | `neural_field_parser.py` | Convert NX files to neural format |
| Data Validator | `validate_parsed_data.py` | Physics and quality checks |
| Field Predictor | `field_predictor.py` | GNN for full field prediction |
| Parametric Predictor | `parametric_predictor.py` | GNN for direct objectives |
| Physics Loss | `physics_losses.py` | Physics-informed training |
| Neural Surrogate | `neural_surrogate.py` | Integration with Atomizer |
| Neural Runner | `runner_with_neural.py` | Optimization with NN acceleration |
### Workflow Diagram
```
Traditional:
Design → NX Model → Mesh → Solve (30 min) → Results → Objective
Neural (after training):
Design → Neural Network (4.5 ms) → Results → Objective
```
---
## Neural Model Types
### 1. Field Predictor GNN
**Use Case**: When you need full field predictions (stress distribution, deformation shape).
```
Input Features (12D per node):
├── Node coordinates (x, y, z)
├── Material properties (E, nu, rho)
├── Boundary conditions (fixed/free per DOF)
└── Load information (force magnitude, direction)
GNN Layers (6 message passing):
├── MeshGraphConv (custom for FEA topology)
├── Layer normalization
├── ReLU activation
└── Dropout (0.1)
Output (per node):
├── Displacement (6 DOF: Tx, Ty, Tz, Rx, Ry, Rz)
└── Von Mises stress (1 value)
```
**Parameters**: ~718,221 trainable
### 2. Parametric Predictor GNN (Recommended)
**Use Case**: Direct optimization objective prediction (fastest option).
```
Design Parameters (ND) → Design Encoder (MLP) → GNN Backbone → Scalar Heads
Output (objectives):
├── mass (grams)
├── frequency (Hz)
├── max_displacement (mm)
└── max_stress (MPa)
```
**Parameters**: ~500,000 trainable
### 3. Ensemble Models
**Use Case**: Uncertainty quantification.
1. Train 3-5 models with different random seeds
2. At inference, run all models
3. Use mean for prediction, std for uncertainty
4. High uncertainty → trigger FEA validation
---
## Training Pipeline
### Step 1: Collect Training Data
Enable export in workflow config:
```json
{
"training_data_export": {
"enabled": true,
"export_dir": "atomizer_field_training_data/my_study"
}
}
```
Output structure:
```
atomizer_field_training_data/my_study/
├── trial_0001/
│ ├── input/model.bdf # Nastran input
│ ├── output/model.op2 # Binary results
│ └── metadata.json # Design params + objectives
├── trial_0002/
│ └── ...
└── study_summary.json
```
**Recommended**: 100-500 FEA samples for good generalization.
### Step 2: Parse to Neural Format
```bash
cd atomizer-field
python batch_parser.py ../atomizer_field_training_data/my_study
```
Creates HDF5 + JSON files per trial.
### Step 3: Train Model
**Parametric Predictor** (recommended):
```bash
python train_parametric.py \
--train_dir ../training_data/parsed \
--val_dir ../validation_data/parsed \
--epochs 200 \
--hidden_channels 128 \
--num_layers 4
```
**Field Predictor**:
```bash
python train.py \
--train_dir ../training_data/parsed \
--epochs 200 \
--model FieldPredictorGNN \
--hidden_channels 128 \
--num_layers 6 \
--physics_loss_weight 0.3
```
### Step 4: Validate
```bash
python validate.py --checkpoint runs/my_model/checkpoint_best.pt
```
Expected output:
```
Validation Results:
├── Mean Absolute Error: 2.3% (mass), 1.8% (frequency)
├── R² Score: 0.987
├── Inference Time: 4.5ms ± 0.8ms
└── Physics Violations: 0.2%
```
### Step 5: Deploy
```json
{
"neural_surrogate": {
"enabled": true,
"model_checkpoint": "atomizer-field/runs/my_model/checkpoint_best.pt",
"confidence_threshold": 0.85
}
}
```
---
## Configuration
### Full Neural Configuration Example
```json
{
"study_name": "bracket_neural_optimization",
"surrogate_settings": {
"enabled": true,
"model_type": "parametric_gnn",
"model_path": "models/bracket_surrogate.pt",
"confidence_threshold": 0.85,
"validation_frequency": 10,
"fallback_to_fea": true
},
"training_data_export": {
"enabled": true,
"export_dir": "atomizer_field_training_data/bracket_study",
"export_bdf": true,
"export_op2": true,
"export_fields": ["displacement", "stress"]
},
"neural_optimization": {
"initial_fea_trials": 50,
"neural_trials": 5000,
"retraining_interval": 500,
"uncertainty_threshold": 0.15
}
}
```
### Configuration Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enabled` | bool | false | Enable neural surrogate |
| `model_type` | string | "parametric_gnn" | Model architecture |
| `model_path` | string | - | Path to trained model |
| `confidence_threshold` | float | 0.85 | Min confidence for predictions |
| `validation_frequency` | int | 10 | FEA validation every N trials |
| `fallback_to_fea` | bool | true | Use FEA when uncertain |
---
## Hybrid FEA/Neural Workflow
### Phase 1: FEA Exploration (50-100 trials)
- Run standard FEA optimization
- Export training data automatically
- Build landscape understanding
### Phase 2: Neural Training
- Parse collected data
- Train parametric predictor
- Validate accuracy
### Phase 3: Neural Acceleration (1000s of trials)
- Use neural network for rapid exploration
- Periodic FEA validation
- Retrain if distribution shifts
### Phase 4: FEA Refinement (10-20 trials)
- Validate top candidates with FEA
- Ensure results are physically accurate
- Generate final Pareto front
---
## Adaptive Iteration Loop
For complex optimizations, use iterative refinement:
```
┌─────────────────────────────────────────────────────────────────┐
│ Iteration 1: │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Initial FEA │ -> │ Train NN │ -> │ NN Search │ │
│ │ (50-100) │ │ Surrogate │ │ (1000 trials)│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ Iteration 2+: ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Validate Top │ -> │ Retrain NN │ -> │ NN Search │ │
│ │ NN with FEA │ │ with new data│ │ (1000 trials)│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Adaptive Configuration
```json
{
"adaptive_settings": {
"enabled": true,
"initial_fea_trials": 50,
"nn_trials_per_iteration": 1000,
"fea_validation_per_iteration": 5,
"max_iterations": 10,
"convergence_threshold": 0.01,
"retrain_epochs": 100
}
}
```
### Convergence Criteria
Stop when:
- No improvement for 2-3 consecutive iterations
- Reached FEA budget limit
- Objective improvement < 1% threshold
### Output Files
```
studies/my_study/3_results/
├── adaptive_state.json # Current iteration state
├── surrogate_model.pt # Trained neural network
└── training_history.json # NN training metrics
```
---
## Loss Functions
### Data Loss (MSE)
Standard prediction error:
```python
data_loss = MSE(predicted, target)
```
### Physics Loss
Enforce physical constraints:
```python
physics_loss = (
equilibrium_loss + # Force balance
boundary_loss + # BC satisfaction
compatibility_loss # Strain compatibility
)
```
### Combined Training
```python
total_loss = data_loss + 0.3 * physics_loss
```
Physics loss weight typically 0.1-0.5.
---
## Uncertainty Quantification
### Ensemble Method
```python
# Run N models
predictions = [model_i(x) for model_i in ensemble]
# Statistics
mean_prediction = np.mean(predictions)
uncertainty = np.std(predictions)
# Decision
if uncertainty > threshold:
# Use FEA instead
result = run_fea(x)
else:
result = mean_prediction
```
### Confidence Thresholds
| Uncertainty | Action |
|-------------|--------|
| < 5% | Use neural prediction |
| 5-15% | Use neural, flag for validation |
| > 15% | Fall back to FEA |
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| High prediction error | Insufficient training data | Collect more FEA samples |
| Out-of-distribution warnings | Design outside training range | Retrain with expanded range |
| Slow inference | Large mesh | Use parametric predictor instead |
| Physics violations | Low physics loss weight | Increase `physics_loss_weight` |
---
## Cross-References
- **Depends On**: [SYS_10_IMSO](./SYS_10_IMSO.md) for optimization framework
- **Used By**: [OP_02_RUN_OPTIMIZATION](../operations/OP_02_RUN_OPTIMIZATION.md), [OP_05_EXPORT_TRAINING_DATA](../operations/OP_05_EXPORT_TRAINING_DATA.md)
- **See Also**: [modules/neural-acceleration.md](../../.claude/skills/modules/neural-acceleration.md)
---
## Implementation Files
```
atomizer-field/
├── neural_field_parser.py # BDF/OP2 parsing
├── field_predictor.py # Field GNN
├── parametric_predictor.py # Parametric GNN
├── train.py # Field training
├── train_parametric.py # Parametric training
├── validate.py # Model validation
├── physics_losses.py # Physics-informed loss
└── batch_parser.py # Batch data conversion
optimization_engine/
├── neural_surrogate.py # Atomizer integration
└── runner_with_neural.py # Neural runner
```
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 2.1 | 2025-12-10 | Added Zernike GNN section for mirror optimization |
| 2.0 | 2025-12-06 | Added MLP Surrogate with Turbo Mode |
| 1.0 | 2025-12-05 | Initial consolidation from neural docs |

View File

@@ -0,0 +1,442 @@
# SYS_15: Adaptive Method Selector
<!--
PROTOCOL: Adaptive Method Selector
LAYER: System
VERSION: 2.0
STATUS: Active
LAST_UPDATED: 2025-12-07
PRIVILEGE: user
LOAD_WITH: [SYS_10_IMSO, SYS_11_MULTI_OBJECTIVE, SYS_14_NEURAL_ACCELERATION]
-->
## Overview
The **Adaptive Method Selector (AMS)** analyzes optimization problems and recommends the best method (turbo, hybrid_loop, pure_fea, etc.) based on:
1. **Static Analysis**: Problem characteristics from config (dimensionality, objectives, constraints)
2. **Dynamic Analysis**: Early FEA trial metrics (smoothness, correlations, feasibility)
3. **NN Quality Assessment**: Relative accuracy thresholds comparing NN error to problem variability
4. **Runtime Monitoring**: Continuous optimization performance assessment
**Key Value**: Eliminates guesswork in choosing optimization strategies by providing data-driven recommendations with relative accuracy thresholds.
---
## When to Use
| Trigger | Action |
|---------|--------|
| Starting a new optimization | Run method selector first |
| "which method", "recommend" mentioned | Suggest method selector |
| Unsure between turbo/hybrid/fea | Use method selector |
| > 20 FEA trials completed | Re-run for updated recommendation |
---
## Quick Reference
### CLI Usage
```bash
python -m optimization_engine.method_selector <config_path> [db_path]
```
**Examples**:
```bash
# Config-only analysis (before any FEA trials)
python -m optimization_engine.method_selector 1_setup/optimization_config.json
# Full analysis with FEA data
python -m optimization_engine.method_selector 1_setup/optimization_config.json 2_results/study.db
```
### Python API
```python
from optimization_engine.method_selector import AdaptiveMethodSelector
selector = AdaptiveMethodSelector()
recommendation = selector.recommend("1_setup/optimization_config.json", "2_results/study.db")
print(recommendation.method) # 'turbo', 'hybrid_loop', 'pure_fea', 'gnn_field'
print(recommendation.confidence) # 0.0 - 1.0
print(recommendation.parameters) # {'nn_trials': 5000, 'batch_size': 100, ...}
print(recommendation.reasoning) # Explanation string
print(recommendation.alternatives) # Other methods with scores
```
---
## Available Methods
| Method | Description | Best For |
|--------|-------------|----------|
| **TURBO** | Aggressive NN exploration with single-best FEA validation | Low-dimensional, smooth responses |
| **HYBRID_LOOP** | Iterative train→predict→validate→retrain cycle | Moderate complexity, uncertain landscape |
| **PURE_FEA** | Traditional FEA-only optimization | High-dimensional, complex physics |
| **GNN_FIELD** | Graph neural network for field prediction | Need full field visualization |
---
## Selection Criteria
### Static Factors (from config)
| Factor | Favors TURBO | Favors HYBRID_LOOP | Favors PURE_FEA |
|--------|--------------|---------------------|-----------------|
| **n_variables** | ≤5 | 5-10 | >10 |
| **n_objectives** | 1-3 | 2-4 | Any |
| **n_constraints** | ≤3 | 3-5 | >5 |
| **FEA budget** | >50 trials | 30-50 trials | <30 trials |
### Dynamic Factors (from FEA trials)
| Factor | Measurement | Impact |
|--------|-------------|--------|
| **Response smoothness** | Lipschitz constant estimate | Smooth → NN works well |
| **Variable sensitivity** | Correlation with objectives | High correlation → easier to learn |
| **Feasibility rate** | % of valid designs | Low feasibility → need more exploration |
| **Objective correlations** | Pairwise correlations | Strong correlations → simpler landscape |
---
## NN Quality Assessment
The method selector uses **relative accuracy thresholds** to assess NN suitability. Instead of absolute error limits, it compares NN error to the problem's natural variability (coefficient of variation).
### Core Concept
```
NN Suitability = f(nn_error / coefficient_of_variation)
If nn_error >> CV → NN is unreliable (not learning, just noise)
If nn_error ≈ CV → NN captures the trend (hybrid recommended)
If nn_error << CV → NN is excellent (turbo viable)
```
### Physics-Based Classification
Objectives are classified by their expected predictability:
| Objective Type | Examples | Max Expected Error | CV Ratio Limit |
|----------------|----------|-------------------|----------------|
| **Linear** | mass, volume | 2% | 0.5 |
| **Smooth** | frequency, avg stress | 5% | 1.0 |
| **Nonlinear** | max stress, stiffness | 10% | 2.0 |
| **Chaotic** | contact, buckling | 20% | 3.0 |
### CV Ratio Interpretation
The **CV Ratio** = NN Error / (Coefficient of Variation × 100):
| CV Ratio | Quality | Interpretation |
|----------|---------|----------------|
| < 0.5 | ✓ Great | NN captures physics much better than noise |
| 0.5 - 1.0 | ✓ Good | NN adds significant value for exploration |
| 1.0 - 2.0 | ~ OK | NN is marginal, use with validation |
| > 2.0 | ✗ Poor | NN not learning effectively, use FEA |
### Method Recommendations Based on Quality
| Turbo Suitability | Hybrid Suitability | Recommendation |
|-------------------|--------------------|-----------------------|
| > 80% | any | **TURBO** - trust NN fully |
| 50-80% | > 50% | **TURBO** with monitoring |
| < 50% | > 50% | **HYBRID_LOOP** - verify periodically |
| < 30% | < 50% | **PURE_FEA** or retrain first |
### Data Sources
NN quality metrics are collected from:
1. `validation_report.json` - FEA validation results
2. `turbo_report.json` - Turbo mode validation history
3. `study.db` - Trial `nn_error_percent` user attributes
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ AdaptiveMethodSelector │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌────────────────────┐ ┌───────────────────┐ │
│ │ ProblemProfiler │ │EarlyMetricsCollector│ │ NNQualityAssessor │ │
│ │(static analysis)│ │ (dynamic analysis) │ │ (NN accuracy) │ │
│ └───────┬─────────┘ └─────────┬──────────┘ └─────────┬─────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ _score_methods() │ │
│ │ (rule-based scoring with static + dynamic + NN factors) │ │
│ └───────────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ MethodRecommendation │ │
│ │ method, confidence, parameters, reasoning, warnings │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ RuntimeAdvisor │ ← Monitors during optimization │
│ │ (pivot advisor) │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## Components
### 1. ProblemProfiler
Extracts static problem characteristics from `optimization_config.json`:
```python
@dataclass
class ProblemProfile:
n_variables: int
variable_names: List[str]
variable_bounds: Dict[str, Tuple[float, float]]
n_objectives: int
objective_names: List[str]
n_constraints: int
fea_time_estimate: float
max_fea_trials: int
is_multi_objective: bool
has_constraints: bool
```
### 2. EarlyMetricsCollector
Computes metrics from first N FEA trials in `study.db`:
```python
@dataclass
class EarlyMetrics:
n_trials_analyzed: int
objective_means: Dict[str, float]
objective_stds: Dict[str, float]
coefficient_of_variation: Dict[str, float]
objective_correlations: Dict[str, float]
variable_objective_correlations: Dict[str, Dict[str, float]]
feasibility_rate: float
response_smoothness: float # 0-1, higher = better for NN
variable_sensitivity: Dict[str, float]
```
### 3. NNQualityAssessor
Assesses NN surrogate quality relative to problem complexity:
```python
@dataclass
class NNQualityMetrics:
has_nn_data: bool = False
n_validations: int = 0
nn_errors: Dict[str, float] # Absolute % error per objective
cv_ratios: Dict[str, float] # nn_error / (CV * 100) per objective
expected_errors: Dict[str, float] # Physics-based threshold
overall_quality: float # 0-1, based on absolute thresholds
turbo_suitability: float # 0-1, based on CV ratios
hybrid_suitability: float # 0-1, more lenient threshold
objective_types: Dict[str, str] # 'linear', 'smooth', 'nonlinear', 'chaotic'
```
### 4. AdaptiveMethodSelector
Main entry point that combines static + dynamic + NN quality analysis:
```python
selector = AdaptiveMethodSelector(min_trials=20)
recommendation = selector.recommend(config_path, db_path, results_dir=results_dir)
# Access last NN quality for display
print(f"Turbo suitability: {selector.last_nn_quality.turbo_suitability:.0%}")
```
### 5. RuntimeAdvisor
Monitors optimization progress and suggests pivots:
```python
advisor = RuntimeAdvisor()
pivot_advice = advisor.assess(db_path, config_path, current_method="turbo")
if pivot_advice.should_pivot:
print(f"Consider switching to {pivot_advice.recommended_method}")
print(f"Reason: {pivot_advice.reason}")
```
---
## Example Output
```
======================================================================
OPTIMIZATION METHOD ADVISOR
======================================================================
Problem Profile:
Variables: 2 (support_angle, tip_thickness)
Objectives: 3 (mass, stress, stiffness)
Constraints: 1
Max FEA budget: ~72 trials
NN Quality Assessment:
Validations analyzed: 10
| Objective | NN Error | CV | Ratio | Type | Quality |
|---------------|----------|--------|-------|------------|---------|
| mass | 3.7% | 16.0% | 0.23 | linear | ✓ Great |
| stress | 2.0% | 7.7% | 0.26 | nonlinear | ✓ Great |
| stiffness | 7.8% | 38.9% | 0.20 | nonlinear | ✓ Great |
Overall Quality: 22%
Turbo Suitability: 77%
Hybrid Suitability: 88%
----------------------------------------------------------------------
RECOMMENDED: TURBO
Confidence: 100%
Reason: low-dimensional design space; sufficient FEA budget; smooth landscape (79%); good NN quality (77%)
Suggested parameters:
--nn-trials: 5000
--batch-size: 100
--retrain-every: 10
--epochs: 150
Alternatives:
- hybrid_loop (90%): uncertain landscape - hybrid adapts; NN adds value with periodic retraining
- pure_fea (50%): default recommendation
Warnings:
! mass: NN error (3.7%) above expected (2%) - consider retraining or using hybrid mode
======================================================================
```
---
## Parameter Recommendations
The selector suggests optimal parameters based on problem characteristics:
| Parameter | Low-D (≤3 vars) | Medium-D (4-6 vars) | High-D (>6 vars) |
|-----------|-----------------|---------------------|------------------|
| `--nn-trials` | 5000 | 10000 | 20000 |
| `--batch-size` | 100 | 100 | 200 |
| `--retrain-every` | 10 | 15 | 20 |
| `--epochs` | 150 | 200 | 300 |
---
## Scoring Algorithm
Each method receives a score based on weighted factors:
```python
# TURBO scoring
turbo_score = 50 # base score
turbo_score += 30 if n_variables <= 5 else -20 # dimensionality
turbo_score += 25 if smoothness > 0.7 else -10 # response smoothness
turbo_score += 20 if fea_budget > 50 else -15 # budget
turbo_score += 15 if feasibility > 0.8 else -5 # feasibility
turbo_score = max(0, min(100, turbo_score)) # clamp 0-100
# Similar for HYBRID_LOOP, PURE_FEA, GNN_FIELD
```
---
## Integration with run_optimization.py
The method selector can be integrated into the optimization workflow:
```python
# At start of optimization
from optimization_engine.method_selector import recommend_method
recommendation = recommend_method(config_path, db_path)
print(f"Recommended method: {recommendation.method}")
print(f"Parameters: {recommendation.parameters}")
# Ask user confirmation
if user_confirms:
if recommendation.method == 'turbo':
os.system(f"python run_nn_optimization.py --turbo "
f"--nn-trials {recommendation.parameters['nn_trials']} "
f"--batch-size {recommendation.parameters['batch_size']}")
```
---
## Troubleshooting
| Symptom | Cause | Solution |
|---------|-------|----------|
| "Insufficient trials" | < 20 FEA trials | Run more FEA trials first |
| Low confidence score | Conflicting signals | Try hybrid_loop as safe default |
| PURE_FEA recommended | High dimensionality | Consider dimension reduction |
| GNN_FIELD recommended | Need field visualization | Set up atomizer-field |
### Config Format Compatibility
The method selector supports multiple config JSON formats:
| Old Format | New Format | Both Supported |
|------------|------------|----------------|
| `parameter` | `name` | Variable name |
| `bounds: [min, max]` | `min`, `max` | Variable bounds |
| `goal` | `direction` | Objective direction |
**Example equivalent configs:**
```json
// Old format (UAV study style)
{"design_variables": [{"parameter": "angle", "bounds": [30, 60]}]}
// New format (beam study style)
{"design_variables": [{"name": "angle", "min": 30, "max": 60}]}
```
---
## Cross-References
- **Depends On**:
- [SYS_10_IMSO](./SYS_10_IMSO.md) for optimization framework
- [SYS_14_NEURAL_ACCELERATION](./SYS_14_NEURAL_ACCELERATION.md) for neural methods
- **Used By**: [OP_02_RUN_OPTIMIZATION](../operations/OP_02_RUN_OPTIMIZATION.md)
- **See Also**: [modules/method-selection.md](../../.claude/skills/modules/method-selection.md)
---
## Implementation Files
```
optimization_engine/
└── method_selector.py # Complete AMS implementation
├── ProblemProfiler # Static config analysis
├── EarlyMetricsCollector # Dynamic FEA metrics
├── NNQualityMetrics # NN accuracy dataclass
├── NNQualityAssessor # Relative accuracy assessment
├── AdaptiveMethodSelector # Main recommendation engine
├── RuntimeAdvisor # Mid-run pivot advisor
├── print_recommendation() # CLI output with NN quality table
└── recommend_method() # Convenience function
```
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 2.1 | 2025-12-07 | Added config format flexibility (parameter/name, bounds/min-max, goal/direction) |
| 2.0 | 2025-12-07 | Added NNQualityAssessor with relative accuracy thresholds |
| 1.0 | 2025-12-06 | Initial implementation with 4 methods |

View File

@@ -0,0 +1,341 @@
"""
Auto-Documentation Generator for Atomizer
This module automatically generates documentation from code, ensuring
that skills and protocols stay in sync with the implementation.
Usage:
python -m optimization_engine.auto_doc extractors
python -m optimization_engine.auto_doc templates
python -m optimization_engine.auto_doc all
"""
import inspect
import importlib
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any, Optional
def get_extractor_info() -> List[Dict[str, Any]]:
"""Extract information about all registered extractors."""
from optimization_engine import extractors
extractor_info = []
# Get all exported functions
for name in extractors.__all__:
obj = getattr(extractors, name)
if callable(obj):
# Get function signature
try:
sig = inspect.signature(obj)
params = [
{
'name': p.name,
'default': str(p.default) if p.default != inspect.Parameter.empty else None,
'annotation': str(p.annotation) if p.annotation != inspect.Parameter.empty else None
}
for p in sig.parameters.values()
]
except (ValueError, TypeError):
params = []
# Get docstring
doc = inspect.getdoc(obj) or "No documentation available"
# Determine category
category = "general"
if "stress" in name.lower():
category = "stress"
elif "temperature" in name.lower() or "thermal" in name.lower() or "heat" in name.lower():
category = "thermal"
elif "modal" in name.lower() or "frequency" in name.lower():
category = "modal"
elif "zernike" in name.lower():
category = "optical"
elif "mass" in name.lower():
category = "mass"
elif "strain" in name.lower():
category = "strain"
elif "spc" in name.lower() or "reaction" in name.lower() or "force" in name.lower():
category = "forces"
# Determine phase
phase = "Phase 1"
if name in ['extract_principal_stress', 'extract_max_principal_stress',
'extract_min_principal_stress', 'extract_strain_energy',
'extract_total_strain_energy', 'extract_strain_energy_density',
'extract_spc_forces', 'extract_total_reaction_force',
'extract_reaction_component', 'check_force_equilibrium']:
phase = "Phase 2"
elif name in ['extract_temperature', 'extract_temperature_gradient',
'extract_heat_flux', 'get_max_temperature',
'extract_modal_mass', 'extract_frequencies',
'get_first_frequency', 'get_modal_mass_ratio']:
phase = "Phase 3"
extractor_info.append({
'name': name,
'module': obj.__module__,
'category': category,
'phase': phase,
'parameters': params,
'docstring': doc,
'is_class': inspect.isclass(obj)
})
return extractor_info
def get_template_info() -> List[Dict[str, Any]]:
"""Extract information about available study templates."""
templates_file = Path(__file__).parent / 'templates' / 'registry.json'
if not templates_file.exists():
return []
with open(templates_file) as f:
data = json.load(f)
return data.get('templates', [])
def generate_extractor_markdown(extractors: List[Dict[str, Any]]) -> str:
"""Generate markdown documentation for extractors."""
lines = [
"# Atomizer Extractor Library",
"",
f"*Auto-generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*",
"",
"This document is automatically generated from the extractor source code.",
"",
"---",
"",
"## Quick Reference",
"",
"| Extractor | Category | Phase | Description |",
"|-----------|----------|-------|-------------|",
]
for ext in sorted(extractors, key=lambda x: (x['category'], x['name'])):
doc_first_line = ext['docstring'].split('\n')[0][:60]
lines.append(f"| `{ext['name']}` | {ext['category']} | {ext['phase']} | {doc_first_line} |")
lines.extend(["", "---", ""])
# Group by category
categories = {}
for ext in extractors:
cat = ext['category']
if cat not in categories:
categories[cat] = []
categories[cat].append(ext)
for cat_name, cat_extractors in sorted(categories.items()):
lines.append(f"## {cat_name.title()} Extractors")
lines.append("")
for ext in sorted(cat_extractors, key=lambda x: x['name']):
lines.append(f"### `{ext['name']}`")
lines.append("")
lines.append(f"**Module**: `{ext['module']}`")
lines.append(f"**Phase**: {ext['phase']}")
lines.append("")
# Parameters
if ext['parameters']:
lines.append("**Parameters**:")
lines.append("")
for param in ext['parameters']:
default_str = f" = `{param['default']}`" if param['default'] else ""
lines.append(f"- `{param['name']}`{default_str}")
lines.append("")
# Docstring
lines.append("**Description**:")
lines.append("")
lines.append("```")
lines.append(ext['docstring'])
lines.append("```")
lines.append("")
lines.append("---")
lines.append("")
return '\n'.join(lines)
def generate_template_markdown(templates: List[Dict[str, Any]]) -> str:
"""Generate markdown documentation for templates."""
lines = [
"# Atomizer Study Templates",
"",
f"*Auto-generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*",
"",
"Available templates for quick study creation.",
"",
"---",
"",
"## Template Reference",
"",
"| Template | Objectives | Extractors |",
"|----------|------------|------------|",
]
for tmpl in templates:
# Handle objectives that might be dicts or strings
obj_list = tmpl.get('objectives', [])
if obj_list and isinstance(obj_list[0], dict):
objectives = ', '.join([o.get('name', str(o)) for o in obj_list])
else:
objectives = ', '.join(obj_list)
extractors = ', '.join(tmpl.get('extractors', []))
lines.append(f"| `{tmpl['name']}` | {objectives} | {extractors} |")
lines.extend(["", "---", ""])
for tmpl in templates:
lines.append(f"## {tmpl['name']}")
lines.append("")
lines.append(f"**Description**: {tmpl.get('description', 'N/A')}")
lines.append("")
lines.append(f"**Category**: {tmpl.get('category', 'N/A')}")
lines.append(f"**Solver**: {tmpl.get('solver', 'N/A')}")
lines.append(f"**Sampler**: {tmpl.get('sampler', 'N/A')}")
lines.append(f"**Turbo Suitable**: {'Yes' if tmpl.get('turbo_suitable') else 'No'}")
lines.append("")
lines.append(f"**Example Study**: `{tmpl.get('example_study', 'N/A')}`")
lines.append("")
if tmpl.get('objectives'):
lines.append("**Objectives**:")
for obj in tmpl['objectives']:
if isinstance(obj, dict):
lines.append(f"- {obj.get('name', '?')} ({obj.get('direction', '?')}) - Extractor: {obj.get('extractor', '?')}")
else:
lines.append(f"- {obj}")
lines.append("")
if tmpl.get('extractors'):
lines.append("**Extractors Used**:")
for ext in tmpl['extractors']:
lines.append(f"- {ext}")
lines.append("")
if tmpl.get('recommended_trials'):
lines.append("**Recommended Trials**:")
for key, val in tmpl['recommended_trials'].items():
lines.append(f"- {key}: {val}")
lines.append("")
lines.append("---")
lines.append("")
return '\n'.join(lines)
def generate_cheatsheet_update(extractors: List[Dict[str, Any]]) -> str:
"""Generate the extractor quick reference for 01_CHEATSHEET.md."""
lines = [
"## Extractor Quick Reference",
"",
"| Physics | Extractor | Function Call |",
"|---------|-----------|---------------|",
]
# Map categories to physics names
physics_map = {
'stress': 'Von Mises stress',
'thermal': 'Temperature',
'modal': 'Natural frequency',
'optical': 'Zernike WFE',
'mass': 'Mass',
'strain': 'Strain energy',
'forces': 'Reaction forces',
'general': 'Displacement',
}
for ext in sorted(extractors, key=lambda x: x['category']):
if ext['is_class']:
continue
physics = physics_map.get(ext['category'], ext['category'])
# Build function call example
params = ext['parameters'][:2] if ext['parameters'] else []
param_str = ', '.join([p['name'] for p in params])
lines.append(f"| {physics} | {ext['name']} | `{ext['name']}({param_str})` |")
return '\n'.join(lines)
def update_atomizer_context(extractors: List[Dict[str, Any]], templates: List[Dict[str, Any]]):
"""Update ATOMIZER_CONTEXT.md with current extractor count."""
context_file = Path(__file__).parent.parent / '.claude' / 'ATOMIZER_CONTEXT.md'
if not context_file.exists():
print(f"Warning: {context_file} not found")
return
content = context_file.read_text()
# Update extractor library version based on count
extractor_count = len(extractors)
template_count = len(templates)
print(f"Found {extractor_count} extractors and {template_count} templates")
# Could add logic here to update version info based on changes
def main():
import sys
if len(sys.argv) < 2:
print("Usage: python -m optimization_engine.auto_doc [extractors|templates|all]")
sys.exit(1)
command = sys.argv[1]
output_dir = Path(__file__).parent.parent / 'docs' / 'generated'
output_dir.mkdir(parents=True, exist_ok=True)
if command in ['extractors', 'all']:
print("Generating extractor documentation...")
extractors = get_extractor_info()
# Write full documentation
doc_content = generate_extractor_markdown(extractors)
(output_dir / 'EXTRACTORS.md').write_text(doc_content)
print(f" Written: {output_dir / 'EXTRACTORS.md'}")
# Write cheatsheet update
cheatsheet = generate_cheatsheet_update(extractors)
(output_dir / 'EXTRACTOR_CHEATSHEET.md').write_text(cheatsheet)
print(f" Written: {output_dir / 'EXTRACTOR_CHEATSHEET.md'}")
print(f" Found {len(extractors)} extractors")
if command in ['templates', 'all']:
print("Generating template documentation...")
templates = get_template_info()
if templates:
doc_content = generate_template_markdown(templates)
(output_dir / 'TEMPLATES.md').write_text(doc_content)
print(f" Written: {output_dir / 'TEMPLATES.md'}")
print(f" Found {len(templates)} templates")
else:
print(" No templates found")
if command == 'all':
print("\nUpdating ATOMIZER_CONTEXT.md...")
extractors = get_extractor_info()
templates = get_template_info()
update_atomizer_context(extractors, templates)
print("\nDone!")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,598 @@
"""
BaseOptimizationRunner - Unified base class for all optimization studies.
This module eliminates ~4,200 lines of duplicated code across study run_optimization.py files
by providing a config-driven optimization runner.
Usage:
# In study's run_optimization.py (now ~50 lines instead of ~300):
from optimization_engine.base_runner import ConfigDrivenRunner
runner = ConfigDrivenRunner(__file__)
runner.run()
Or for custom extraction logic:
from optimization_engine.base_runner import BaseOptimizationRunner
class MyStudyRunner(BaseOptimizationRunner):
def extract_objectives(self, op2_file, dat_file, design_vars):
# Custom extraction logic
return {'mass': ..., 'stress': ..., 'stiffness': ...}
runner = MyStudyRunner(__file__)
runner.run()
"""
from pathlib import Path
import sys
import json
import argparse
from datetime import datetime
from typing import Dict, Any, Optional, Tuple, List, Callable
from abc import ABC, abstractmethod
import importlib
import optuna
from optuna.samplers import NSGAIISampler, TPESampler
class ConfigNormalizer:
"""
Normalizes different config formats to a standard internal format.
Handles variations like:
- 'parameter' vs 'name' for variable names
- 'bounds' vs 'min'/'max' for ranges
- 'goal' vs 'direction' for objective direction
"""
@staticmethod
def normalize_config(config: Dict) -> Dict:
"""Convert any config format to standardized format."""
normalized = {
'study_name': config.get('study_name', 'unnamed_study'),
'description': config.get('description', ''),
'design_variables': [],
'objectives': [],
'constraints': [],
'simulation': {},
'optimization': {},
'neural_acceleration': config.get('neural_acceleration', {}),
}
# Normalize design variables
for var in config.get('design_variables', []):
normalized['design_variables'].append({
'name': var.get('parameter') or var.get('name'),
'type': var.get('type', 'continuous'),
'min': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[0] if 'bounds' in var else var.get('min', 0),
'max': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[1] if 'bounds' in var else var.get('max', 1),
'units': var.get('units', ''),
'description': var.get('description', ''),
})
# Normalize objectives
for obj in config.get('objectives', []):
normalized['objectives'].append({
'name': obj.get('name'),
'direction': obj.get('goal') or obj.get('direction', 'minimize'),
'description': obj.get('description', ''),
'extraction': obj.get('extraction', {}),
})
# Normalize constraints
for con in config.get('constraints', []):
normalized['constraints'].append({
'name': con.get('name'),
'type': con.get('type', 'less_than'),
'value': con.get('threshold') or con.get('value', 0),
'units': con.get('units', ''),
'description': con.get('description', ''),
'extraction': con.get('extraction', {}),
})
# Normalize simulation settings
sim = config.get('simulation', {})
normalized['simulation'] = {
'prt_file': sim.get('prt_file') or sim.get('model_file', ''),
'sim_file': sim.get('sim_file', ''),
'fem_file': sim.get('fem_file', ''),
'dat_file': sim.get('dat_file', ''),
'op2_file': sim.get('op2_file', ''),
'solution_name': sim.get('solution_name', 'Solution 1'),
'solver': sim.get('solver', 'nastran'),
}
# Normalize optimization settings
opt = config.get('optimization', config.get('optimization_settings', {}))
normalized['optimization'] = {
'algorithm': opt.get('algorithm') or opt.get('sampler', 'NSGAIISampler'),
'n_trials': opt.get('n_trials', 100),
'population_size': opt.get('population_size', 20),
'seed': opt.get('seed', 42),
'timeout_per_trial': opt.get('timeout_per_trial', 600),
}
return normalized
class BaseOptimizationRunner(ABC):
"""
Abstract base class for optimization runners.
Subclasses must implement extract_objectives() to define how
physics results are extracted from FEA output files.
"""
def __init__(self, script_path: str, config_path: Optional[str] = None):
"""
Initialize the runner.
Args:
script_path: Path to the study's run_optimization.py (__file__)
config_path: Optional explicit path to config file
"""
self.study_dir = Path(script_path).parent
self.config_path = Path(config_path) if config_path else self._find_config()
self.model_dir = self.study_dir / "1_setup" / "model"
self.results_dir = self.study_dir / "2_results"
# Load and normalize config
with open(self.config_path, 'r') as f:
self.raw_config = json.load(f)
self.config = ConfigNormalizer.normalize_config(self.raw_config)
self.study_name = self.config['study_name']
self.logger = None
self.nx_solver = None
def _find_config(self) -> Path:
"""Find the optimization config file."""
candidates = [
self.study_dir / "optimization_config.json",
self.study_dir / "1_setup" / "optimization_config.json",
]
for path in candidates:
if path.exists():
return path
raise FileNotFoundError(f"No optimization_config.json found in {self.study_dir}")
def _setup(self):
"""Initialize solver and logger."""
# Add project root to path
project_root = self.study_dir.parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from optimization_engine.nx_solver import NXSolver
from optimization_engine.logger import get_logger
self.results_dir.mkdir(exist_ok=True)
self.logger = get_logger(self.study_name, study_dir=self.results_dir)
self.nx_solver = NXSolver(nastran_version="2506")
def sample_design_variables(self, trial: optuna.Trial) -> Dict[str, float]:
"""Sample design variables from the config."""
design_vars = {}
for var in self.config['design_variables']:
name = var['name']
if var['type'] == 'integer':
design_vars[name] = trial.suggest_int(name, int(var['min']), int(var['max']))
else:
design_vars[name] = trial.suggest_float(name, var['min'], var['max'])
return design_vars
def run_simulation(self, design_vars: Dict[str, float]) -> Dict[str, Any]:
"""Run the FEA simulation with given design variables."""
sim_file = self.model_dir / self.config['simulation']['sim_file']
result = self.nx_solver.run_simulation(
sim_file=sim_file,
working_dir=self.model_dir,
expression_updates=design_vars,
solution_name=self.config['simulation'].get('solution_name'),
cleanup=True
)
return result
@abstractmethod
def extract_objectives(self, op2_file: Path, dat_file: Path,
design_vars: Dict[str, float]) -> Dict[str, float]:
"""
Extract objective values from FEA results.
Args:
op2_file: Path to OP2 results file
dat_file: Path to DAT/BDF file
design_vars: Design variable values for this trial
Returns:
Dictionary of objective names to values
"""
pass
def check_constraints(self, objectives: Dict[str, float],
op2_file: Path) -> Tuple[bool, Dict[str, float]]:
"""
Check if constraints are satisfied.
Returns:
Tuple of (feasible, constraint_values)
"""
feasible = True
constraint_values = {}
for con in self.config['constraints']:
name = con['name']
threshold = con['value']
con_type = con['type']
# Try to get constraint value from objectives or extract
if name in objectives:
value = objectives[name]
elif 'stress' in name.lower() and 'stress' in objectives:
value = objectives['stress']
elif 'displacement' in name.lower() and 'displacement' in objectives:
value = objectives['displacement']
else:
# Need to extract separately
value = 0 # Default
constraint_values[name] = value
if con_type == 'less_than' and value > threshold:
feasible = False
self.logger.warning(f' Constraint violation: {name} = {value:.2f} > {threshold}')
elif con_type == 'greater_than' and value < threshold:
feasible = False
self.logger.warning(f' Constraint violation: {name} = {value:.2f} < {threshold}')
return feasible, constraint_values
def objective_function(self, trial: optuna.Trial) -> Tuple[float, ...]:
"""
Main objective function for Optuna optimization.
Returns tuple of objective values for multi-objective optimization.
"""
design_vars = self.sample_design_variables(trial)
self.logger.trial_start(trial.number, design_vars)
try:
# Run simulation
result = self.run_simulation(design_vars)
if not result['success']:
self.logger.trial_failed(trial.number, f"Simulation failed: {result.get('error', 'Unknown')}")
return tuple([float('inf')] * len(self.config['objectives']))
op2_file = result['op2_file']
dat_file = self.model_dir / self.config['simulation']['dat_file']
# Extract objectives
objectives = self.extract_objectives(op2_file, dat_file, design_vars)
# Check constraints
feasible, constraint_values = self.check_constraints(objectives, op2_file)
# Set user attributes
for name, value in objectives.items():
trial.set_user_attr(name, value)
trial.set_user_attr('feasible', feasible)
self.logger.trial_complete(trial.number, objectives, constraint_values, feasible)
# Return objectives in order, converting maximize to minimize
obj_values = []
for obj_config in self.config['objectives']:
name = obj_config['name']
value = objectives.get(name, float('inf'))
if obj_config['direction'] == 'maximize':
value = -value # Negate for maximization
obj_values.append(value)
return tuple(obj_values)
except Exception as e:
self.logger.trial_failed(trial.number, str(e))
return tuple([float('inf')] * len(self.config['objectives']))
def get_sampler(self):
"""Get the appropriate Optuna sampler based on config."""
alg = self.config['optimization']['algorithm']
pop_size = self.config['optimization']['population_size']
seed = self.config['optimization']['seed']
if 'NSGA' in alg.upper():
return NSGAIISampler(population_size=pop_size, seed=seed)
elif 'TPE' in alg.upper():
return TPESampler(seed=seed)
else:
return NSGAIISampler(population_size=pop_size, seed=seed)
def get_directions(self) -> List[str]:
"""Get optimization directions for all objectives."""
# All directions are 'minimize' since we negate maximize objectives
return ['minimize'] * len(self.config['objectives'])
def clean_nastran_files(self):
"""Remove old Nastran solver output files."""
patterns = ['*.op2', '*.f06', '*.log', '*.f04', '*.pch', '*.DBALL', '*.MASTER', '_temp*.txt']
deleted = []
for pattern in patterns:
for f in self.model_dir.glob(pattern):
try:
f.unlink()
deleted.append(f)
self.logger.info(f" Deleted: {f.name}")
except Exception as e:
self.logger.warning(f" Failed to delete {f.name}: {e}")
return deleted
def print_study_info(self):
"""Print study information to console."""
print("\n" + "=" * 60)
print(f" {self.study_name.upper()}")
print("=" * 60)
print(f"\nDescription: {self.config['description']}")
print(f"\nDesign Variables ({len(self.config['design_variables'])}):")
for var in self.config['design_variables']:
print(f" - {var['name']}: {var['min']}-{var['max']} {var['units']}")
print(f"\nObjectives ({len(self.config['objectives'])}):")
for obj in self.config['objectives']:
print(f" - {obj['name']}: {obj['direction']}")
print(f"\nConstraints ({len(self.config['constraints'])}):")
for c in self.config['constraints']:
print(f" - {c['name']}: < {c['value']} {c['units']}")
print()
def run(self, args=None):
"""
Main entry point for running optimization.
Args:
args: Optional argparse Namespace. If None, will parse sys.argv
"""
if args is None:
args = self.parse_args()
self._setup()
if args.clean:
self.clean_nastran_files()
self.print_study_info()
# Determine number of trials and storage
if args.discover:
n_trials = 1
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
study_suffix = "_discover"
elif args.validate:
n_trials = 1
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
study_suffix = "_validate"
elif args.test:
n_trials = 3
storage = f"sqlite:///{self.results_dir / 'study_test.db'}"
study_suffix = "_test"
else:
n_trials = args.trials
storage = f"sqlite:///{self.results_dir / 'study.db'}"
study_suffix = ""
# Create or load study
full_study_name = f"{self.study_name}{study_suffix}"
if args.resume and study_suffix == "":
study = optuna.load_study(
study_name=self.study_name,
storage=storage,
sampler=self.get_sampler()
)
print(f"\nResuming study with {len(study.trials)} existing trials...")
else:
study = optuna.create_study(
study_name=full_study_name,
storage=storage,
sampler=self.get_sampler(),
directions=self.get_directions(),
load_if_exists=(study_suffix == "")
)
# Run optimization
if study_suffix == "":
self.logger.study_start(self.study_name, n_trials,
self.config['optimization']['algorithm'])
print(f"\nRunning {n_trials} trials...")
study.optimize(
self.objective_function,
n_trials=n_trials,
show_progress_bar=True
)
# Report results
n_complete = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
if study_suffix == "":
self.logger.study_complete(self.study_name, len(study.trials), n_complete)
print("\n" + "=" * 60)
print(" COMPLETE!")
print("=" * 60)
print(f"\nTotal trials: {len(study.trials)}")
print(f"Successful: {n_complete}")
if hasattr(study, 'best_trials'):
print(f"Pareto front: {len(study.best_trials)} solutions")
if study_suffix == "":
print("\nNext steps:")
print(" 1. Run method selector:")
print(f" python -m optimization_engine.method_selector {self.config_path.relative_to(self.study_dir)} 2_results/study.db")
print(" 2. If turbo recommended, run neural acceleration")
return 0
def parse_args(self) -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description=f'{self.study_name} - Optimization')
stage_group = parser.add_mutually_exclusive_group()
stage_group.add_argument('--discover', action='store_true', help='Discover model outputs (1 trial)')
stage_group.add_argument('--validate', action='store_true', help='Run single validation trial')
stage_group.add_argument('--test', action='store_true', help='Run 3-trial test')
stage_group.add_argument('--run', action='store_true', help='Run full optimization')
parser.add_argument('--trials', type=int,
default=self.config['optimization']['n_trials'],
help='Number of trials')
parser.add_argument('--resume', action='store_true', help='Resume existing study')
parser.add_argument('--clean', action='store_true', help='Clean old files first')
args = parser.parse_args()
if not any([args.discover, args.validate, args.test, args.run]):
print("No stage specified. Use --discover, --validate, --test, or --run")
print("\nTypical workflow:")
print(" 1. python run_optimization.py --discover # Discover model outputs")
print(" 2. python run_optimization.py --validate # Single trial validation")
print(" 3. python run_optimization.py --test # Quick 3-trial test")
print(f" 4. python run_optimization.py --run --trials {self.config['optimization']['n_trials']} # Full run")
sys.exit(1)
return args
class ConfigDrivenRunner(BaseOptimizationRunner):
"""
Fully config-driven optimization runner.
Automatically extracts objectives based on config file definitions.
Supports standard extractors: mass, stress, displacement, stiffness.
"""
def __init__(self, script_path: str, config_path: Optional[str] = None,
element_type: str = 'auto'):
"""
Initialize config-driven runner.
Args:
script_path: Path to the study's script (__file__)
config_path: Optional explicit path to config
element_type: Element type for stress extraction ('ctetra', 'cquad4', 'auto')
"""
super().__init__(script_path, config_path)
self.element_type = element_type
self._extractors_loaded = False
self._extractors = {}
def _load_extractors(self):
"""Lazy-load extractor functions."""
if self._extractors_loaded:
return
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
from optimization_engine.extractors.extract_displacement import extract_displacement
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
self._extractors = {
'extract_mass_from_bdf': extract_mass_from_bdf,
'extract_displacement': extract_displacement,
'extract_solid_stress': extract_solid_stress,
}
self._extractors_loaded = True
def _detect_element_type(self, dat_file: Path) -> str:
"""Auto-detect element type from BDF/DAT file."""
if self.element_type != 'auto':
return self.element_type
try:
with open(dat_file, 'r') as f:
content = f.read(50000) # Read first 50KB
if 'CTETRA' in content:
return 'ctetra'
elif 'CHEXA' in content:
return 'chexa'
elif 'CQUAD4' in content:
return 'cquad4'
elif 'CTRIA3' in content:
return 'ctria3'
else:
return 'ctetra' # Default
except Exception:
return 'ctetra'
def extract_objectives(self, op2_file: Path, dat_file: Path,
design_vars: Dict[str, float]) -> Dict[str, float]:
"""
Extract all objectives based on config.
Handles common objectives: mass, stress, displacement, stiffness
"""
self._load_extractors()
objectives = {}
element_type = self._detect_element_type(dat_file)
for obj_config in self.config['objectives']:
name = obj_config['name'].lower()
try:
if 'mass' in name:
objectives[obj_config['name']] = self._extractors['extract_mass_from_bdf'](str(dat_file))
self.logger.info(f" {obj_config['name']}: {objectives[obj_config['name']]:.2f} kg")
elif 'stress' in name:
stress_result = self._extractors['extract_solid_stress'](
op2_file, subcase=1, element_type=element_type
)
# Convert kPa to MPa
stress_mpa = stress_result.get('max_von_mises', float('inf')) / 1000.0
objectives[obj_config['name']] = stress_mpa
self.logger.info(f" {obj_config['name']}: {stress_mpa:.2f} MPa")
elif 'displacement' in name:
disp_result = self._extractors['extract_displacement'](op2_file, subcase=1)
objectives[obj_config['name']] = disp_result['max_displacement']
self.logger.info(f" {obj_config['name']}: {disp_result['max_displacement']:.3f} mm")
elif 'stiffness' in name:
disp_result = self._extractors['extract_displacement'](op2_file, subcase=1)
max_disp = disp_result['max_displacement']
applied_force = 1000.0 # N - standard assumption
stiffness = applied_force / max(abs(max_disp), 1e-6)
objectives[obj_config['name']] = stiffness
objectives['displacement'] = max_disp # Store for constraint check
self.logger.info(f" {obj_config['name']}: {stiffness:.1f} N/mm")
self.logger.info(f" displacement: {max_disp:.3f} mm")
else:
self.logger.warning(f" Unknown objective: {name}")
objectives[obj_config['name']] = float('inf')
except Exception as e:
self.logger.error(f" Failed to extract {name}: {e}")
objectives[obj_config['name']] = float('inf')
return objectives
def create_runner(script_path: str, element_type: str = 'auto') -> ConfigDrivenRunner:
"""
Factory function to create a ConfigDrivenRunner.
Args:
script_path: Path to the study's run_optimization.py (__file__)
element_type: Element type for stress extraction
Returns:
Configured runner ready to execute
"""
return ConfigDrivenRunner(script_path, element_type=element_type)

View File

@@ -2,10 +2,25 @@
Available extractors:
- Displacement: extract_displacement
- Stress: extract_solid_stress (von Mises)
- Stress: extract_solid_stress (von Mises), extract_principal_stress
- Frequency: extract_frequency
- Mass: extract_mass_from_expression, extract_mass_from_op2
- Mass (BDF): extract_mass_from_bdf
- Mass (Expression): extract_mass_from_expression
- Mass (Part): extract_part_mass_material, extract_part_mass, PartMassExtractor
- Strain Energy: extract_strain_energy, extract_total_strain_energy
- SPC Forces: extract_spc_forces, extract_total_reaction_force
- Zernike: extract_zernike_from_op2, ZernikeExtractor (telescope mirrors)
Phase 2 Extractors (2025-12-06):
- Principal stress extraction (sigma1, sigma2, sigma3)
- Strain energy extraction (element strain energy)
- SPC forces extraction (reaction forces at boundary conditions)
Phase 3 Extractors (2025-12-06):
- Temperature extraction (thermal analysis: SOL 153/159)
- Thermal gradient extraction
- Heat flux extraction
- Modal mass extraction (modal effective mass from F06)
"""
# Zernike extractor for telescope mirror optimization
@@ -16,10 +31,90 @@ from optimization_engine.extractors.extract_zernike import (
extract_zernike_relative_rms,
)
# Part mass and material extractor (from NX .prt files)
from optimization_engine.extractors.extract_part_mass_material import (
extract_part_mass_material,
extract_part_mass,
extract_part_material,
PartMassExtractor,
)
# Von Mises stress extraction
from optimization_engine.extractors.extract_von_mises_stress import (
extract_solid_stress,
)
# Principal stress extraction (Phase 2)
from optimization_engine.extractors.extract_principal_stress import (
extract_principal_stress,
extract_max_principal_stress,
extract_min_principal_stress,
)
# Strain energy extraction (Phase 2)
from optimization_engine.extractors.extract_strain_energy import (
extract_strain_energy,
extract_total_strain_energy,
extract_strain_energy_density,
)
# SPC forces / reaction forces extraction (Phase 2)
from optimization_engine.extractors.extract_spc_forces import (
extract_spc_forces,
extract_total_reaction_force,
extract_reaction_component,
check_force_equilibrium,
)
# Temperature extraction (Phase 3)
from optimization_engine.extractors.extract_temperature import (
extract_temperature,
extract_temperature_gradient,
extract_heat_flux,
get_max_temperature,
)
# Modal mass extraction (Phase 3)
from optimization_engine.extractors.extract_modal_mass import (
extract_modal_mass,
extract_frequencies,
get_first_frequency,
get_modal_mass_ratio,
)
__all__ = [
# Part mass & material (from .prt)
'extract_part_mass_material',
'extract_part_mass',
'extract_part_material',
'PartMassExtractor',
# Stress extractors
'extract_solid_stress',
'extract_principal_stress',
'extract_max_principal_stress',
'extract_min_principal_stress',
# Strain energy
'extract_strain_energy',
'extract_total_strain_energy',
'extract_strain_energy_density',
# SPC forces / reactions
'extract_spc_forces',
'extract_total_reaction_force',
'extract_reaction_component',
'check_force_equilibrium',
# Zernike (telescope mirrors)
'ZernikeExtractor',
'extract_zernike_from_op2',
'extract_zernike_filtered_rms',
'extract_zernike_relative_rms',
# Temperature (Phase 3 - thermal)
'extract_temperature',
'extract_temperature_gradient',
'extract_heat_flux',
'get_max_temperature',
# Modal mass (Phase 3 - dynamics)
'extract_modal_mass',
'extract_frequencies',
'get_first_frequency',
'get_modal_mass_ratio',
]

View File

@@ -0,0 +1,491 @@
"""
Modal Mass Extractor for Dynamic Analysis Results
==================================================
Phase 3 Task 3.4 - NX Open Automation Roadmap
Extracts modal effective mass and participation factors from Nastran F06 files.
This is essential for dynamic optimization where mode shapes affect system response.
Usage:
from optimization_engine.extractors.extract_modal_mass import extract_modal_mass
result = extract_modal_mass("path/to/modal.f06", mode=1)
print(f"Modal mass X: {result['modal_mass_x']} kg")
Supports:
- SOL 103 (Normal Modes / Eigenvalue Analysis)
- SOL 111 (Modal Frequency Response)
- Modal effective mass table parsing
- Participation factor extraction
"""
import re
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, List, Union, Tuple
import logging
logger = logging.getLogger(__name__)
def extract_modal_mass(
f06_file: Union[str, Path],
mode: Optional[int] = None,
direction: str = 'all'
) -> Dict[str, Any]:
"""
Extract modal effective mass from F06 file.
Modal effective mass indicates how much of the total mass participates
in each mode for each direction. Important for:
- Base excitation problems
- Seismic analysis
- Random vibration
Args:
f06_file: Path to the F06 results file
mode: Mode number to extract (1-indexed). If None, returns all modes.
direction: Direction(s) to extract:
'x', 'y', 'z' - single direction
'all' - all directions (default)
'total' - sum of all directions
Returns:
dict: {
'success': bool,
'modes': list of mode data (if mode=None),
'modal_mass_x': float (kg) - effective mass in X,
'modal_mass_y': float (kg) - effective mass in Y,
'modal_mass_z': float (kg) - effective mass in Z,
'modal_mass_rx': float (kg·m²) - rotational mass about X,
'modal_mass_ry': float (kg·m²) - rotational mass about Y,
'modal_mass_rz': float (kg·m²) - rotational mass about Z,
'participation_x': float (0-1) - participation factor X,
'participation_y': float (0-1) - participation factor Y,
'participation_z': float (0-1) - participation factor Z,
'frequency': float (Hz) - natural frequency,
'cumulative_mass_x': float - cumulative mass fraction X,
'cumulative_mass_y': float - cumulative mass fraction Y,
'cumulative_mass_z': float - cumulative mass fraction Z,
'total_mass': float (kg) - total model mass,
'error': str or None
}
Example:
>>> result = extract_modal_mass("modal_analysis.f06", mode=1)
>>> if result['success']:
... print(f"Mode 1 frequency: {result['frequency']:.2f} Hz")
... print(f"X participation: {result['participation_x']*100:.1f}%")
"""
f06_path = Path(f06_file)
if not f06_path.exists():
return {
'success': False,
'error': f"F06 file not found: {f06_path}",
'modes': [],
'modal_mass_x': None,
'modal_mass_y': None,
'modal_mass_z': None,
'frequency': None
}
try:
with open(f06_path, 'r', errors='ignore') as f:
content = f.read()
# Parse modal effective mass table
modes_data = _parse_modal_effective_mass(content)
if not modes_data:
# Try alternative format (participation factors)
modes_data = _parse_participation_factors(content)
if not modes_data:
return {
'success': False,
'error': "No modal effective mass or participation factor table found in F06. "
"Ensure MEFFMASS case control is present.",
'modes': [],
'modal_mass_x': None,
'modal_mass_y': None,
'modal_mass_z': None,
'frequency': None
}
# Extract total mass from F06
total_mass = _extract_total_mass(content)
if mode is not None:
# Return specific mode
if mode < 1 or mode > len(modes_data):
return {
'success': False,
'error': f"Mode {mode} not found. Available modes: 1-{len(modes_data)}",
'modes': modes_data,
'modal_mass_x': None,
'modal_mass_y': None,
'modal_mass_z': None,
'frequency': None
}
mode_data = modes_data[mode - 1]
return {
'success': True,
'error': None,
'mode_number': mode,
'frequency': mode_data.get('frequency'),
'modal_mass_x': mode_data.get('mass_x'),
'modal_mass_y': mode_data.get('mass_y'),
'modal_mass_z': mode_data.get('mass_z'),
'modal_mass_rx': mode_data.get('mass_rx'),
'modal_mass_ry': mode_data.get('mass_ry'),
'modal_mass_rz': mode_data.get('mass_rz'),
'participation_x': mode_data.get('participation_x'),
'participation_y': mode_data.get('participation_y'),
'participation_z': mode_data.get('participation_z'),
'cumulative_mass_x': mode_data.get('cumulative_x'),
'cumulative_mass_y': mode_data.get('cumulative_y'),
'cumulative_mass_z': mode_data.get('cumulative_z'),
'total_mass': total_mass,
'modes': modes_data
}
else:
# Return all modes summary
return {
'success': True,
'error': None,
'mode_count': len(modes_data),
'modes': modes_data,
'total_mass': total_mass,
'frequencies': [m.get('frequency') for m in modes_data],
'dominant_mode_x': _find_dominant_mode(modes_data, 'x'),
'dominant_mode_y': _find_dominant_mode(modes_data, 'y'),
'dominant_mode_z': _find_dominant_mode(modes_data, 'z'),
}
except Exception as e:
logger.exception(f"Error extracting modal mass from {f06_path}")
return {
'success': False,
'error': str(e),
'modes': [],
'modal_mass_x': None,
'modal_mass_y': None,
'modal_mass_z': None,
'frequency': None
}
def _parse_modal_effective_mass(content: str) -> List[Dict[str, Any]]:
"""Parse modal effective mass table from F06 content."""
modes = []
# Pattern for modal effective mass table header
# This varies by Nastran version, so we try multiple patterns
# Pattern 1: Standard MEFFMASS output
# MODE FREQUENCY T1 T2 T3 R1 R2 R3
pattern1 = re.compile(
r'MODAL\s+EFFECTIVE\s+MASS.*?'
r'MODE\s+FREQUENCY.*?'
r'((?:\s*\d+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)',
re.IGNORECASE | re.DOTALL
)
# Pattern 2: Alternative format
pattern2 = re.compile(
r'EFFECTIVE\s+MASS\s+FRACTION.*?'
r'((?:\s*\d+\s+[\d.E+-]+.*?\n)+)',
re.IGNORECASE | re.DOTALL
)
# Try to find modal effective mass table
match = pattern1.search(content)
if not match:
match = pattern2.search(content)
if match:
table_text = match.group(1)
lines = table_text.strip().split('\n')
for line in lines:
# Parse each mode line
# Expected format: MODE FREQ T1 T2 T3 R1 R2 R3 (or subset)
parts = line.split()
if len(parts) >= 3:
try:
mode_num = int(parts[0])
frequency = float(parts[1])
mode_data = {
'mode': mode_num,
'frequency': frequency,
'mass_x': float(parts[2]) if len(parts) > 2 else 0.0,
'mass_y': float(parts[3]) if len(parts) > 3 else 0.0,
'mass_z': float(parts[4]) if len(parts) > 4 else 0.0,
'mass_rx': float(parts[5]) if len(parts) > 5 else 0.0,
'mass_ry': float(parts[6]) if len(parts) > 6 else 0.0,
'mass_rz': float(parts[7]) if len(parts) > 7 else 0.0,
}
modes.append(mode_data)
except (ValueError, IndexError):
continue
return modes
def _parse_participation_factors(content: str) -> List[Dict[str, Any]]:
"""Parse modal participation factors from F06 content."""
modes = []
# Pattern for eigenvalue/frequency table with participation
# This is the more common output format
eigenvalue_pattern = re.compile(
r'R E A L\s+E I G E N V A L U E S.*?'
r'MODE\s+NO\.\s+EXTRACTION\s+ORDER\s+EIGENVALUE\s+RADIANS\s+CYCLES\s+GENERALIZED\s+GENERALIZED\s*\n'
r'\s+MASS\s+STIFFNESS\s*\n'
r'((?:\s*\d+\s+\d+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)',
re.IGNORECASE | re.DOTALL
)
match = eigenvalue_pattern.search(content)
if match:
table_text = match.group(1)
lines = table_text.strip().split('\n')
for line in lines:
parts = line.split()
if len(parts) >= 5:
try:
mode_num = int(parts[0])
# parts[1] = extraction order
# parts[2] = eigenvalue
# parts[3] = radians
frequency = float(parts[4]) # cycles (Hz)
gen_mass = float(parts[5]) if len(parts) > 5 else 1.0
gen_stiff = float(parts[6]) if len(parts) > 6 else 0.0
mode_data = {
'mode': mode_num,
'frequency': frequency,
'generalized_mass': gen_mass,
'generalized_stiffness': gen_stiff,
# Participation factors may need separate parsing
'mass_x': 0.0,
'mass_y': 0.0,
'mass_z': 0.0,
}
modes.append(mode_data)
except (ValueError, IndexError):
continue
# Also try to find participation factor table
participation_pattern = re.compile(
r'MODAL\s+PARTICIPATION\s+FACTORS.*?'
r'((?:\s*\d+\s+[\d.E+-]+\s+[\d.E+-]+\s+[\d.E+-]+.*?\n)+)',
re.IGNORECASE | re.DOTALL
)
match = participation_pattern.search(content)
if match and modes:
table_text = match.group(1)
lines = table_text.strip().split('\n')
for i, line in enumerate(lines):
parts = line.split()
if len(parts) >= 4 and i < len(modes):
try:
modes[i]['participation_x'] = float(parts[1])
modes[i]['participation_y'] = float(parts[2])
modes[i]['participation_z'] = float(parts[3])
except (ValueError, IndexError):
pass
return modes
def _extract_total_mass(content: str) -> Optional[float]:
"""Extract total model mass from F06 content."""
# Pattern for total mass in OLOAD resultant or GPWG
patterns = [
r'TOTAL\s+MASS\s*=\s*([\d.E+-]+)',
r'MASS\s+TOTAL\s*:\s*([\d.E+-]+)',
r'GPWG.*?MASS\s*=\s*([\d.E+-]+)',
r'MASS\s+CENTER\s+OF\s+GRAVITY.*?MASS\s*=\s*([\d.E+-]+)',
]
for pattern in patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
try:
return float(match.group(1))
except ValueError:
continue
return None
def _find_dominant_mode(modes: List[Dict], direction: str) -> Dict[str, Any]:
"""Find the mode with highest participation in given direction."""
if not modes:
return {'mode': None, 'participation': 0.0}
key = f'mass_{direction}' if direction in 'xyz' else f'participation_{direction}'
max_participation = 0.0
dominant_mode = None
for mode in modes:
participation = mode.get(key, 0.0) or 0.0
if abs(participation) > max_participation:
max_participation = abs(participation)
dominant_mode = mode.get('mode')
return {
'mode': dominant_mode,
'participation': max_participation,
'frequency': modes[dominant_mode - 1].get('frequency') if dominant_mode else None
}
def extract_frequencies(
f06_file: Union[str, Path],
n_modes: Optional[int] = None
) -> Dict[str, Any]:
"""
Extract natural frequencies from modal analysis F06 file.
Simpler version of extract_modal_mass that just gets frequencies.
Args:
f06_file: Path to F06 file
n_modes: Number of modes to extract (default: all)
Returns:
dict: {
'success': bool,
'frequencies': list of frequencies in Hz,
'mode_count': int,
'first_frequency': float,
'error': str or None
}
"""
result = extract_modal_mass(f06_file, mode=None)
if not result['success']:
return {
'success': False,
'error': result['error'],
'frequencies': [],
'mode_count': 0,
'first_frequency': None
}
frequencies = result.get('frequencies', [])
if n_modes is not None:
frequencies = frequencies[:n_modes]
return {
'success': True,
'error': None,
'frequencies': frequencies,
'mode_count': len(frequencies),
'first_frequency': frequencies[0] if frequencies else None
}
def get_first_frequency(f06_file: Union[str, Path]) -> float:
"""
Get first natural frequency from F06 file.
Convenience function for optimization constraints.
Returns 0 if extraction fails.
Args:
f06_file: Path to F06 file
Returns:
float: First natural frequency in Hz, or 0 on failure
"""
result = extract_modal_mass(f06_file, mode=1)
if result['success'] and result.get('frequency'):
return result['frequency']
else:
logger.warning(f"Frequency extraction failed: {result.get('error')}")
return 0.0
def get_modal_mass_ratio(
f06_file: Union[str, Path],
direction: str = 'z',
n_modes: int = 10
) -> float:
"""
Get cumulative modal mass ratio for first n modes.
This indicates what fraction of total mass participates in the
first n modes. Important for determining if enough modes are included.
Args:
f06_file: Path to F06 file
direction: Direction ('x', 'y', or 'z')
n_modes: Number of modes to include
Returns:
float: Cumulative mass ratio (0-1), or 0 on failure
"""
result = extract_modal_mass(f06_file, mode=None)
if not result['success']:
return 0.0
modes = result.get('modes', [])[:n_modes]
key = f'mass_{direction}'
total_participation = sum(abs(m.get(key, 0.0) or 0.0) for m in modes)
total_mass = result.get('total_mass')
if total_mass and total_mass > 0:
return total_participation / total_mass
else:
return total_participation
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
f06_file = sys.argv[1]
mode = int(sys.argv[2]) if len(sys.argv) > 2 else None
print(f"Extracting modal mass from: {f06_file}")
if mode:
result = extract_modal_mass(f06_file, mode=mode)
if result['success']:
print(f"\n=== Mode {mode} Results ===")
print(f"Frequency: {result['frequency']:.4f} Hz")
print(f"Modal mass X: {result['modal_mass_x']}")
print(f"Modal mass Y: {result['modal_mass_y']}")
print(f"Modal mass Z: {result['modal_mass_z']}")
else:
print(f"Error: {result['error']}")
else:
result = extract_modal_mass(f06_file)
if result['success']:
print(f"\n=== Modal Analysis Results ===")
print(f"Mode count: {result['mode_count']}")
print(f"Total mass: {result['total_mass']}")
print(f"\nFrequencies (Hz):")
for i, freq in enumerate(result['frequencies'][:10], 1):
print(f" Mode {i}: {freq:.4f} Hz")
if len(result['frequencies']) > 10:
print(f" ... and {len(result['frequencies']) - 10} more modes")
else:
print(f"Error: {result['error']}")
else:
print("Usage: python extract_modal_mass.py <f06_file> [mode_number]")

View File

@@ -0,0 +1,241 @@
"""
Extract Principal Stresses from Structural Analysis
====================================================
Extracts principal stresses (sigma1, sigma2, sigma3) from OP2 files.
Useful for failure criteria beyond von Mises (e.g., Tresca, max principal).
Pattern: solid_stress
Element Types: CTETRA, CHEXA, CQUAD4, CTRIA3
Result Type: principal stress
API: model.op2_results.stress.ctetra_stress[subcase]
Phase 2 Task 2.3 - NX Open Automation Roadmap
"""
from pathlib import Path
from typing import Dict, Any, Optional, List, Literal
import numpy as np
from pyNastran.op2.op2 import OP2
# Column indices for principal stresses by element type
# Based on pyNastran stress data layout
STRESS_COLUMNS = {
# Solid elements (CTETRA, CHEXA, CPENTA): 10 columns
# [o1, o2, o3, ovm, omax_shear, ...] format varies
'ctetra': {'o1': 0, 'o2': 1, 'o3': 2, 'von_mises': 9, 'max_shear': 8},
'chexa': {'o1': 0, 'o2': 1, 'o3': 2, 'von_mises': 9, 'max_shear': 8},
'cpenta': {'o1': 0, 'o2': 1, 'o3': 2, 'von_mises': 9, 'max_shear': 8},
# Shell elements (CQUAD4, CTRIA3): 8 columns per surface
# [fiber_dist, oxx, oyy, txy, angle, o1, o2, von_mises]
'cquad4': {'o1': 5, 'o2': 6, 'von_mises': 7},
'ctria3': {'o1': 5, 'o2': 6, 'von_mises': 7},
}
def extract_principal_stress(
op2_file: Path,
subcase: int = 1,
element_type: str = 'ctetra',
principal: Literal['max', 'mid', 'min', 'all'] = 'max'
) -> Dict[str, Any]:
"""
Extract principal stresses from solid or shell elements.
Principal stresses are the eigenvalues of the stress tensor,
ordered as: sigma1 >= sigma2 >= sigma3 (o1 >= o2 >= o3)
Args:
op2_file: Path to .op2 file
subcase: Subcase ID (default 1)
element_type: Element type ('ctetra', 'chexa', 'cquad4', 'ctria3')
principal: Which principal stress to return:
- 'max': Maximum principal (sigma1, tension positive)
- 'mid': Middle principal (sigma2)
- 'min': Minimum principal (sigma3, compression negative)
- 'all': Return all three principals
Returns:
dict: {
'max_principal': Maximum principal stress value,
'min_principal': Minimum principal stress value,
'mid_principal': Middle principal stress (if applicable),
'max_element': Element ID with maximum principal,
'min_element': Element ID with minimum principal,
'von_mises_max': Max von Mises for comparison,
'element_type': Element type used,
'subcase': Subcase ID,
'units': 'MPa (model units)'
}
Example:
>>> result = extract_principal_stress('model.op2', element_type='ctetra')
>>> print(f"Max tension: {result['max_principal']:.2f} MPa")
>>> print(f"Max compression: {result['min_principal']:.2f} MPa")
"""
model = OP2()
model.read_op2(str(op2_file))
# Map element type to stress attribute
stress_attr_map = {
'ctetra': 'ctetra_stress',
'chexa': 'chexa_stress',
'cpenta': 'cpenta_stress',
'cquad4': 'cquad4_stress',
'ctria3': 'ctria3_stress'
}
element_type_lower = element_type.lower()
stress_attr = stress_attr_map.get(element_type_lower)
if not stress_attr:
raise ValueError(f"Unknown element type: {element_type}. "
f"Supported: {list(stress_attr_map.keys())}")
# Access stress through op2_results container
stress_dict = None
if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'stress'):
stress_container = model.op2_results.stress
if hasattr(stress_container, stress_attr):
stress_dict = getattr(stress_container, stress_attr)
if stress_dict is None or not stress_dict:
available = [a for a in dir(model) if 'stress' in a.lower()]
raise ValueError(f"No {element_type} stress results in OP2. "
f"Available: {available}")
# Get subcase
available_subcases = list(stress_dict.keys())
if subcase in available_subcases:
actual_subcase = subcase
else:
actual_subcase = available_subcases[0]
stress = stress_dict[actual_subcase]
# Get column indices for this element type
cols = STRESS_COLUMNS.get(element_type_lower)
if cols is None:
# Fallback: assume standard layout
cols = {'o1': 0, 'o2': 1, 'o3': 2 if element_type_lower in ['ctetra', 'chexa', 'cpenta'] else None}
itime = 0 # First time step (static analysis)
# Extract principal stresses
o1 = stress.data[itime, :, cols['o1']] # Max principal
o2 = stress.data[itime, :, cols['o2']] # Mid principal
# Solid elements have 3 principals, shells have 2
if 'o3' in cols and cols['o3'] is not None:
o3 = stress.data[itime, :, cols['o3']] # Min principal
else:
o3 = None
# Get element IDs
element_ids = np.array([eid for (eid, node) in stress.element_node])
# Find extremes
max_o1_idx = np.argmax(o1)
min_o1_idx = np.argmin(o1)
result = {
'max_principal': float(np.max(o1)),
'max_principal_element': int(element_ids[max_o1_idx]),
'min_principal_o1': float(np.min(o1)),
'min_principal_o1_element': int(element_ids[min_o1_idx]),
'mean_principal': float(np.mean(o1)),
'element_type': element_type,
'subcase': actual_subcase,
'units': 'MPa (model units)',
'num_elements': len(element_ids),
}
# Add mid principal stats
result['max_mid_principal'] = float(np.max(o2))
result['min_mid_principal'] = float(np.min(o2))
# Add minimum principal (sigma3) for solid elements
if o3 is not None:
min_o3_idx = np.argmin(o3) # Most negative = max compression
max_o3_idx = np.argmax(o3)
result['max_min_principal'] = float(np.max(o3))
result['min_min_principal'] = float(np.min(o3)) # Most compressive
result['min_principal_element'] = int(element_ids[min_o3_idx])
result['has_three_principals'] = True
else:
result['has_three_principals'] = False
# Add von Mises for comparison
if 'von_mises' in cols:
vm = stress.data[itime, :, cols['von_mises']]
result['von_mises_max'] = float(np.max(vm))
result['von_mises_mean'] = float(np.mean(vm))
return result
def extract_max_principal_stress(
op2_file: Path,
subcase: int = 1,
element_type: str = 'ctetra'
) -> float:
"""
Convenience function to extract maximum principal stress.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID
element_type: Element type
Returns:
Maximum principal stress value (tension positive)
"""
result = extract_principal_stress(op2_file, subcase, element_type)
return result['max_principal']
def extract_min_principal_stress(
op2_file: Path,
subcase: int = 1,
element_type: str = 'ctetra'
) -> float:
"""
Convenience function to extract minimum principal stress.
For solid elements, returns sigma3 (most compressive).
For shell elements, returns sigma2.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID
element_type: Element type
Returns:
Minimum principal stress value (compression negative)
"""
result = extract_principal_stress(op2_file, subcase, element_type)
if result['has_three_principals']:
return result['min_min_principal']
else:
return result['min_mid_principal']
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
element_type = sys.argv[2] if len(sys.argv) > 2 else 'ctetra'
result = extract_principal_stress(op2_file, element_type=element_type)
print(f"\nPrincipal Stress Results ({element_type}):")
print(f" Max Principal (σ1): {result['max_principal']:.2f} MPa")
print(f" Element: {result['max_principal_element']}")
if result.get('has_three_principals'):
print(f" Min Principal (σ3): {result['min_min_principal']:.2f} MPa")
print(f" Element: {result['min_principal_element']}")
if 'von_mises_max' in result:
print(f" Von Mises Max: {result['von_mises_max']:.2f} MPa")
print(f" Elements analyzed: {result['num_elements']}")
else:
print(f"Usage: python {sys.argv[0]} <op2_file> [element_type]")

View File

@@ -0,0 +1,322 @@
"""
Extract SPC Forces (Reaction Forces) from Structural Analysis
=============================================================
Extracts single-point constraint (SPC) forces from OP2 files.
These are the reaction forces at boundary conditions.
Pattern: spc_forces
Node Type: Constrained grid points
Result Type: reaction force
API: model.spc_forces[subcase]
Phase 2 Task 2.5 - NX Open Automation Roadmap
"""
from pathlib import Path
from typing import Dict, Any, Optional, List, Literal
import numpy as np
from pyNastran.op2.op2 import OP2
def extract_spc_forces(
op2_file: Path,
subcase: int = 1,
component: Literal['total', 'fx', 'fy', 'fz', 'mx', 'my', 'mz', 'force', 'moment'] = 'total'
) -> Dict[str, Any]:
"""
Extract SPC (reaction) forces from boundary conditions.
SPC forces are the reaction forces at constrained nodes. They balance
the applied loads and indicate load path through the structure.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID (default 1)
component: Which component(s) to return:
- 'total': Resultant force magnitude (sqrt(fx^2+fy^2+fz^2))
- 'fx', 'fy', 'fz': Individual force components
- 'mx', 'my', 'mz': Individual moment components
- 'force': Vector sum of forces only
- 'moment': Vector sum of moments only
Returns:
dict: {
'total_reaction': Total reaction force magnitude,
'max_reaction': Maximum nodal reaction,
'max_reaction_node': Node ID with max reaction,
'sum_fx': Sum of Fx at all nodes,
'sum_fy': Sum of Fy at all nodes,
'sum_fz': Sum of Fz at all nodes,
'sum_mx': Sum of Mx at all nodes,
'sum_my': Sum of My at all nodes,
'sum_mz': Sum of Mz at all nodes,
'node_reactions': Dict of {node_id: [fx,fy,fz,mx,my,mz]},
'num_constrained_nodes': Number of nodes with SPCs,
'subcase': Subcase ID,
'units': 'N, N-mm (model units)'
}
Example:
>>> result = extract_spc_forces('model.op2')
>>> print(f"Total reaction: {result['total_reaction']:.2f} N")
>>> print(f"Sum Fz: {result['sum_fz']:.2f} N")
"""
model = OP2()
model.read_op2(str(op2_file))
# Check for SPC forces
spc_dict = None
# Try op2_results container first
if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'force'):
force_container = model.op2_results.force
if hasattr(force_container, 'spc_forces'):
spc_dict = force_container.spc_forces
# Fallback to direct model attribute
if spc_dict is None and hasattr(model, 'spc_forces') and model.spc_forces:
spc_dict = model.spc_forces
if spc_dict is None or not spc_dict:
raise ValueError(
"No SPC forces found in OP2 file. "
"Ensure SPCFORCE=ALL is in the Nastran case control."
)
# Get subcase
available_subcases = list(spc_dict.keys())
if subcase in available_subcases:
actual_subcase = subcase
else:
actual_subcase = available_subcases[0]
spc_result = spc_dict[actual_subcase]
# Extract data
# SPC forces: data[itime, inode, 6] where columns are [fx, fy, fz, mx, my, mz]
itime = 0
if hasattr(spc_result, 'data'):
data = spc_result.data[itime] # Shape: (nnodes, 6)
node_ids = spc_result.node_gridtype[:, 0] if hasattr(spc_result, 'node_gridtype') else np.arange(len(data))
else:
raise ValueError("Unexpected SPC forces data format")
# Force components (columns 0-2)
fx = data[:, 0]
fy = data[:, 1]
fz = data[:, 2]
# Moment components (columns 3-5)
mx = data[:, 3]
my = data[:, 4]
mz = data[:, 5]
# Resultant force at each node
force_mag = np.sqrt(fx**2 + fy**2 + fz**2)
moment_mag = np.sqrt(mx**2 + my**2 + mz**2)
# Total resultant (force + moment contribution)
# Note: Forces and moments have different units, so total is just force magnitude
total_mag = force_mag
# Statistics
max_idx = np.argmax(total_mag)
max_reaction = float(total_mag[max_idx])
max_node = int(node_ids[max_idx])
# Sum of components (should balance applied loads in static analysis)
sum_fx = float(np.sum(fx))
sum_fy = float(np.sum(fy))
sum_fz = float(np.sum(fz))
sum_mx = float(np.sum(mx))
sum_my = float(np.sum(my))
sum_mz = float(np.sum(mz))
# Total reaction force magnitude
total_reaction = float(np.sqrt(sum_fx**2 + sum_fy**2 + sum_fz**2))
# Per-node reactions dictionary
node_reactions = {}
for i, nid in enumerate(node_ids):
node_reactions[int(nid)] = [
float(fx[i]), float(fy[i]), float(fz[i]),
float(mx[i]), float(my[i]), float(mz[i])
]
# Component-specific return values
component_result = None
if component == 'fx':
component_result = float(np.max(np.abs(fx)))
elif component == 'fy':
component_result = float(np.max(np.abs(fy)))
elif component == 'fz':
component_result = float(np.max(np.abs(fz)))
elif component == 'mx':
component_result = float(np.max(np.abs(mx)))
elif component == 'my':
component_result = float(np.max(np.abs(my)))
elif component == 'mz':
component_result = float(np.max(np.abs(mz)))
elif component == 'force':
component_result = float(np.max(force_mag))
elif component == 'moment':
component_result = float(np.max(moment_mag))
elif component == 'total':
component_result = total_reaction
return {
'total_reaction': total_reaction,
'max_reaction': max_reaction,
'max_reaction_node': max_node,
'component_max': component_result,
'component': component,
'sum_fx': sum_fx,
'sum_fy': sum_fy,
'sum_fz': sum_fz,
'sum_mx': sum_mx,
'sum_my': sum_my,
'sum_mz': sum_mz,
'max_fx': float(np.max(np.abs(fx))),
'max_fy': float(np.max(np.abs(fy))),
'max_fz': float(np.max(np.abs(fz))),
'max_mx': float(np.max(np.abs(mx))),
'max_my': float(np.max(np.abs(my))),
'max_mz': float(np.max(np.abs(mz))),
'node_reactions': node_reactions,
'num_constrained_nodes': len(node_ids),
'subcase': actual_subcase,
'units': 'N, N-mm (model units)',
}
def extract_total_reaction_force(
op2_file: Path,
subcase: int = 1
) -> float:
"""
Convenience function to extract total reaction force magnitude.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID
Returns:
Total reaction force magnitude (N)
"""
result = extract_spc_forces(op2_file, subcase)
return result['total_reaction']
def extract_reaction_component(
op2_file: Path,
component: str = 'fz',
subcase: int = 1
) -> float:
"""
Extract maximum absolute value of a specific reaction component.
Args:
op2_file: Path to .op2 file
component: 'fx', 'fy', 'fz', 'mx', 'my', 'mz'
subcase: Subcase ID
Returns:
Maximum absolute value of the specified component
"""
result = extract_spc_forces(op2_file, subcase, component)
return result['component_max']
def check_force_equilibrium(
op2_file: Path,
applied_load: Optional[Dict[str, float]] = None,
tolerance: float = 1.0
) -> Dict[str, Any]:
"""
Check if reaction forces balance applied loads (equilibrium check).
In a valid static analysis, sum of reactions should equal applied loads.
Args:
op2_file: Path to .op2 file
applied_load: Optional dict of applied loads {'fx': N, 'fy': N, 'fz': N}
tolerance: Tolerance for equilibrium check (default 1.0 N)
Returns:
dict: {
'in_equilibrium': Boolean,
'reaction_sum': [fx, fy, fz],
'imbalance': [dx, dy, dz] (if applied_load provided),
'max_imbalance': Maximum component imbalance
}
"""
result = extract_spc_forces(op2_file)
reaction_sum = [result['sum_fx'], result['sum_fy'], result['sum_fz']]
equilibrium_result = {
'reaction_sum': reaction_sum,
'moment_sum': [result['sum_mx'], result['sum_my'], result['sum_mz']],
}
if applied_load:
applied = [
applied_load.get('fx', 0.0),
applied_load.get('fy', 0.0),
applied_load.get('fz', 0.0)
]
# Reactions should be opposite to applied loads
imbalance = [
reaction_sum[0] + applied[0],
reaction_sum[1] + applied[1],
reaction_sum[2] + applied[2]
]
max_imbalance = max(abs(i) for i in imbalance)
equilibrium_result['applied_load'] = applied
equilibrium_result['imbalance'] = imbalance
equilibrium_result['max_imbalance'] = max_imbalance
equilibrium_result['in_equilibrium'] = max_imbalance <= tolerance
else:
# Without applied load, just check if reactions are non-zero
equilibrium_result['total_reaction'] = result['total_reaction']
equilibrium_result['in_equilibrium'] = True # Can't check without applied load
return equilibrium_result
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
try:
result = extract_spc_forces(op2_file)
print(f"\nSPC Forces (Reaction Forces):")
print(f" Total reaction: {result['total_reaction']:.2f} N")
print(f" Max nodal reaction: {result['max_reaction']:.2f} N")
print(f" Node: {result['max_reaction_node']}")
print(f"\n Force sums (should balance applied loads):")
print(f" ΣFx: {result['sum_fx']:.2f} N")
print(f" ΣFy: {result['sum_fy']:.2f} N")
print(f" ΣFz: {result['sum_fz']:.2f} N")
print(f"\n Moment sums:")
print(f" ΣMx: {result['sum_mx']:.2f} N-mm")
print(f" ΣMy: {result['sum_my']:.2f} N-mm")
print(f" ΣMz: {result['sum_mz']:.2f} N-mm")
print(f"\n Constrained nodes: {result['num_constrained_nodes']}")
# Show a few node reactions
print(f"\n Sample node reactions:")
for i, (nid, forces) in enumerate(result['node_reactions'].items()):
if i >= 3:
print(f" ... ({result['num_constrained_nodes'] - 3} more)")
break
print(f" Node {nid}: Fx={forces[0]:.2f}, Fy={forces[1]:.2f}, Fz={forces[2]:.2f}")
except ValueError as e:
print(f"Error: {e}")
else:
print(f"Usage: python {sys.argv[0]} <op2_file>")

View File

@@ -0,0 +1,280 @@
"""
Extract Strain Energy from Structural Analysis
===============================================
Extracts element strain energy and strain energy density from OP2 files.
Strain energy is useful for topology optimization and structural efficiency metrics.
Pattern: strain_energy
Element Types: All structural elements
Result Type: strain energy
API: model.op2_results.strain_energy.*[subcase]
Phase 2 Task 2.4 - NX Open Automation Roadmap
"""
from pathlib import Path
from typing import Dict, Any, Optional, List
import numpy as np
from pyNastran.op2.op2 import OP2
def extract_strain_energy(
op2_file: Path,
subcase: int = 1,
element_type: Optional[str] = None,
top_n: int = 10
) -> Dict[str, Any]:
"""
Extract strain energy from structural elements.
Strain energy (U) is a measure of the work done to deform the structure:
U = 0.5 * integral(sigma * epsilon) dV
High strain energy density indicates highly stressed regions.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID (default 1)
element_type: Filter by element type (e.g., 'ctetra', 'chexa', 'cquad4')
If None, returns total from all elements
top_n: Number of top elements to return by strain energy
Returns:
dict: {
'total_strain_energy': Total strain energy (all elements),
'mean_strain_energy': Mean strain energy per element,
'max_strain_energy': Maximum element strain energy,
'max_energy_element': Element ID with max strain energy,
'top_elements': List of (element_id, energy) tuples,
'energy_by_type': Dict of {element_type: total_energy},
'num_elements': Total element count,
'subcase': Subcase ID,
'units': 'N-mm (model units)'
}
Example:
>>> result = extract_strain_energy('model.op2')
>>> print(f"Total strain energy: {result['total_strain_energy']:.2f} N-mm")
>>> print(f"Highest energy element: {result['max_energy_element']}")
"""
model = OP2()
model.read_op2(str(op2_file))
# Check for strain energy results
# pyNastran stores strain energy in op2_results.strain_energy
strain_energy_dict = None
if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'strain_energy'):
se_container = model.op2_results.strain_energy
# Strain energy is typically stored by element type
# ctetra_strain_energy, chexa_strain_energy, etc.
se_attrs = [a for a in dir(se_container) if 'strain_energy' in a.lower()]
if not se_attrs:
# Try direct access patterns
if hasattr(se_container, 'strain_energy'):
strain_energy_dict = se_container.strain_energy
elif hasattr(model, 'strain_energy') and model.strain_energy:
strain_energy_dict = model.strain_energy
# Fallback: try legacy access pattern
if strain_energy_dict is None and hasattr(model, 'ctetra_strain_energy'):
strain_energy_dict = model.ctetra_strain_energy
# Collect all strain energy data
all_elements = []
all_energies = []
energy_by_type = {}
# Search through all possible strain energy attributes
se_attr_names = [
'ctetra_strain_energy',
'chexa_strain_energy',
'cpenta_strain_energy',
'cquad4_strain_energy',
'ctria3_strain_energy',
'cbar_strain_energy',
'cbeam_strain_energy',
'crod_strain_energy',
]
found_any = False
for attr_name in se_attr_names:
se_dict = None
# Try op2_results container first
if hasattr(model, 'op2_results') and hasattr(model.op2_results, 'strain_energy'):
se_container = model.op2_results.strain_energy
if hasattr(se_container, attr_name):
se_dict = getattr(se_container, attr_name)
# Fallback to direct model attribute
if se_dict is None and hasattr(model, attr_name):
se_dict = getattr(model, attr_name)
if se_dict is None or not se_dict:
continue
# Extract element type from attribute name
etype = attr_name.replace('_strain_energy', '')
# Filter by element type if specified
if element_type is not None and etype.lower() != element_type.lower():
continue
# Get subcase
available_subcases = list(se_dict.keys())
if not available_subcases:
continue
if subcase in available_subcases:
actual_subcase = subcase
else:
actual_subcase = available_subcases[0]
se_result = se_dict[actual_subcase]
found_any = True
# Extract data
# Strain energy typically stored as: data[itime, ielement, icolumn]
# Column 0 is usually total strain energy
itime = 0
if hasattr(se_result, 'data'):
energies = se_result.data[itime, :, 0]
element_ids = se_result.element if hasattr(se_result, 'element') else np.arange(len(energies))
all_elements.extend(element_ids.tolist())
all_energies.extend(energies.tolist())
energy_by_type[etype] = float(np.sum(energies))
if not found_any or len(all_energies) == 0:
# No strain energy found - this might not be requested in the analysis
raise ValueError(
"No strain energy results found in OP2 file. "
"Ensure STRAIN=ALL or SEFINAL is in the Nastran case control."
)
# Convert to numpy for analysis
all_energies = np.array(all_energies)
all_elements = np.array(all_elements)
# Statistics
total_energy = float(np.sum(all_energies))
mean_energy = float(np.mean(all_energies))
max_idx = np.argmax(all_energies)
max_energy = float(all_energies[max_idx])
max_element = int(all_elements[max_idx])
# Top N elements by strain energy
top_indices = np.argsort(all_energies)[-top_n:][::-1]
top_elements = [
(int(all_elements[i]), float(all_energies[i]))
for i in top_indices
]
return {
'total_strain_energy': total_energy,
'mean_strain_energy': mean_energy,
'max_strain_energy': max_energy,
'max_energy_element': max_element,
'min_strain_energy': float(np.min(all_energies)),
'std_strain_energy': float(np.std(all_energies)),
'top_elements': top_elements,
'energy_by_type': energy_by_type,
'num_elements': len(all_elements),
'subcase': subcase,
'units': 'N-mm (model units)',
}
def extract_total_strain_energy(
op2_file: Path,
subcase: int = 1
) -> float:
"""
Convenience function to extract total strain energy.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID
Returns:
Total strain energy (N-mm)
"""
result = extract_strain_energy(op2_file, subcase)
return result['total_strain_energy']
def extract_strain_energy_density(
op2_file: Path,
subcase: int = 1,
element_type: str = 'ctetra'
) -> Dict[str, Any]:
"""
Extract strain energy density (energy per volume).
Strain energy density is useful for identifying critical regions
and for material utilization optimization.
Args:
op2_file: Path to .op2 file
subcase: Subcase ID
element_type: Element type to analyze
Returns:
dict: {
'max_density': Maximum strain energy density,
'mean_density': Mean strain energy density,
'total_energy': Total strain energy,
'units': 'N/mm^2 (MPa equivalent)'
}
Note:
This requires element volume data which may not always be available.
Falls back to energy-only metrics if volume is unavailable.
"""
# For now, just return strain energy
# Full implementation would require element volume from BDF or OP2
result = extract_strain_energy(op2_file, subcase, element_type)
# Without volume data, we can't compute true density
# Return energy metrics with a note
return {
'max_strain_energy': result['max_strain_energy'],
'mean_strain_energy': result['mean_strain_energy'],
'total_strain_energy': result['total_strain_energy'],
'max_element': result['max_energy_element'],
'note': 'True density requires element volumes (not computed)',
'units': 'N-mm (energy), density requires volume'
}
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
op2_file = Path(sys.argv[1])
try:
result = extract_strain_energy(op2_file)
print(f"\nStrain Energy Results:")
print(f" Total: {result['total_strain_energy']:.4f} N-mm")
print(f" Mean: {result['mean_strain_energy']:.4f} N-mm")
print(f" Max: {result['max_strain_energy']:.4f} N-mm")
print(f" Element: {result['max_energy_element']}")
print(f"\n Energy by element type:")
for etype, energy in result['energy_by_type'].items():
print(f" {etype}: {energy:.4f} N-mm")
print(f"\n Top 5 elements:")
for eid, energy in result['top_elements'][:5]:
print(f" {eid}: {energy:.4f} N-mm")
print(f"\n Total elements: {result['num_elements']}")
except ValueError as e:
print(f"Error: {e}")
else:
print(f"Usage: python {sys.argv[0]} <op2_file>")

View File

@@ -0,0 +1,467 @@
"""
Temperature Extractor for Thermal Analysis Results
===================================================
Phase 3 Task 3.1 - NX Open Automation Roadmap
Extracts nodal temperature results from Nastran OP2 files for thermal optimization.
Usage:
from optimization_engine.extractors.extract_temperature import extract_temperature
result = extract_temperature("path/to/thermal.op2", subcase=1)
print(f"Max temperature: {result['max_temperature']} K")
Supports:
- SOL 153 (Steady-State Heat Transfer)
- SOL 159 (Transient Heat Transfer)
- Thermal subcases in coupled analyses
"""
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, List, Union
import logging
logger = logging.getLogger(__name__)
def extract_temperature(
op2_file: Union[str, Path],
subcase: int = 1,
nodes: Optional[List[int]] = None,
return_field: bool = False
) -> Dict[str, Any]:
"""
Extract nodal temperatures from thermal analysis OP2 file.
Args:
op2_file: Path to the OP2 results file
subcase: Subcase number to extract (default: 1)
nodes: Optional list of specific node IDs to extract.
If None, extracts all nodes.
return_field: If True, include full temperature field in result
Returns:
dict: {
'success': bool,
'max_temperature': float (K or °C depending on model units),
'min_temperature': float,
'avg_temperature': float,
'max_node_id': int (node with max temperature),
'min_node_id': int (node with min temperature),
'node_count': int,
'temperatures': dict (node_id: temp) - only if return_field=True,
'unit': str ('K' or 'C'),
'subcase': int,
'error': str or None
}
Example:
>>> result = extract_temperature("thermal_analysis.op2", subcase=1)
>>> if result['success']:
... print(f"Max temp: {result['max_temperature']:.1f} K at node {result['max_node_id']}")
... print(f"Temperature range: {result['min_temperature']:.1f} - {result['max_temperature']:.1f} K")
"""
op2_path = Path(op2_file)
if not op2_path.exists():
return {
'success': False,
'error': f"OP2 file not found: {op2_path}",
'max_temperature': None,
'min_temperature': None,
'avg_temperature': None,
'max_node_id': None,
'min_node_id': None,
'node_count': 0,
'subcase': subcase
}
try:
from pyNastran.op2.op2 import read_op2
# Read OP2 with minimal output
op2 = read_op2(str(op2_path), load_geometry=False, debug=False, log=None)
# Check for temperature results
# pyNastran stores temperatures in different attributes depending on analysis type
temperatures = None
# Method 1: Check temperatures attribute (SOL 153/159)
if hasattr(op2, 'temperatures') and op2.temperatures:
if subcase in op2.temperatures:
temp_data = op2.temperatures[subcase]
temperatures = _extract_from_table(temp_data, nodes)
logger.debug(f"Found temperatures in op2.temperatures[{subcase}]")
# Method 2: Check thermal load results (alternative storage)
if temperatures is None and hasattr(op2, 'thermal_load_vectors'):
if subcase in op2.thermal_load_vectors:
temp_data = op2.thermal_load_vectors[subcase]
temperatures = _extract_from_table(temp_data, nodes)
logger.debug(f"Found temperatures in op2.thermal_load_vectors[{subcase}]")
# Method 3: Check displacement as temperature (some solvers store temp in disp)
# In thermal analysis, "displacement" can actually be temperature
if temperatures is None and hasattr(op2, 'displacements') and op2.displacements:
if subcase in op2.displacements:
disp_data = op2.displacements[subcase]
# Check if this is actually temperature data
# Temperature data typically has only 1 DOF (scalar field)
if hasattr(disp_data, 'data'):
data = disp_data.data
if len(data.shape) >= 2:
# For thermal, we expect scalar temperature at each node
# Column 0 of translational data contains temperature
temps_array = data[0, :, 0] if len(data.shape) == 3 else data[:, 0]
node_ids = disp_data.node_gridtype[:, 0]
temperatures = {int(nid): float(temps_array[i])
for i, nid in enumerate(node_ids)
if nodes is None or nid in nodes}
logger.debug(f"Found temperatures in op2.displacements[{subcase}] (thermal mode)")
if temperatures is None or len(temperatures) == 0:
# List available subcases for debugging
available_subcases = []
if hasattr(op2, 'temperatures') and op2.temperatures:
available_subcases.extend(list(op2.temperatures.keys()))
return {
'success': False,
'error': f"No temperature results found for subcase {subcase}. "
f"Available subcases: {available_subcases}",
'max_temperature': None,
'min_temperature': None,
'avg_temperature': None,
'max_node_id': None,
'min_node_id': None,
'node_count': 0,
'subcase': subcase
}
# Compute statistics
temp_values = np.array(list(temperatures.values()))
temp_nodes = np.array(list(temperatures.keys()))
max_idx = np.argmax(temp_values)
min_idx = np.argmin(temp_values)
result = {
'success': True,
'error': None,
'max_temperature': float(temp_values[max_idx]),
'min_temperature': float(temp_values[min_idx]),
'avg_temperature': float(np.mean(temp_values)),
'std_temperature': float(np.std(temp_values)),
'max_node_id': int(temp_nodes[max_idx]),
'min_node_id': int(temp_nodes[min_idx]),
'node_count': len(temperatures),
'unit': 'K', # Nastran typically uses Kelvin
'subcase': subcase
}
if return_field:
result['temperatures'] = temperatures
return result
except ImportError:
return {
'success': False,
'error': "pyNastran not installed. Install with: pip install pyNastran",
'max_temperature': None,
'min_temperature': None,
'avg_temperature': None,
'max_node_id': None,
'min_node_id': None,
'node_count': 0,
'subcase': subcase
}
except Exception as e:
logger.exception(f"Error extracting temperature from {op2_path}")
return {
'success': False,
'error': str(e),
'max_temperature': None,
'min_temperature': None,
'avg_temperature': None,
'max_node_id': None,
'min_node_id': None,
'node_count': 0,
'subcase': subcase
}
def _extract_from_table(temp_data, nodes: Optional[List[int]] = None) -> Dict[int, float]:
"""Extract temperature values from a pyNastran result table."""
temperatures = {}
if hasattr(temp_data, 'data') and hasattr(temp_data, 'node_gridtype'):
data = temp_data.data
node_ids = temp_data.node_gridtype[:, 0]
# Data shape is typically (ntimes, nnodes, ncomponents)
# For temperature, we want the first component
if len(data.shape) == 3:
temp_values = data[0, :, 0] # First time step, all nodes, first component
elif len(data.shape) == 2:
temp_values = data[:, 0]
else:
temp_values = data
for i, nid in enumerate(node_ids):
if nodes is None or nid in nodes:
temperatures[int(nid)] = float(temp_values[i])
return temperatures
def extract_temperature_gradient(
op2_file: Union[str, Path],
subcase: int = 1,
method: str = 'nodal_difference'
) -> Dict[str, Any]:
"""
Extract temperature gradients from thermal analysis.
Computes temperature gradients based on nodal temperature differences.
This is useful for identifying thermal stress hot spots.
Args:
op2_file: Path to the OP2 results file
subcase: Subcase number
method: Gradient calculation method:
- 'nodal_difference': Max temperature difference between adjacent nodes
- 'element_based': Gradient within elements (requires mesh connectivity)
Returns:
dict: {
'success': bool,
'max_gradient': float (K/mm or temperature units/length),
'avg_gradient': float,
'temperature_range': float (max - min temperature),
'gradient_location': tuple (node_id_hot, node_id_cold),
'error': str or None
}
Note:
For accurate gradients, element-based calculation requires mesh connectivity
which may not be available in all OP2 files. The nodal_difference method
provides an approximation based on temperature range.
"""
# First extract temperatures
temp_result = extract_temperature(op2_file, subcase=subcase, return_field=True)
if not temp_result['success']:
return {
'success': False,
'error': temp_result['error'],
'max_gradient': None,
'avg_gradient': None,
'temperature_range': None,
'gradient_location': None
}
temperatures = temp_result.get('temperatures', {})
if len(temperatures) < 2:
return {
'success': False,
'error': "Need at least 2 nodes to compute gradient",
'max_gradient': None,
'avg_gradient': None,
'temperature_range': None,
'gradient_location': None
}
# Compute temperature range (proxy for max gradient without mesh)
temp_range = temp_result['max_temperature'] - temp_result['min_temperature']
# For nodal_difference method, we report the temperature range
# True gradient computation would require mesh connectivity
return {
'success': True,
'error': None,
'max_gradient': temp_range, # Simplified - actual gradient needs mesh
'avg_gradient': temp_range / 2, # Rough estimate
'temperature_range': temp_range,
'gradient_location': (temp_result['max_node_id'], temp_result['min_node_id']),
'max_temperature': temp_result['max_temperature'],
'min_temperature': temp_result['min_temperature'],
'note': "Gradient approximated from temperature range. "
"Accurate gradient requires mesh connectivity."
}
def extract_heat_flux(
op2_file: Union[str, Path],
subcase: int = 1,
element_type: str = 'all'
) -> Dict[str, Any]:
"""
Extract element heat flux from thermal analysis OP2 file.
Args:
op2_file: Path to the OP2 results file
subcase: Subcase number
element_type: Element type to extract ('all', 'ctetra', 'chexa', etc.)
Returns:
dict: {
'success': bool,
'max_heat_flux': float (W/mm² or model units),
'min_heat_flux': float,
'avg_heat_flux': float,
'max_element_id': int,
'element_count': int,
'unit': str,
'error': str or None
}
"""
op2_path = Path(op2_file)
if not op2_path.exists():
return {
'success': False,
'error': f"OP2 file not found: {op2_path}",
'max_heat_flux': None,
'min_heat_flux': None,
'avg_heat_flux': None,
'max_element_id': None,
'element_count': 0,
'subcase': subcase
}
try:
from pyNastran.op2.op2 import read_op2
op2 = read_op2(str(op2_path), load_geometry=False, debug=False, log=None)
# Check for heat flux results
# pyNastran stores thermal flux in chbdyg_thermal_load or similar
heat_flux_data = None
# Check various thermal flux attributes
flux_attrs = [
'chbdyg_thermal_load',
'chbdye_thermal_load',
'chbdyp_thermal_load',
'thermalLoad_CONV',
'thermalLoad_CHBDY'
]
for attr in flux_attrs:
if hasattr(op2, attr):
data = getattr(op2, attr)
if data and subcase in data:
heat_flux_data = data[subcase]
logger.debug(f"Found heat flux in op2.{attr}[{subcase}]")
break
if heat_flux_data is None:
return {
'success': False,
'error': f"No heat flux results found for subcase {subcase}. "
"Heat flux output may not be requested in the analysis.",
'max_heat_flux': None,
'min_heat_flux': None,
'avg_heat_flux': None,
'max_element_id': None,
'element_count': 0,
'subcase': subcase
}
# Extract flux values
if hasattr(heat_flux_data, 'data'):
data = heat_flux_data.data
element_ids = heat_flux_data.element if hasattr(heat_flux_data, 'element') else []
# Flux magnitude
if len(data.shape) == 3:
flux_values = np.linalg.norm(data[0, :, :], axis=1)
else:
flux_values = np.abs(data[:, 0]) if len(data.shape) == 2 else data
max_idx = np.argmax(flux_values)
return {
'success': True,
'error': None,
'max_heat_flux': float(np.max(flux_values)),
'min_heat_flux': float(np.min(flux_values)),
'avg_heat_flux': float(np.mean(flux_values)),
'max_element_id': int(element_ids[max_idx]) if len(element_ids) > max_idx else None,
'element_count': len(flux_values),
'unit': 'W/mm²',
'subcase': subcase
}
return {
'success': False,
'error': "Could not parse heat flux data format",
'max_heat_flux': None,
'min_heat_flux': None,
'avg_heat_flux': None,
'max_element_id': None,
'element_count': 0,
'subcase': subcase
}
except Exception as e:
logger.exception(f"Error extracting heat flux from {op2_path}")
return {
'success': False,
'error': str(e),
'max_heat_flux': None,
'min_heat_flux': None,
'avg_heat_flux': None,
'max_element_id': None,
'element_count': 0,
'subcase': subcase
}
# Convenience function for optimization constraints
def get_max_temperature(op2_file: Union[str, Path], subcase: int = 1) -> float:
"""
Get maximum temperature from OP2 file.
Convenience function for use in optimization constraints.
Returns inf if extraction fails.
Args:
op2_file: Path to OP2 file
subcase: Subcase number
Returns:
float: Maximum temperature or inf on failure
"""
result = extract_temperature(op2_file, subcase=subcase)
if result['success']:
return result['max_temperature']
else:
logger.warning(f"Temperature extraction failed: {result['error']}")
return float('inf')
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
op2_file = sys.argv[1]
subcase = int(sys.argv[2]) if len(sys.argv) > 2 else 1
print(f"Extracting temperature from: {op2_file}")
result = extract_temperature(op2_file, subcase=subcase)
if result['success']:
print(f"\n=== Temperature Results (Subcase {subcase}) ===")
print(f"Max temperature: {result['max_temperature']:.2f} {result['unit']} (node {result['max_node_id']})")
print(f"Min temperature: {result['min_temperature']:.2f} {result['unit']} (node {result['min_node_id']})")
print(f"Avg temperature: {result['avg_temperature']:.2f} {result['unit']}")
print(f"Node count: {result['node_count']}")
else:
print(f"Error: {result['error']}")
else:
print("Usage: python extract_temperature.py <op2_file> [subcase]")

View File

@@ -0,0 +1,195 @@
"""
Test script for Phase 3 extractors (Multi-Physics)
===================================================
Tests:
- Temperature extraction (extract_temperature)
- Thermal gradient extraction (extract_temperature_gradient)
- Modal mass extraction (extract_modal_mass)
Run with:
conda activate atomizer
python -m optimization_engine.extractors.test_phase3_extractors
"""
import sys
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parents[2]
sys.path.insert(0, str(project_root))
def test_imports():
"""Test that all Phase 3 extractors can be imported."""
print("\n" + "=" * 60)
print("Testing Phase 3 Extractor Imports")
print("=" * 60)
try:
from optimization_engine.extractors import (
extract_temperature,
extract_temperature_gradient,
extract_heat_flux,
get_max_temperature,
extract_modal_mass,
extract_frequencies,
get_first_frequency,
get_modal_mass_ratio,
)
print("✓ All Phase 3 extractors imported successfully!")
return True
except ImportError as e:
print(f"✗ Import failed: {e}")
return False
def test_temperature_extractor(op2_file: str = None):
"""Test temperature extraction."""
print("\n" + "=" * 60)
print("Testing extract_temperature")
print("=" * 60)
from optimization_engine.extractors import extract_temperature
if op2_file and Path(op2_file).exists():
result = extract_temperature(op2_file, subcase=1)
print(f"Result: {result}")
if result['success']:
print(f" Max temperature: {result['max_temperature']}")
print(f" Min temperature: {result['min_temperature']}")
print(f" Avg temperature: {result['avg_temperature']}")
print(f" Node count: {result['node_count']}")
else:
print(f" Note: {result['error']}")
print(" (This is expected for non-thermal OP2 files)")
else:
# Test with non-existent file to verify error handling
result = extract_temperature("nonexistent.op2")
if not result['success'] and 'not found' in result['error']:
print("✓ Error handling works correctly for missing files")
else:
print("✗ Error handling issue")
return True
def test_temperature_gradient(op2_file: str = None):
"""Test temperature gradient extraction."""
print("\n" + "=" * 60)
print("Testing extract_temperature_gradient")
print("=" * 60)
from optimization_engine.extractors import extract_temperature_gradient
if op2_file and Path(op2_file).exists():
result = extract_temperature_gradient(op2_file, subcase=1)
print(f"Result: {result}")
else:
result = extract_temperature_gradient("nonexistent.op2")
if not result['success']:
print("✓ Error handling works correctly")
return True
def test_modal_mass_extractor(f06_file: str = None):
"""Test modal mass extraction."""
print("\n" + "=" * 60)
print("Testing extract_modal_mass")
print("=" * 60)
from optimization_engine.extractors import extract_modal_mass, get_first_frequency
if f06_file and Path(f06_file).exists():
# Test all modes
result = extract_modal_mass(f06_file, mode=None)
print(f"All modes result:")
if result['success']:
print(f" Mode count: {result['mode_count']}")
print(f" Frequencies: {result['frequencies'][:5]}..." if len(result.get('frequencies', [])) > 5 else f" Frequencies: {result.get('frequencies', [])}")
else:
print(f" Note: {result['error']}")
# Test specific mode
result = extract_modal_mass(f06_file, mode=1)
print(f"\nMode 1 result:")
if result['success']:
print(f" Frequency: {result['frequency']} Hz")
print(f" Modal mass X: {result.get('modal_mass_x')}")
print(f" Modal mass Y: {result.get('modal_mass_y')}")
print(f" Modal mass Z: {result.get('modal_mass_z')}")
else:
print(f" Note: {result['error']}")
# Test convenience function
freq = get_first_frequency(f06_file)
print(f"\nFirst frequency (convenience): {freq} Hz")
else:
result = extract_modal_mass("nonexistent.f06")
if not result['success'] and 'not found' in result['error']:
print("✓ Error handling works correctly for missing files")
return True
def find_test_files():
"""Find available test files in studies."""
studies_dir = project_root / "studies"
op2_files = list(studies_dir.rglob("*.op2"))
f06_files = list(studies_dir.rglob("*.f06"))
print(f"\nFound {len(op2_files)} OP2 files and {len(f06_files)} F06 files")
return op2_files, f06_files
def main():
print("=" * 60)
print("PHASE 3 EXTRACTOR TESTS")
print("Multi-Physics: Thermal & Dynamic")
print("=" * 60)
# Test imports first
if not test_imports():
print("\n✗ Import test failed. Cannot continue.")
return 1
# Find test files
op2_files, f06_files = find_test_files()
# Use first available files for testing
op2_file = str(op2_files[0]) if op2_files else None
f06_file = str(f06_files[0]) if f06_files else None
if op2_file:
print(f"\nUsing OP2 file: {op2_file}")
if f06_file:
print(f"Using F06 file: {f06_file}")
# Run tests
test_temperature_extractor(op2_file)
test_temperature_gradient(op2_file)
test_modal_mass_extractor(f06_file)
print("\n" + "=" * 60)
print("PHASE 3 TESTS COMPLETE")
print("=" * 60)
print("""
Summary:
- Temperature extraction: Ready for thermal OP2 files (SOL 153/159)
- Thermal gradient: Ready (approximation based on temperature range)
- Heat flux: Ready for thermal OP2 files
- Modal mass: Ready for modal F06 files (SOL 103)
Note: Full testing requires thermal and modal analysis result files.
The extractors will return appropriate error messages for non-thermal/modal data.
""")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,834 @@
"""
GenericSurrogate - Config-driven neural network surrogate for optimization.
This module eliminates ~2,800 lines of duplicated code across study run_nn_optimization.py files
by providing a fully config-driven neural surrogate system.
Usage:
# In study's run_nn_optimization.py (now ~30 lines instead of ~600):
from optimization_engine.generic_surrogate import ConfigDrivenSurrogate
surrogate = ConfigDrivenSurrogate(__file__)
surrogate.run() # Handles --train, --turbo, --all flags automatically
"""
from pathlib import Path
import sys
import json
import argparse
from datetime import datetime
from typing import Dict, Any, Optional, List, Tuple
import time
import numpy as np
# Conditional PyTorch import
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, random_split, TensorDataset
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
import optuna
from optuna.samplers import NSGAIISampler
class MLPSurrogate(nn.Module):
"""
Generic MLP architecture for surrogate modeling.
Architecture: Input -> [Linear -> LayerNorm -> ReLU -> Dropout] * N -> Output
"""
def __init__(self, n_inputs: int, n_outputs: int,
hidden_dims: List[int] = None, dropout: float = 0.1):
super().__init__()
if hidden_dims is None:
# Default architecture scales with problem size
hidden_dims = [64, 128, 128, 64]
layers = []
prev_dim = n_inputs
for hidden_dim in hidden_dims:
layers.extend([
nn.Linear(prev_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.ReLU(),
nn.Dropout(dropout)
])
prev_dim = hidden_dim
layers.append(nn.Linear(prev_dim, n_outputs))
self.network = nn.Sequential(*layers)
# Initialize weights
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def forward(self, x):
return self.network(x)
class GenericSurrogate:
"""
Config-driven neural surrogate for FEA optimization.
Automatically adapts to any number of design variables and objectives
based on the optimization_config.json file.
"""
def __init__(self, config: Dict, device: str = 'auto'):
"""
Initialize surrogate from config.
Args:
config: Normalized config dictionary
device: 'auto', 'cuda', or 'cpu'
"""
if not TORCH_AVAILABLE:
raise ImportError("PyTorch required for neural surrogate")
self.config = config
self.device = torch.device(
'cuda' if torch.cuda.is_available() and device == 'auto' else 'cpu'
)
# Extract variable and objective info from config
self.design_var_names = [v['name'] for v in config['design_variables']]
self.design_var_bounds = {
v['name']: (v['min'], v['max'])
for v in config['design_variables']
}
self.design_var_types = {
v['name']: v.get('type', 'continuous')
for v in config['design_variables']
}
self.objective_names = [o['name'] for o in config['objectives']]
self.n_inputs = len(self.design_var_names)
self.n_outputs = len(self.objective_names)
self.model = None
self.normalization = None
def _get_hidden_dims(self) -> List[int]:
"""Calculate hidden layer dimensions based on problem size."""
n = self.n_inputs
if n <= 3:
return [32, 64, 32]
elif n <= 6:
return [64, 128, 128, 64]
elif n <= 10:
return [128, 256, 256, 128]
else:
return [256, 512, 512, 256]
def train_from_database(self, db_path: Path, study_name: str,
epochs: int = 300, validation_split: float = 0.2,
batch_size: int = 16, learning_rate: float = 0.001,
save_path: Path = None, verbose: bool = True):
"""
Train surrogate from Optuna database.
Args:
db_path: Path to study.db
study_name: Name of the Optuna study
epochs: Number of training epochs
validation_split: Fraction of data for validation
batch_size: Training batch size
learning_rate: Initial learning rate
save_path: Where to save the trained model
verbose: Print training progress
"""
if verbose:
print(f"\n{'='*60}")
print(f"Training Generic Surrogate ({self.n_inputs} inputs -> {self.n_outputs} outputs)")
print(f"{'='*60}")
print(f"Device: {self.device}")
print(f"Database: {db_path}")
# Load data from Optuna
storage = optuna.storages.RDBStorage(f"sqlite:///{db_path}")
study = optuna.load_study(study_name=study_name, storage=storage)
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
if verbose:
print(f"Found {len(completed)} completed trials")
if len(completed) < 10:
raise ValueError(f"Need at least 10 trials for training, got {len(completed)}")
# Extract training data
design_params = []
objectives = []
for trial in completed:
# Skip inf values
if any(v == float('inf') or v != v for v in trial.values): # nan check
continue
params = [trial.params.get(name, 0) for name in self.design_var_names]
objs = list(trial.values)
design_params.append(params)
objectives.append(objs)
design_params = np.array(design_params, dtype=np.float32)
objectives = np.array(objectives, dtype=np.float32)
if verbose:
print(f"Valid samples: {len(design_params)}")
print(f"\nDesign variable ranges:")
for i, name in enumerate(self.design_var_names):
print(f" {name}: {design_params[:, i].min():.2f} - {design_params[:, i].max():.2f}")
print(f"\nObjective ranges:")
for i, name in enumerate(self.objective_names):
print(f" {name}: {objectives[:, i].min():.4f} - {objectives[:, i].max():.4f}")
# Compute normalization parameters
design_mean = design_params.mean(axis=0)
design_std = design_params.std(axis=0) + 1e-8
objective_mean = objectives.mean(axis=0)
objective_std = objectives.std(axis=0) + 1e-8
self.normalization = {
'design_mean': design_mean,
'design_std': design_std,
'objective_mean': objective_mean,
'objective_std': objective_std
}
# Normalize data
X = (design_params - design_mean) / design_std
Y = (objectives - objective_mean) / objective_std
X_tensor = torch.tensor(X, dtype=torch.float32)
Y_tensor = torch.tensor(Y, dtype=torch.float32)
# Create datasets
dataset = TensorDataset(X_tensor, Y_tensor)
n_val = max(1, int(len(dataset) * validation_split))
n_train = len(dataset) - n_val
train_ds, val_ds = random_split(dataset, [n_train, n_val])
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=batch_size)
if verbose:
print(f"\nTraining: {n_train} samples, Validation: {n_val} samples")
# Build model
hidden_dims = self._get_hidden_dims()
self.model = MLPSurrogate(
n_inputs=self.n_inputs,
n_outputs=self.n_outputs,
hidden_dims=hidden_dims
).to(self.device)
n_params = sum(p.numel() for p in self.model.parameters())
if verbose:
print(f"Model architecture: {self.n_inputs} -> {hidden_dims} -> {self.n_outputs}")
print(f"Total parameters: {n_params:,}")
# Training setup
optimizer = torch.optim.AdamW(self.model.parameters(), lr=learning_rate, weight_decay=1e-5)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
best_val_loss = float('inf')
best_state = None
if verbose:
print(f"\nTraining for {epochs} epochs...")
for epoch in range(epochs):
# Training
self.model.train()
train_loss = 0.0
for x, y in train_loader:
x, y = x.to(self.device), y.to(self.device)
optimizer.zero_grad()
pred = self.model(x)
loss = F.mse_loss(pred, y)
loss.backward()
optimizer.step()
train_loss += loss.item()
train_loss /= len(train_loader)
# Validation
self.model.eval()
val_loss = 0.0
with torch.no_grad():
for x, y in val_loader:
x, y = x.to(self.device), y.to(self.device)
pred = self.model(x)
val_loss += F.mse_loss(pred, y).item()
val_loss /= len(val_loader)
scheduler.step()
if val_loss < best_val_loss:
best_val_loss = val_loss
best_state = self.model.state_dict().copy()
if verbose and ((epoch + 1) % 50 == 0 or epoch == 0):
print(f" Epoch {epoch+1:3d}: train={train_loss:.6f}, val={val_loss:.6f}")
# Load best model
self.model.load_state_dict(best_state)
if verbose:
print(f"\nBest validation loss: {best_val_loss:.6f}")
# Final evaluation
self._print_validation_metrics(val_loader)
# Save model
if save_path:
self.save(save_path)
return self
def _print_validation_metrics(self, val_loader):
"""Print validation accuracy metrics."""
self.model.eval()
all_preds = []
all_targets = []
with torch.no_grad():
for x, y in val_loader:
x = x.to(self.device)
pred = self.model(x).cpu().numpy()
all_preds.append(pred)
all_targets.append(y.numpy())
all_preds = np.concatenate(all_preds)
all_targets = np.concatenate(all_targets)
# Denormalize
preds_denorm = all_preds * self.normalization['objective_std'] + self.normalization['objective_mean']
targets_denorm = all_targets * self.normalization['objective_std'] + self.normalization['objective_mean']
print(f"\nValidation accuracy:")
for i, name in enumerate(self.objective_names):
mae = np.abs(preds_denorm[:, i] - targets_denorm[:, i]).mean()
mape = (np.abs(preds_denorm[:, i] - targets_denorm[:, i]) /
(np.abs(targets_denorm[:, i]) + 1e-8)).mean() * 100
print(f" {name}: MAE={mae:.4f}, MAPE={mape:.1f}%")
def predict(self, design_params: Dict[str, float]) -> Dict[str, float]:
"""
Predict objectives from design parameters.
Args:
design_params: Dictionary of design variable values
Returns:
Dictionary of predicted objective values
"""
if self.model is None:
raise ValueError("Model not trained. Call train_from_database first.")
# Build input array
x = np.array([design_params.get(name, 0) for name in self.design_var_names], dtype=np.float32)
x_norm = (x - self.normalization['design_mean']) / self.normalization['design_std']
x_tensor = torch.tensor(x_norm, dtype=torch.float32, device=self.device).unsqueeze(0)
# Predict
self.model.eval()
with torch.no_grad():
y_norm = self.model(x_tensor).cpu().numpy()[0]
# Denormalize
y = y_norm * self.normalization['objective_std'] + self.normalization['objective_mean']
return {name: float(y[i]) for i, name in enumerate(self.objective_names)}
def sample_random_design(self) -> Dict[str, float]:
"""Sample a random point in the design space."""
params = {}
for name in self.design_var_names:
low, high = self.design_var_bounds[name]
if self.design_var_types[name] == 'integer':
params[name] = float(np.random.randint(int(low), int(high) + 1))
else:
params[name] = np.random.uniform(low, high)
return params
def save(self, path: Path):
"""Save model to file."""
path = Path(path)
torch.save({
'model_state_dict': self.model.state_dict(),
'normalization': {
'design_mean': self.normalization['design_mean'].tolist(),
'design_std': self.normalization['design_std'].tolist(),
'objective_mean': self.normalization['objective_mean'].tolist(),
'objective_std': self.normalization['objective_std'].tolist()
},
'design_var_names': self.design_var_names,
'objective_names': self.objective_names,
'n_inputs': self.n_inputs,
'n_outputs': self.n_outputs,
'hidden_dims': self._get_hidden_dims()
}, path)
print(f"Model saved to {path}")
def load(self, path: Path):
"""Load model from file."""
path = Path(path)
checkpoint = torch.load(path, map_location=self.device)
hidden_dims = checkpoint.get('hidden_dims', self._get_hidden_dims())
self.model = MLPSurrogate(
n_inputs=checkpoint['n_inputs'],
n_outputs=checkpoint['n_outputs'],
hidden_dims=hidden_dims
).to(self.device)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.eval()
norm = checkpoint['normalization']
self.normalization = {
'design_mean': np.array(norm['design_mean']),
'design_std': np.array(norm['design_std']),
'objective_mean': np.array(norm['objective_mean']),
'objective_std': np.array(norm['objective_std'])
}
self.design_var_names = checkpoint.get('design_var_names', self.design_var_names)
self.objective_names = checkpoint.get('objective_names', self.objective_names)
print(f"Model loaded from {path}")
class ConfigDrivenSurrogate:
"""
Fully config-driven neural surrogate system.
Provides complete --train, --turbo, --all workflow based on optimization_config.json.
Handles FEA validation, surrogate retraining, and result reporting automatically.
"""
def __init__(self, script_path: str, config_path: Optional[str] = None,
element_type: str = 'auto'):
"""
Initialize config-driven surrogate.
Args:
script_path: Path to study's run_nn_optimization.py (__file__)
config_path: Optional explicit path to config
element_type: Element type for stress extraction ('auto' detects from DAT file)
"""
self.study_dir = Path(script_path).parent
self.config_path = Path(config_path) if config_path else self._find_config()
self.model_dir = self.study_dir / "1_setup" / "model"
self.results_dir = self.study_dir / "2_results"
# Load config
with open(self.config_path, 'r') as f:
self.raw_config = json.load(f)
# Normalize config (reuse from base_runner)
self.config = self._normalize_config(self.raw_config)
self.study_name = self.config['study_name']
self.element_type = element_type
self.surrogate = None
self.logger = None
self.nx_solver = None
def _find_config(self) -> Path:
"""Find the optimization config file."""
candidates = [
self.study_dir / "optimization_config.json",
self.study_dir / "1_setup" / "optimization_config.json",
]
for path in candidates:
if path.exists():
return path
raise FileNotFoundError(f"No optimization_config.json found in {self.study_dir}")
def _normalize_config(self, config: Dict) -> Dict:
"""Normalize config format variations."""
# This mirrors ConfigNormalizer from base_runner.py
normalized = {
'study_name': config.get('study_name', 'unnamed_study'),
'description': config.get('description', ''),
'design_variables': [],
'objectives': [],
'constraints': [],
'simulation': {},
'neural_acceleration': config.get('neural_acceleration', {}),
}
# Normalize design variables
for var in config.get('design_variables', []):
normalized['design_variables'].append({
'name': var.get('parameter') or var.get('name'),
'type': var.get('type', 'continuous'),
'min': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[0] if 'bounds' in var else var.get('min', 0),
'max': var.get('bounds', [var.get('min', 0), var.get('max', 1)])[1] if 'bounds' in var else var.get('max', 1),
})
# Normalize objectives
for obj in config.get('objectives', []):
normalized['objectives'].append({
'name': obj.get('name'),
'direction': obj.get('goal') or obj.get('direction', 'minimize'),
})
# Normalize simulation
sim = config.get('simulation', {})
normalized['simulation'] = {
'sim_file': sim.get('sim_file', ''),
'dat_file': sim.get('dat_file', ''),
'solution_name': sim.get('solution_name', 'Solution 1'),
}
return normalized
def _setup(self):
"""Initialize solver and logger."""
project_root = self.study_dir.parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from optimization_engine.nx_solver import NXSolver
from optimization_engine.logger import get_logger
self.results_dir.mkdir(exist_ok=True)
self.logger = get_logger(self.study_name, study_dir=self.results_dir)
self.nx_solver = NXSolver(nastran_version="2506")
def _detect_element_type(self, dat_file: Path) -> str:
"""Auto-detect element type from DAT file."""
if self.element_type != 'auto':
return self.element_type
try:
with open(dat_file, 'r') as f:
content = f.read(50000)
if 'CTETRA' in content:
return 'ctetra'
elif 'CHEXA' in content:
return 'chexa'
elif 'CQUAD4' in content:
return 'cquad4'
else:
return 'ctetra'
except Exception:
return 'ctetra'
def train(self, epochs: int = 300) -> GenericSurrogate:
"""Train surrogate model from FEA database."""
print(f"\n{'='*60}")
print("PHASE: Train Surrogate Model")
print(f"{'='*60}")
self.surrogate = GenericSurrogate(self.config, device='auto')
self.surrogate.train_from_database(
db_path=self.results_dir / "study.db",
study_name=self.study_name,
epochs=epochs,
save_path=self.results_dir / "surrogate_best.pt"
)
return self.surrogate
def turbo(self, total_nn_trials: int = 5000, batch_size: int = 100,
retrain_every: int = 10, epochs: int = 150):
"""
Run TURBO mode: NN exploration + FEA validation + surrogate retraining.
Args:
total_nn_trials: Total NN trials to run
batch_size: NN trials per batch before FEA validation
retrain_every: Retrain surrogate every N FEA validations
epochs: Training epochs for surrogate
"""
from optimization_engine.extractors.bdf_mass_extractor import extract_mass_from_bdf
from optimization_engine.extractors.extract_displacement import extract_displacement
from optimization_engine.extractors.extract_von_mises_stress import extract_solid_stress
print(f"\n{'#'*60}")
print(f"# TURBO MODE: {self.study_name}")
print(f"{'#'*60}")
print(f"Design variables: {len(self.config['design_variables'])}")
print(f"Objectives: {len(self.config['objectives'])}")
print(f"Total NN budget: {total_nn_trials:,} trials")
print(f"NN batch size: {batch_size}")
print(f"Expected FEA validations: ~{total_nn_trials // batch_size}")
# Initial training
print(f"\n[INIT] Training initial surrogate...")
self.train(epochs=epochs)
sim_file = self.model_dir / self.config['simulation']['sim_file']
dat_file = self.model_dir / self.config['simulation']['dat_file']
element_type = self._detect_element_type(dat_file)
fea_count = 0
nn_count = 0
best_solutions = []
iteration = 0
start_time = time.time()
# Get objective info
obj_names = [o['name'] for o in self.config['objectives']]
obj_directions = [o['direction'] for o in self.config['objectives']]
while nn_count < total_nn_trials:
iteration += 1
batch_trials = min(batch_size, total_nn_trials - nn_count)
print(f"\n{''*50}")
print(f"Iteration {iteration}: NN trials {nn_count+1}-{nn_count+batch_trials}")
# Find best candidate via NN
best_candidate = None
best_score = float('inf')
for _ in range(batch_trials):
params = self.surrogate.sample_random_design()
pred = self.surrogate.predict(params)
# Compute score (simple weighted sum - lower is better)
score = sum(pred[name] if obj_directions[i] == 'minimize' else -pred[name]
for i, name in enumerate(obj_names))
if score < best_score:
best_score = score
best_candidate = {'params': params, 'nn_pred': pred}
nn_count += batch_trials
params = best_candidate['params']
nn_pred = best_candidate['nn_pred']
# Log NN prediction
var_str = ", ".join(f"{k}={v:.2f}" for k, v in list(params.items())[:3])
print(f" Best NN: {var_str}...")
pred_str = ", ".join(f"{k}={v:.2f}" for k, v in nn_pred.items())
print(f" NN pred: {pred_str}")
# Run FEA validation
result = self.nx_solver.run_simulation(
sim_file=sim_file,
working_dir=self.model_dir,
expression_updates=params,
solution_name=self.config['simulation'].get('solution_name'),
cleanup=True
)
if not result['success']:
print(f" FEA FAILED - skipping")
continue
# Extract FEA results
op2_file = result['op2_file']
fea_results = self._extract_fea_results(op2_file, dat_file, element_type,
extract_mass_from_bdf, extract_displacement,
extract_solid_stress)
fea_str = ", ".join(f"{k}={v:.2f}" for k, v in fea_results.items())
print(f" FEA: {fea_str}")
# Compute errors
errors = {}
for name in obj_names:
if name in fea_results and name in nn_pred and fea_results[name] != 0:
errors[name] = abs(fea_results[name] - nn_pred[name]) / abs(fea_results[name]) * 100
if errors:
err_str = ", ".join(f"{k}={v:.1f}%" for k, v in errors.items())
print(f" Error: {err_str}")
fea_count += 1
# Add to main study database
self._add_to_study(params, fea_results, iteration)
best_solutions.append({
'iteration': iteration,
'params': {k: float(v) for k, v in params.items()},
'fea': [fea_results.get(name, 0) for name in obj_names],
'nn_error': [errors.get(name, 0) for name in obj_names[:2]] # First 2 errors
})
# Retrain periodically
if fea_count % retrain_every == 0:
print(f"\n [RETRAIN] Retraining surrogate...")
self.train(epochs=epochs)
# Progress
elapsed = time.time() - start_time
rate = nn_count / elapsed if elapsed > 0 else 0
remaining = (total_nn_trials - nn_count) / rate if rate > 0 else 0
print(f" Progress: {nn_count:,}/{total_nn_trials:,} NN | {fea_count} FEA | {elapsed/60:.1f}min | ~{remaining/60:.1f}min left")
# Final summary
print(f"\n{'#'*60}")
print("# TURBO MODE COMPLETE")
print(f"{'#'*60}")
print(f"NN trials: {nn_count:,}")
print(f"FEA validations: {fea_count}")
print(f"Time: {(time.time() - start_time)/60:.1f} minutes")
# Save report
turbo_report = {
'mode': 'turbo',
'total_nn_trials': nn_count,
'fea_validations': fea_count,
'time_minutes': (time.time() - start_time) / 60,
'best_solutions': best_solutions[-20:]
}
report_path = self.results_dir / "turbo_report.json"
with open(report_path, 'w') as f:
json.dump(turbo_report, f, indent=2)
print(f"\nReport saved to {report_path}")
def _extract_fea_results(self, op2_file: Path, dat_file: Path, element_type: str,
extract_mass_from_bdf, extract_displacement, extract_solid_stress) -> Dict[str, float]:
"""Extract FEA results for all objectives."""
results = {}
for obj in self.config['objectives']:
name = obj['name'].lower()
try:
if 'mass' in name:
results[obj['name']] = extract_mass_from_bdf(str(dat_file))
elif 'stress' in name:
stress_result = extract_solid_stress(op2_file, subcase=1, element_type=element_type)
results[obj['name']] = stress_result.get('max_von_mises', float('inf')) / 1000.0
elif 'displacement' in name:
disp_result = extract_displacement(op2_file, subcase=1)
results[obj['name']] = disp_result['max_displacement']
elif 'stiffness' in name:
disp_result = extract_displacement(op2_file, subcase=1)
max_disp = disp_result['max_displacement']
# Negative for minimization in multi-objective
results[obj['name']] = -1000.0 / max(abs(max_disp), 1e-6)
results['displacement'] = max_disp
except Exception as e:
print(f" Warning: Failed to extract {name}: {e}")
results[obj['name']] = float('inf')
return results
def _add_to_study(self, params: Dict, fea_results: Dict, iteration: int):
"""Add FEA result to main Optuna study."""
try:
storage = f"sqlite:///{self.results_dir / 'study.db'}"
study = optuna.load_study(
study_name=self.study_name,
storage=storage,
sampler=NSGAIISampler(population_size=20, seed=42)
)
trial = study.ask()
for var in self.config['design_variables']:
name = var['name']
value = params[name]
if var['type'] == 'integer':
trial.suggest_int(name, int(value), int(value))
else:
trial.suggest_float(name, value, value)
# Get objective values in order
obj_values = [fea_results.get(o['name'], float('inf')) for o in self.config['objectives']]
study.tell(trial, obj_values)
trial.set_user_attr('source', 'turbo_mode')
trial.set_user_attr('iteration', iteration)
except Exception as e:
print(f" Warning: couldn't add to study: {e}")
def run(self, args=None):
"""
Main entry point with argument parsing.
Handles --train, --turbo, --all flags.
"""
if args is None:
args = self.parse_args()
self._setup()
print(f"\n{'#'*60}")
print(f"# {self.study_name} - Hybrid NN Optimization")
print(f"{'#'*60}")
if args.all or args.train:
self.train(epochs=args.epochs)
if args.all or args.turbo:
self.turbo(
total_nn_trials=args.nn_trials,
batch_size=args.batch_size,
retrain_every=args.retrain_every,
epochs=args.epochs
)
print(f"\n{'#'*60}")
print("# Workflow Complete!")
print(f"{'#'*60}\n")
return 0
def parse_args(self) -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description=f'{self.study_name} - Hybrid NN Optimization')
parser.add_argument('--train', action='store_true', help='Train surrogate only')
parser.add_argument('--turbo', action='store_true', help='TURBO mode (recommended)')
parser.add_argument('--all', action='store_true', help='Train then run turbo')
nn_config = self.config.get('neural_acceleration', {})
parser.add_argument('--epochs', type=int, default=nn_config.get('epochs', 200), help='Training epochs')
parser.add_argument('--nn-trials', type=int, default=nn_config.get('nn_trials', 5000), help='Total NN trials')
parser.add_argument('--batch-size', type=int, default=100, help='NN batch size')
parser.add_argument('--retrain-every', type=int, default=10, help='Retrain every N FEA')
args = parser.parse_args()
if not any([args.train, args.turbo, args.all]):
print("No phase specified. Use --train, --turbo, or --all")
print("\nRecommended workflow:")
print(f" python run_nn_optimization.py --turbo --nn-trials {nn_config.get('nn_trials', 5000)}")
sys.exit(1)
return args
def create_surrogate(script_path: str, element_type: str = 'auto') -> ConfigDrivenSurrogate:
"""
Factory function to create a ConfigDrivenSurrogate.
Args:
script_path: Path to study's run_nn_optimization.py (__file__)
element_type: Element type for stress extraction
Returns:
Configured surrogate ready to run
"""
return ConfigDrivenSurrogate(script_path, element_type=element_type)

View File

@@ -0,0 +1,69 @@
"""
GNN (Graph Neural Network) Surrogate Module for Atomizer
=========================================================
This module provides Graph Neural Network-based surrogates for FEA optimization,
particularly designed for Zernike-based mirror optimization where spatial structure
matters.
Key Components:
- PolarMirrorGraph: Fixed polar grid graph structure for mirror surface
- ZernikeGNN: GNN model for predicting displacement fields
- DifferentiableZernikeFit: GPU-accelerated Zernike fitting
- ZernikeObjectiveLayer: Compute objectives from displacement fields
- ZernikeGNNTrainer: Complete training pipeline
Why GNN over MLP for Zernike?
1. Spatial awareness: GNN learns smooth deformation fields via message passing
2. Correct relative computation: Predicts fields, then subtracts (like FEA)
3. Multi-task learning: Field + objective supervision
4. Physics-informed: Edge structure respects mirror geometry
Usage:
# Training
python -m optimization_engine.gnn.train_zernike_gnn V11 V12 --epochs 200
# API
from optimization_engine.gnn import PolarMirrorGraph, ZernikeGNN, ZernikeGNNTrainer
"""
__version__ = "1.0.0"
# Core components
from .polar_graph import PolarMirrorGraph, create_mirror_dataset
from .zernike_gnn import ZernikeGNN, ZernikeGNNLite, create_model, load_model
from .differentiable_zernike import (
DifferentiableZernikeFit,
ZernikeObjectiveLayer,
ZernikeRMSLoss,
build_zernike_matrix,
)
from .extract_displacement_field import (
extract_displacement_field,
save_field,
load_field,
)
from .train_zernike_gnn import ZernikeGNNTrainer, MirrorDataset
__all__ = [
# Polar Graph
'PolarMirrorGraph',
'create_mirror_dataset',
# GNN Model
'ZernikeGNN',
'ZernikeGNNLite',
'create_model',
'load_model',
# Zernike Layers
'DifferentiableZernikeFit',
'ZernikeObjectiveLayer',
'ZernikeRMSLoss',
'build_zernike_matrix',
# Field Extraction
'extract_displacement_field',
'save_field',
'load_field',
# Training
'ZernikeGNNTrainer',
'MirrorDataset',
]

View File

@@ -0,0 +1,475 @@
"""
Backfill Displacement Field Data from Existing Trials
======================================================
This script scans existing mirror optimization studies (V11, V12, etc.) and extracts
displacement field data from OP2 files for GNN training.
Structure it expects:
studies/m1_mirror_adaptive_V11/
├── 2_iterations/
│ ├── iter91/
│ │ ├── assy_m1_assyfem1_sim1-solution_1.op2
│ │ ├── assy_m1_assyfem1_sim1-solution_1.dat
│ │ └── params.exp
│ ├── iter92/
│ │ └── ...
└── 3_results/
└── study.db (Optuna database)
Output structure:
studies/m1_mirror_adaptive_V11/
└── gnn_data/
├── trial_0000/
│ ├── displacement_field.h5
│ └── metadata.json
├── trial_0001/
│ └── ...
└── dataset_index.json (maps iter -> trial)
Usage:
python -m optimization_engine.gnn.backfill_field_data V11
python -m optimization_engine.gnn.backfill_field_data V11 V12 --merge
"""
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime
import numpy as np
# Add parent to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from optimization_engine.gnn.extract_displacement_field import (
extract_displacement_field,
save_field,
load_field,
HAS_H5PY,
)
def find_studies(base_dir: Path, pattern: str = "m1_mirror_adaptive_V*") -> List[Path]:
"""Find all matching study directories."""
studies_dir = base_dir / "studies"
matches = list(studies_dir.glob(pattern))
return sorted(matches)
def find_op2_files(study_dir: Path) -> List[Tuple[int, Path, Path]]:
"""
Find all OP2 files in iteration folders.
Returns:
List of (iter_number, op2_path, dat_path) tuples
"""
iterations_dir = study_dir / "2_iterations"
if not iterations_dir.exists():
print(f"[WARN] No 2_iterations folder in {study_dir.name}")
return []
results = []
for iter_dir in sorted(iterations_dir.iterdir()):
if not iter_dir.is_dir():
continue
# Extract iteration number
match = re.match(r'iter(\d+)', iter_dir.name)
if not match:
continue
iter_num = int(match.group(1))
# Find OP2 file
op2_files = list(iter_dir.glob('*-solution_1.op2'))
if not op2_files:
op2_files = list(iter_dir.glob('*.op2'))
if not op2_files:
continue
op2_path = op2_files[0]
# Find DAT file
dat_path = op2_path.with_suffix('.dat')
if not dat_path.exists():
dat_path = op2_path.with_suffix('.bdf')
if not dat_path.exists():
print(f"[WARN] No DAT/BDF for {op2_path.name}, skipping")
continue
results.append((iter_num, op2_path, dat_path))
return results
def read_params_exp(iter_dir: Path) -> Optional[Dict[str, float]]:
"""Read design parameters from params.exp file."""
params_file = iter_dir / "params.exp"
if not params_file.exists():
return None
params = {}
with open(params_file, 'r') as f:
for line in f:
line = line.strip()
if '=' in line:
# Format: name = value
parts = line.split('=')
if len(parts) == 2:
name = parts[0].strip()
try:
value = float(parts[1].strip())
params[name] = value
except ValueError:
pass
return params
def backfill_study(
study_dir: Path,
output_dir: Optional[Path] = None,
r_inner: float = 100.0,
r_outer: float = 650.0,
overwrite: bool = False,
verbose: bool = True
) -> Dict[str, Any]:
"""
Backfill displacement field data for a single study.
Args:
study_dir: Path to study directory
output_dir: Output directory (default: study_dir/gnn_data)
r_inner: Inner radius for surface identification
r_outer: Outer radius for surface identification
overwrite: Overwrite existing field data
verbose: Print progress
Returns:
Summary dictionary with statistics
"""
if output_dir is None:
output_dir = study_dir / "gnn_data"
output_dir.mkdir(parents=True, exist_ok=True)
if verbose:
print(f"\n{'='*60}")
print(f"BACKFILLING: {study_dir.name}")
print(f"{'='*60}")
# Find all OP2 files
op2_list = find_op2_files(study_dir)
if verbose:
print(f"Found {len(op2_list)} iterations with OP2 files")
# Track results
success_count = 0
skip_count = 0
error_count = 0
index = {}
for iter_num, op2_path, dat_path in op2_list:
# Create trial directory
trial_dir = output_dir / f"trial_{iter_num:04d}"
# Check if already exists
field_ext = '.h5' if HAS_H5PY else '.npz'
field_path = trial_dir / f"displacement_field{field_ext}"
if field_path.exists() and not overwrite:
if verbose:
print(f"[SKIP] iter{iter_num}: already processed")
skip_count += 1
index[iter_num] = {
'trial_dir': str(trial_dir.relative_to(study_dir)),
'status': 'skipped',
}
continue
try:
# Extract displacement field
if verbose:
print(f"[{iter_num:3d}] Extracting from {op2_path.name}...", end=' ')
field_data = extract_displacement_field(
op2_path,
bdf_path=dat_path,
r_inner=r_inner,
r_outer=r_outer,
verbose=False
)
# Save field data
trial_dir.mkdir(parents=True, exist_ok=True)
save_field(field_data, field_path)
# Read params if available
params = read_params_exp(op2_path.parent)
# Save metadata
meta = {
'iter_number': iter_num,
'op2_file': str(op2_path.name),
'n_nodes': len(field_data['node_ids']),
'subcases': list(field_data['z_displacement'].keys()),
'params': params,
'extraction_timestamp': datetime.now().isoformat(),
}
meta_path = trial_dir / "metadata.json"
with open(meta_path, 'w') as f:
json.dump(meta, f, indent=2)
if verbose:
print(f"OK ({len(field_data['node_ids'])} nodes)")
success_count += 1
index[iter_num] = {
'trial_dir': str(trial_dir.relative_to(study_dir)),
'n_nodes': len(field_data['node_ids']),
'params': params,
'status': 'success',
}
except Exception as e:
if verbose:
print(f"ERROR: {e}")
error_count += 1
index[iter_num] = {
'trial_dir': str(trial_dir.relative_to(study_dir)) if trial_dir.exists() else None,
'error': str(e),
'status': 'error',
}
# Save index file
index_path = output_dir / "dataset_index.json"
index_data = {
'study_name': study_dir.name,
'generated': datetime.now().isoformat(),
'summary': {
'total': len(op2_list),
'success': success_count,
'skipped': skip_count,
'errors': error_count,
},
'trials': index,
}
with open(index_path, 'w') as f:
json.dump(index_data, f, indent=2)
if verbose:
print(f"\n{'='*60}")
print(f"SUMMARY: {study_dir.name}")
print(f" Success: {success_count}")
print(f" Skipped: {skip_count}")
print(f" Errors: {error_count}")
print(f" Index: {index_path}")
print(f"{'='*60}")
return index_data
def merge_datasets(
study_dirs: List[Path],
output_dir: Path,
train_ratio: float = 0.8,
verbose: bool = True
) -> Dict[str, Any]:
"""
Merge displacement field data from multiple studies into a single dataset.
Args:
study_dirs: List of study directories
output_dir: Output directory for merged dataset
train_ratio: Fraction of data for training (rest for validation)
verbose: Print progress
Returns:
Dataset metadata dictionary
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if verbose:
print(f"\n{'='*60}")
print("MERGING DATASETS")
print(f"{'='*60}")
all_trials = []
for study_dir in study_dirs:
gnn_data_dir = study_dir / "gnn_data"
index_path = gnn_data_dir / "dataset_index.json"
if not index_path.exists():
print(f"[WARN] No index for {study_dir.name}, run backfill first")
continue
with open(index_path, 'r') as f:
index = json.load(f)
study_name = study_dir.name
for iter_num, trial_info in index['trials'].items():
if trial_info.get('status') != 'success':
continue
trial_dir = study_dir / trial_info['trial_dir']
all_trials.append({
'study': study_name,
'iter': int(iter_num),
'trial_dir': trial_dir,
'params': trial_info.get('params', {}),
'n_nodes': trial_info.get('n_nodes'),
})
if verbose:
print(f"Total successful trials: {len(all_trials)}")
# Shuffle and split
np.random.seed(42)
indices = np.random.permutation(len(all_trials))
n_train = int(len(all_trials) * train_ratio)
train_indices = indices[:n_train]
val_indices = indices[n_train:]
# Create split files
splits = {
'train': [all_trials[i] for i in train_indices],
'val': [all_trials[i] for i in val_indices],
}
for split_name, trials in splits.items():
split_dir = output_dir / split_name
split_dir.mkdir(exist_ok=True)
split_meta = []
for i, trial in enumerate(trials):
# Copy/link field data
src_ext = '.h5' if HAS_H5PY else '.npz'
src_path = trial['trial_dir'] / f"displacement_field{src_ext}"
dst_path = split_dir / f"sample_{i:04d}{src_ext}"
if src_path.exists():
# Copy file (or could use symlink on Linux)
import shutil
shutil.copy(src_path, dst_path)
split_meta.append({
'index': i,
'source_study': trial['study'],
'source_iter': trial['iter'],
'params': trial['params'],
'n_nodes': trial['n_nodes'],
})
# Save split metadata
meta_path = split_dir / "metadata.json"
with open(meta_path, 'w') as f:
json.dump({
'split': split_name,
'n_samples': len(split_meta),
'samples': split_meta,
}, f, indent=2)
if verbose:
print(f" {split_name}: {len(split_meta)} samples")
# Save overall metadata
dataset_meta = {
'created': datetime.now().isoformat(),
'source_studies': [str(s.name) for s in study_dirs],
'total_samples': len(all_trials),
'train_samples': len(splits['train']),
'val_samples': len(splits['val']),
'train_ratio': train_ratio,
}
with open(output_dir / "dataset_meta.json", 'w') as f:
json.dump(dataset_meta, f, indent=2)
if verbose:
print(f"\nDataset saved to: {output_dir}")
print(f" Train: {len(splits['train'])} samples")
print(f" Val: {len(splits['val'])} samples")
return dataset_meta
# =============================================================================
# CLI
# =============================================================================
def main():
import argparse
parser = argparse.ArgumentParser(
description='Backfill displacement field data for GNN training'
)
parser.add_argument('studies', nargs='+', type=str,
help='Study versions (e.g., V11 V12) or "all"')
parser.add_argument('--merge', action='store_true',
help='Merge data from multiple studies')
parser.add_argument('--output', '-o', type=Path,
help='Output directory for merged dataset')
parser.add_argument('--r-inner', type=float, default=100.0,
help='Inner radius (mm)')
parser.add_argument('--r-outer', type=float, default=650.0,
help='Outer radius (mm)')
parser.add_argument('--overwrite', action='store_true',
help='Overwrite existing field data')
parser.add_argument('--train-ratio', type=float, default=0.8,
help='Train/val split ratio')
args = parser.parse_args()
# Find base directory
base_dir = Path(__file__).parent.parent.parent
# Find studies
if args.studies == ['all']:
study_dirs = find_studies(base_dir, "m1_mirror_adaptive_V*")
else:
study_dirs = []
for s in args.studies:
if s.startswith('V'):
pattern = f"m1_mirror_adaptive_{s}"
else:
pattern = s
matches = find_studies(base_dir, pattern)
study_dirs.extend(matches)
if not study_dirs:
print("No studies found!")
return 1
print(f"Found {len(study_dirs)} studies:")
for s in study_dirs:
print(f" - {s.name}")
# Backfill each study
for study_dir in study_dirs:
backfill_study(
study_dir,
r_inner=args.r_inner,
r_outer=args.r_outer,
overwrite=args.overwrite,
)
# Merge if requested
if args.merge and len(study_dirs) > 1:
output_dir = args.output
if output_dir is None:
output_dir = base_dir / "studies" / "gnn_merged_dataset"
merge_datasets(
study_dirs,
output_dir,
train_ratio=args.train_ratio,
)
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,544 @@
"""
Differentiable Zernike Fitting Layer
=====================================
This module provides GPU-accelerated, differentiable Zernike polynomial fitting.
The key innovation is putting Zernike fitting INSIDE the neural network for
end-to-end training.
Why Differentiable Zernike?
Current MLP approach:
design → MLP → coefficients (learn 200 outputs independently)
GNN + Differentiable Zernike:
design → GNN → displacement field → Zernike fit → coefficients
Differentiable! Gradients flow back
This allows the network to learn:
1. Spatially coherent displacement fields
2. Fields that produce accurate Zernike coefficients
3. Correct relative deformation computation
Components:
1. DifferentiableZernikeFit - Fits coefficients from displacement field
2. ZernikeObjectiveLayer - Computes RMS objectives like FEA post-processing
Usage:
from optimization_engine.gnn.differentiable_zernike import (
DifferentiableZernikeFit,
ZernikeObjectiveLayer
)
from optimization_engine.gnn.polar_graph import PolarMirrorGraph
graph = PolarMirrorGraph()
objective_layer = ZernikeObjectiveLayer(graph, n_modes=50)
# In forward pass:
z_disp = gnn_model(...) # [n_nodes, 4]
objectives = objective_layer(z_disp) # Dict with RMS values
"""
import math
import numpy as np
import torch
import torch.nn as nn
from typing import Dict, Optional, Tuple
from pathlib import Path
def zernike_noll(j: int, r: np.ndarray, theta: np.ndarray) -> np.ndarray:
"""
Compute Zernike polynomial for Noll index j.
Uses the standard Noll indexing convention:
j=1: piston, j=2: tilt-y, j=3: tilt-x, j=4: defocus, etc.
Args:
j: Noll index (1-based)
r: Radial coordinates (normalized to [0, 1])
theta: Angular coordinates (radians)
Returns:
Zernike polynomial values at (r, theta)
"""
# Convert Noll index to (n, m)
n = int(np.ceil((-3 + np.sqrt(9 + 8 * (j - 1))) / 2))
m_sum = j - n * (n + 1) // 2 - 1
if n % 2 == 0:
m = 2 * (m_sum // 2) if j % 2 == 1 else 2 * (m_sum // 2) + 1
else:
m = 2 * (m_sum // 2) + 1 if j % 2 == 1 else 2 * (m_sum // 2)
if (n - m) % 2 == 1:
m = -m
# Compute radial polynomial R_n^|m|(r)
R = np.zeros_like(r)
m_abs = abs(m)
for k in range((n - m_abs) // 2 + 1):
coef = ((-1) ** k * math.factorial(n - k) /
(math.factorial(k) *
math.factorial((n + m_abs) // 2 - k) *
math.factorial((n - m_abs) // 2 - k)))
R += coef * r ** (n - 2 * k)
# Combine with angular part
if m >= 0:
Z = R * np.cos(m_abs * theta)
else:
Z = R * np.sin(m_abs * theta)
# Normalization factor
if m == 0:
norm = np.sqrt(n + 1)
else:
norm = np.sqrt(2 * (n + 1))
return norm * Z
def build_zernike_matrix(
r: np.ndarray,
theta: np.ndarray,
n_modes: int = 50,
r_max: float = None
) -> np.ndarray:
"""
Build Zernike basis matrix for a set of points.
Args:
r: Radial coordinates
theta: Angular coordinates
n_modes: Number of Zernike modes (Noll indices 1 to n_modes)
r_max: Maximum radius for normalization (if None, use max(r))
Returns:
Z: [n_points, n_modes] Zernike basis matrix
"""
if r_max is None:
r_max = r.max()
r_norm = r / r_max
n_points = len(r)
Z = np.zeros((n_points, n_modes), dtype=np.float64)
for j in range(1, n_modes + 1):
Z[:, j - 1] = zernike_noll(j, r_norm, theta)
return Z
class DifferentiableZernikeFit(nn.Module):
"""
GPU-accelerated, differentiable Zernike polynomial fitting.
This layer fits Zernike coefficients to a displacement field using
least squares. The key insight is that least squares has a closed-form
solution: c = (Z^T Z)^{-1} Z^T @ values
By precomputing (Z^T Z)^{-1} Z^T, we can fit coefficients with a single
matrix multiply, which is fully differentiable.
"""
def __init__(
self,
polar_graph,
n_modes: int = 50,
regularization: float = 1e-6
):
"""
Args:
polar_graph: PolarMirrorGraph instance
n_modes: Number of Zernike modes to fit
regularization: Tikhonov regularization for stability
"""
super().__init__()
self.n_modes = n_modes
# Get coordinates from polar graph
r = polar_graph.r
theta = polar_graph.theta
r_max = polar_graph.r_outer
# Build Zernike basis matrix [n_nodes, n_modes]
Z = build_zernike_matrix(r, theta, n_modes, r_max)
# Convert to tensor and register as buffer
Z_tensor = torch.tensor(Z, dtype=torch.float32)
self.register_buffer('Z', Z_tensor)
# Precompute pseudo-inverse with regularization
# c = (Z^T Z + λI)^{-1} Z^T @ values
ZtZ = Z_tensor.T @ Z_tensor
ZtZ_reg = ZtZ + regularization * torch.eye(n_modes)
ZtZ_inv = torch.inverse(ZtZ_reg)
pseudo_inv = ZtZ_inv @ Z_tensor.T # [n_modes, n_nodes]
self.register_buffer('pseudo_inverse', pseudo_inv)
def forward(self, z_displacement: torch.Tensor) -> torch.Tensor:
"""
Fit Zernike coefficients to displacement field.
Args:
z_displacement: [n_nodes] or [n_nodes, n_subcases] displacement
Returns:
coefficients: [n_modes] or [n_subcases, n_modes]
"""
if z_displacement.dim() == 1:
# Single field: [n_nodes] → [n_modes]
return self.pseudo_inverse @ z_displacement
else:
# Multiple subcases: [n_nodes, n_subcases] → [n_subcases, n_modes]
# Transpose, multiply, transpose back
return (self.pseudo_inverse @ z_displacement).T
def reconstruct(self, coefficients: torch.Tensor) -> torch.Tensor:
"""
Reconstruct displacement field from coefficients.
Args:
coefficients: [n_modes] or [n_subcases, n_modes]
Returns:
z_displacement: [n_nodes] or [n_nodes, n_subcases]
"""
if coefficients.dim() == 1:
return self.Z @ coefficients
else:
return self.Z @ coefficients.T
def fit_and_residual(
self,
z_displacement: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Fit coefficients and return residual.
Args:
z_displacement: [n_nodes] or [n_nodes, n_subcases]
Returns:
coefficients, residual
"""
coeffs = self.forward(z_displacement)
reconstruction = self.reconstruct(coeffs)
residual = z_displacement - reconstruction
return coeffs, residual
class ZernikeObjectiveLayer(nn.Module):
"""
Compute Zernike-based optimization objectives from displacement field.
This layer replicates the exact computation done in FEA post-processing:
1. Compute relative displacement (e.g., 40° - 20°)
2. Convert to wavefront error (× 2 for reflection, mm → nm)
3. Fit Zernike and remove low-order terms
4. Compute filtered RMS
The computation is fully differentiable, allowing end-to-end training
with objective-based loss.
"""
def __init__(
self,
polar_graph,
n_modes: int = 50,
regularization: float = 1e-6
):
"""
Args:
polar_graph: PolarMirrorGraph instance
n_modes: Number of Zernike modes
regularization: Regularization for Zernike fitting
"""
super().__init__()
self.n_modes = n_modes
self.zernike_fit = DifferentiableZernikeFit(polar_graph, n_modes, regularization)
# Precompute Zernike basis subsets for filtering
Z = self.zernike_fit.Z
# Low-order modes (J1-J4: piston, tip, tilt, defocus)
self.register_buffer('Z_j1_to_j4', Z[:, :4])
# Only J1-J3 for manufacturing objective
self.register_buffer('Z_j1_to_j3', Z[:, :3])
# Store node count
self.n_nodes = Z.shape[0]
def forward(
self,
z_disp_all_subcases: torch.Tensor,
return_all: bool = False
) -> Dict[str, torch.Tensor]:
"""
Compute Zernike objectives from displacement field.
Args:
z_disp_all_subcases: [n_nodes, 4] Z-displacement for 4 subcases
Subcase order: 1=90°, 2=20°(ref), 3=40°, 4=60°
return_all: If True, return additional diagnostics
Returns:
Dictionary with objective values:
- rel_filtered_rms_40_vs_20: RMS after J1-J4 removal (nm)
- rel_filtered_rms_60_vs_20: RMS after J1-J4 removal (nm)
- mfg_90_optician_workload: RMS after J1-J3 removal (nm)
"""
# Unpack subcases
disp_90 = z_disp_all_subcases[:, 0] # Subcase 1: 90°
disp_20 = z_disp_all_subcases[:, 1] # Subcase 2: 20° (reference)
disp_40 = z_disp_all_subcases[:, 2] # Subcase 3: 40°
disp_60 = z_disp_all_subcases[:, 3] # Subcase 4: 60°
# === Objective 1: Relative filtered RMS 40° vs 20° ===
disp_rel_40 = disp_40 - disp_20
wfe_rel_40 = 2.0 * disp_rel_40 * 1e6 # mm → nm, ×2 for reflection
rms_40_vs_20 = self._compute_filtered_rms_j1_to_j4(wfe_rel_40)
# === Objective 2: Relative filtered RMS 60° vs 20° ===
disp_rel_60 = disp_60 - disp_20
wfe_rel_60 = 2.0 * disp_rel_60 * 1e6
rms_60_vs_20 = self._compute_filtered_rms_j1_to_j4(wfe_rel_60)
# === Objective 3: Manufacturing 90° (J1-J3 filtered) ===
disp_rel_90 = disp_90 - disp_20
wfe_rel_90 = 2.0 * disp_rel_90 * 1e6
mfg_90 = self._compute_filtered_rms_j1_to_j3(wfe_rel_90)
result = {
'rel_filtered_rms_40_vs_20': rms_40_vs_20,
'rel_filtered_rms_60_vs_20': rms_60_vs_20,
'mfg_90_optician_workload': mfg_90,
}
if return_all:
# Include intermediate values for debugging
result['wfe_rel_40'] = wfe_rel_40
result['wfe_rel_60'] = wfe_rel_60
result['wfe_rel_90'] = wfe_rel_90
return result
def _compute_filtered_rms_j1_to_j4(self, wfe: torch.Tensor) -> torch.Tensor:
"""
Compute RMS after removing J1-J4 (piston, tip, tilt, defocus).
This is the standard filtered RMS for optical performance.
"""
# Fit low-order coefficients using precomputed pseudo-inverse
# c = (Z^T Z)^{-1} Z^T @ wfe
Z_low = self.Z_j1_to_j4
ZtZ_low = Z_low.T @ Z_low
coeffs_low = torch.linalg.solve(ZtZ_low, Z_low.T @ wfe)
# Reconstruct low-order surface
wfe_low = Z_low @ coeffs_low
# Residual (high-order content)
wfe_filtered = wfe - wfe_low
# RMS
return torch.sqrt(torch.mean(wfe_filtered ** 2))
def _compute_filtered_rms_j1_to_j3(self, wfe: torch.Tensor) -> torch.Tensor:
"""
Compute RMS after removing only J1-J3 (piston, tip, tilt).
This keeps defocus (J4), which is harder to polish out - represents
actual manufacturing workload.
"""
Z_low = self.Z_j1_to_j3
ZtZ_low = Z_low.T @ Z_low
coeffs_low = torch.linalg.solve(ZtZ_low, Z_low.T @ wfe)
wfe_low = Z_low @ coeffs_low
wfe_filtered = wfe - wfe_low
return torch.sqrt(torch.mean(wfe_filtered ** 2))
class ZernikeRMSLoss(nn.Module):
"""
Combined loss function for GNN training.
This loss combines:
1. Displacement field reconstruction loss (MSE)
2. Objective prediction loss (relative Zernike RMS)
The multi-task loss helps the network learn both accurate
displacement fields AND accurate objective predictions.
"""
def __init__(
self,
polar_graph,
field_weight: float = 1.0,
objective_weight: float = 0.1,
n_modes: int = 50
):
"""
Args:
polar_graph: PolarMirrorGraph instance
field_weight: Weight for displacement field loss
objective_weight: Weight for objective loss
n_modes: Number of Zernike modes
"""
super().__init__()
self.field_weight = field_weight
self.objective_weight = objective_weight
self.objective_layer = ZernikeObjectiveLayer(polar_graph, n_modes)
def forward(
self,
z_disp_pred: torch.Tensor,
z_disp_true: torch.Tensor,
objectives_true: Optional[Dict[str, torch.Tensor]] = None
) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]:
"""
Compute combined loss.
Args:
z_disp_pred: Predicted displacement [n_nodes, 4]
z_disp_true: Ground truth displacement [n_nodes, 4]
objectives_true: Optional dict of true objective values
Returns:
total_loss, loss_components dict
"""
# Field reconstruction loss
loss_field = nn.functional.mse_loss(z_disp_pred, z_disp_true)
# Scale field loss to account for small displacement values
# Displacements are ~1e-4 mm, so MSE is ~1e-8
loss_field_scaled = loss_field * 1e8
components = {
'loss_field': loss_field_scaled,
}
total_loss = self.field_weight * loss_field_scaled
# Objective loss (if ground truth provided)
if objectives_true is not None and self.objective_weight > 0:
objectives_pred = self.objective_layer(z_disp_pred)
loss_obj = 0.0
for key in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
if key in objectives_true:
pred = objectives_pred[key]
true = objectives_true[key]
# Relative error squared
rel_err = ((pred - true) / (true + 1e-6)) ** 2
loss_obj = loss_obj + rel_err
components[f'loss_{key}'] = rel_err
components['loss_objectives'] = loss_obj
total_loss = total_loss + self.objective_weight * loss_obj
components['total_loss'] = total_loss
return total_loss, components
# =============================================================================
# Testing
# =============================================================================
if __name__ == '__main__':
import sys
sys.path.insert(0, "C:/Users/Antoine/Atomizer")
from optimization_engine.gnn.polar_graph import PolarMirrorGraph
print("="*60)
print("Testing Differentiable Zernike Layer")
print("="*60)
# Create polar graph
graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
print(f"\nPolar Graph: {graph.n_nodes} nodes")
# Create Zernike fitting layer
zernike_fit = DifferentiableZernikeFit(graph, n_modes=50)
print(f"Zernike Fit: {zernike_fit.n_modes} modes")
print(f" Z matrix: {zernike_fit.Z.shape}")
print(f" Pseudo-inverse: {zernike_fit.pseudo_inverse.shape}")
# Test with synthetic displacement
print("\n--- Test Zernike Fitting ---")
# Create synthetic displacement (defocus + astigmatism pattern)
r_norm = torch.tensor(graph.r / graph.r_outer, dtype=torch.float32)
theta = torch.tensor(graph.theta, dtype=torch.float32)
# Defocus (J4) + Astigmatism (J5)
synthetic_disp = 0.001 * (2 * r_norm**2 - 1) + 0.0005 * r_norm**2 * torch.cos(2 * theta)
# Fit coefficients
coeffs = zernike_fit(synthetic_disp)
print(f"Fitted coefficients shape: {coeffs.shape}")
print(f"First 10 coefficients: {coeffs[:10].tolist()}")
# Reconstruct
recon = zernike_fit.reconstruct(coeffs)
error = (synthetic_disp - recon).abs()
print(f"Reconstruction error: max={error.max():.6f}, mean={error.mean():.6f}")
# Test with multiple subcases
print("\n--- Test Multi-Subcase ---")
z_disp_multi = torch.stack([
synthetic_disp,
synthetic_disp * 0.5,
synthetic_disp * 0.7,
synthetic_disp * 0.9,
], dim=1) # [n_nodes, 4]
coeffs_multi = zernike_fit(z_disp_multi)
print(f"Multi-subcase coefficients: {coeffs_multi.shape}")
# Test objective layer
print("\n--- Test Objective Layer ---")
objective_layer = ZernikeObjectiveLayer(graph, n_modes=50)
objectives = objective_layer(z_disp_multi)
print("Computed objectives:")
for key, val in objectives.items():
print(f" {key}: {val.item():.2f} nm")
# Test gradient flow
print("\n--- Test Gradient Flow ---")
z_disp_grad = z_disp_multi.clone().detach().requires_grad_(True)
objectives = objective_layer(z_disp_grad)
loss = objectives['rel_filtered_rms_40_vs_20']
loss.backward()
print(f"Gradient shape: {z_disp_grad.grad.shape}")
print(f"Gradient range: [{z_disp_grad.grad.min():.6f}, {z_disp_grad.grad.max():.6f}]")
print("✓ Gradients flow through Zernike fitting!")
# Test loss function
print("\n--- Test Loss Function ---")
loss_fn = ZernikeRMSLoss(graph, field_weight=1.0, objective_weight=0.1)
z_pred = (z_disp_multi.detach() + 0.0001 * torch.randn_like(z_disp_multi)).requires_grad_(True)
total_loss, components = loss_fn(z_pred, z_disp_multi.detach())
print(f"Total loss: {total_loss.item():.6f}")
for key, val in components.items():
if isinstance(val, torch.Tensor):
print(f" {key}: {val.item():.6f}")
print("\n" + "="*60)
print("✓ All tests passed!")
print("="*60)

View File

@@ -0,0 +1,455 @@
"""
Displacement Field Extraction for GNN Training
===============================================
This module extracts full displacement fields from Nastran OP2 files for GNN training.
Unlike the Zernike extractors that reduce to coefficients, this preserves the raw
spatial data that GNN needs to learn the physics.
Key Features:
1. Extract Z-displacement for all optical surface nodes
2. Store node coordinates for graph construction
3. Support for multiple subcases (gravity orientations)
4. HDF5 storage for efficient loading during training
Output Format (HDF5):
/node_ids - [n_nodes] int array
/node_coords - [n_nodes, 3] float array (X, Y, Z)
/subcase_1 - [n_nodes] Z-displacement for subcase 1
/subcase_2 - [n_nodes] Z-displacement for subcase 2
/subcase_3 - [n_nodes] Z-displacement for subcase 3
/subcase_4 - [n_nodes] Z-displacement for subcase 4
/metadata - JSON string with extraction info
Usage:
from optimization_engine.gnn.extract_displacement_field import extract_displacement_field
field_data = extract_displacement_field(op2_path, bdf_path)
save_field_to_hdf5(field_data, output_path)
"""
import json
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
try:
import h5py
HAS_H5PY = True
except ImportError:
HAS_H5PY = False
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF
def identify_optical_surface_nodes(
node_coords: Dict[int, np.ndarray],
r_inner: float = 100.0,
r_outer: float = 650.0,
z_tolerance: float = 100.0
) -> Tuple[List[int], np.ndarray]:
"""
Identify nodes on the optical surface by spatial filtering.
The optical surface is identified by:
1. Radial position (between inner and outer radius)
2. Consistent Z range (nodes on the curved mirror surface)
Args:
node_coords: Dictionary mapping node ID to (X, Y, Z) coordinates
r_inner: Inner radius cutoff (central hole)
r_outer: Outer radius limit
z_tolerance: Maximum Z deviation from mean to include
Returns:
Tuple of (node_ids list, coordinates array [n, 3])
"""
# Get all coordinates as arrays
nids = list(node_coords.keys())
coords = np.array([node_coords[nid] for nid in nids])
# Calculate radial position
r = np.sqrt(coords[:, 0]**2 + coords[:, 1]**2)
# Initial radial filter
radial_mask = (r >= r_inner) & (r <= r_outer)
# Find nodes in radial range
radial_nids = np.array(nids)[radial_mask]
radial_coords = coords[radial_mask]
if len(radial_coords) == 0:
raise ValueError(f"No nodes found in radial range [{r_inner}, {r_outer}]")
# The optical surface should have a relatively small Z range
z_vals = radial_coords[:, 2]
z_mean = np.mean(z_vals)
# Filter to nodes within z_tolerance of the mean Z
z_mask = np.abs(radial_coords[:, 2] - z_mean) < z_tolerance
surface_nids = radial_nids[z_mask].tolist()
surface_coords = radial_coords[z_mask]
return surface_nids, surface_coords
def extract_displacement_field(
op2_path: Path,
bdf_path: Optional[Path] = None,
r_inner: float = 100.0,
r_outer: float = 650.0,
subcases: Optional[List[int]] = None,
verbose: bool = True
) -> Dict[str, Any]:
"""
Extract full displacement field for GNN training.
This function extracts Z-displacement from OP2 files for nodes on the optical
surface (defined by radial position). It builds node coordinates directly from
the OP2 data matched against BDF geometry, then filters by radial position.
Args:
op2_path: Path to OP2 file
bdf_path: Path to BDF/DAT file (auto-detected if None)
r_inner: Inner radius for surface identification (mm)
r_outer: Outer radius for surface identification (mm)
subcases: List of subcases to extract (default: [1, 2, 3, 4])
verbose: Print progress messages
Returns:
Dictionary containing:
- node_ids: List of node IDs on optical surface
- node_coords: Array [n_nodes, 3] of coordinates
- z_displacement: Dict mapping subcase -> [n_nodes] Z-displacements
- metadata: Extraction metadata
"""
op2_path = Path(op2_path)
# Find BDF file
if bdf_path is None:
for ext in ['.dat', '.bdf']:
candidate = op2_path.with_suffix(ext)
if candidate.exists():
bdf_path = candidate
break
if bdf_path is None:
raise FileNotFoundError(f"No .dat or .bdf found for {op2_path}")
if subcases is None:
subcases = [1, 2, 3, 4]
if verbose:
print(f"[FIELD] Reading geometry from: {bdf_path.name}")
# Read geometry from BDF
bdf = BDF()
bdf.read_bdf(str(bdf_path))
node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
if verbose:
print(f"[FIELD] Total nodes in BDF: {len(node_geo)}")
# Read OP2
if verbose:
print(f"[FIELD] Reading displacements from: {op2_path.name}")
op2 = OP2()
op2.read_op2(str(op2_path))
if not op2.displacements:
raise RuntimeError("No displacement data in OP2")
# Extract data by iterating through OP2 nodes and matching to BDF geometry
# This approach works even when node numbering differs between sources
subcase_data = {}
for key, darr in op2.displacements.items():
isub = int(getattr(darr, 'isubcase', key))
if isub not in subcases:
continue
data = darr.data
dmat = data[0] if data.ndim == 3 else data
ngt = darr.node_gridtype
op2_node_ids = ngt[:, 0] if ngt.ndim == 2 else ngt
# Build arrays of matched data
nids = []
X = []
Y = []
Z = []
disp_z = []
for i, nid in enumerate(op2_node_ids):
nid_int = int(nid)
if nid_int in node_geo:
pos = node_geo[nid_int]
nids.append(nid_int)
X.append(pos[0])
Y.append(pos[1])
Z.append(pos[2])
disp_z.append(float(dmat[i, 2])) # Z component
X = np.array(X, dtype=np.float32)
Y = np.array(Y, dtype=np.float32)
Z = np.array(Z, dtype=np.float32)
disp_z = np.array(disp_z, dtype=np.float32)
nids = np.array(nids, dtype=np.int32)
# Filter to optical surface by radial position
r = np.sqrt(X**2 + Y**2)
surface_mask = (r >= r_inner) & (r <= r_outer)
subcase_data[isub] = {
'node_ids': nids[surface_mask],
'coords': np.column_stack([X[surface_mask], Y[surface_mask], Z[surface_mask]]),
'disp_z': disp_z[surface_mask],
}
if verbose:
print(f"[FIELD] Subcase {isub}: {len(nids)} matched, {np.sum(surface_mask)} on surface")
# Get common nodes across all subcases (should be the same)
all_subcase_keys = list(subcase_data.keys())
if not all_subcase_keys:
raise RuntimeError("No subcases found in OP2")
# Use first subcase to define node list
ref_subcase = all_subcase_keys[0]
surface_nids = subcase_data[ref_subcase]['node_ids'].tolist()
surface_coords = subcase_data[ref_subcase]['coords']
# Build displacement dict for all subcases
z_displacement = {}
for isub in subcases:
if isub in subcase_data:
z_displacement[isub] = subcase_data[isub]['disp_z']
if verbose:
print(f"[FIELD] Final surface: {len(surface_nids)} nodes")
r_surface = np.sqrt(surface_coords[:, 0]**2 + surface_coords[:, 1]**2)
print(f"[FIELD] Radial range: [{r_surface.min():.1f}, {r_surface.max():.1f}] mm")
# Build metadata
metadata = {
'extraction_timestamp': datetime.now().isoformat(),
'op2_file': str(op2_path.name),
'bdf_file': str(bdf_path.name),
'n_nodes': len(surface_nids),
'r_inner': r_inner,
'r_outer': r_outer,
'subcases': list(z_displacement.keys()),
}
return {
'node_ids': surface_nids,
'node_coords': surface_coords,
'z_displacement': z_displacement,
'metadata': metadata,
}
def save_field_to_hdf5(
field_data: Dict[str, Any],
output_path: Path,
compression: str = 'gzip'
) -> None:
"""
Save displacement field data to HDF5 file.
Args:
field_data: Output from extract_displacement_field()
output_path: Path to save HDF5 file
compression: Compression algorithm ('gzip', 'lzf', or None)
"""
if not HAS_H5PY:
raise ImportError("h5py required for HDF5 storage: pip install h5py")
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with h5py.File(output_path, 'w') as f:
# Node data
f.create_dataset('node_ids', data=np.array(field_data['node_ids'], dtype=np.int32),
compression=compression)
f.create_dataset('node_coords', data=field_data['node_coords'].astype(np.float32),
compression=compression)
# Displacement for each subcase
for subcase, z_disp in field_data['z_displacement'].items():
f.create_dataset(f'subcase_{subcase}', data=z_disp.astype(np.float32),
compression=compression)
# Metadata as JSON string
f.attrs['metadata'] = json.dumps(field_data['metadata'])
# Report file size
size_kb = output_path.stat().st_size / 1024
print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")
def load_field_from_hdf5(hdf5_path: Path) -> Dict[str, Any]:
"""
Load displacement field data from HDF5 file.
Args:
hdf5_path: Path to HDF5 file
Returns:
Dictionary with same structure as extract_displacement_field()
"""
if not HAS_H5PY:
raise ImportError("h5py required for HDF5 storage: pip install h5py")
with h5py.File(hdf5_path, 'r') as f:
node_ids = f['node_ids'][:].tolist()
node_coords = f['node_coords'][:]
# Load subcases
z_displacement = {}
for key in f.keys():
if key.startswith('subcase_'):
subcase = int(key.split('_')[1])
z_displacement[subcase] = f[key][:]
metadata = json.loads(f.attrs['metadata'])
return {
'node_ids': node_ids,
'node_coords': node_coords,
'z_displacement': z_displacement,
'metadata': metadata,
}
def save_field_to_npz(
field_data: Dict[str, Any],
output_path: Path
) -> None:
"""
Save displacement field data to compressed NPZ file (fallback if no h5py).
Args:
field_data: Output from extract_displacement_field()
output_path: Path to save NPZ file
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
save_dict = {
'node_ids': np.array(field_data['node_ids'], dtype=np.int32),
'node_coords': field_data['node_coords'].astype(np.float32),
'metadata_json': np.array([json.dumps(field_data['metadata'])]),
}
# Add subcases
for subcase, z_disp in field_data['z_displacement'].items():
save_dict[f'subcase_{subcase}'] = z_disp.astype(np.float32)
np.savez_compressed(output_path, **save_dict)
size_kb = output_path.stat().st_size / 1024
print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")
def load_field_from_npz(npz_path: Path) -> Dict[str, Any]:
"""
Load displacement field data from NPZ file.
Args:
npz_path: Path to NPZ file
Returns:
Dictionary with same structure as extract_displacement_field()
"""
data = np.load(npz_path, allow_pickle=True)
node_ids = data['node_ids'].tolist()
node_coords = data['node_coords']
metadata = json.loads(str(data['metadata_json'][0]))
# Load subcases
z_displacement = {}
for key in data.keys():
if key.startswith('subcase_'):
subcase = int(key.split('_')[1])
z_displacement[subcase] = data[key]
return {
'node_ids': node_ids,
'node_coords': node_coords,
'z_displacement': z_displacement,
'metadata': metadata,
}
# =============================================================================
# Convenience functions
# =============================================================================
def save_field(field_data: Dict[str, Any], output_path: Path) -> None:
"""Save field data using best available format (HDF5 preferred)."""
output_path = Path(output_path)
if HAS_H5PY and output_path.suffix == '.h5':
save_field_to_hdf5(field_data, output_path)
else:
if output_path.suffix != '.npz':
output_path = output_path.with_suffix('.npz')
save_field_to_npz(field_data, output_path)
def load_field(path: Path) -> Dict[str, Any]:
"""Load field data from HDF5 or NPZ file."""
path = Path(path)
if path.suffix == '.h5':
return load_field_from_hdf5(path)
else:
return load_field_from_npz(path)
# =============================================================================
# CLI
# =============================================================================
if __name__ == '__main__':
import sys
import argparse
parser = argparse.ArgumentParser(
description='Extract displacement field from Nastran OP2 for GNN training'
)
parser.add_argument('op2_path', type=Path, help='Path to OP2 file')
parser.add_argument('-o', '--output', type=Path, help='Output path (default: same dir as OP2)')
parser.add_argument('--r-inner', type=float, default=100.0, help='Inner radius (mm)')
parser.add_argument('--r-outer', type=float, default=650.0, help='Outer radius (mm)')
parser.add_argument('--format', choices=['h5', 'npz'], default='h5',
help='Output format (default: h5)')
args = parser.parse_args()
# Extract field
field_data = extract_displacement_field(
args.op2_path,
r_inner=args.r_inner,
r_outer=args.r_outer,
)
# Determine output path
if args.output:
output_path = args.output
else:
output_path = args.op2_path.parent / f'displacement_field.{args.format}'
# Save
save_field(field_data, output_path)
# Print summary
print("\n" + "="*60)
print("EXTRACTION SUMMARY")
print("="*60)
print(f"Nodes: {len(field_data['node_ids'])}")
print(f"Subcases: {list(field_data['z_displacement'].keys())}")
for sc, disp in field_data['z_displacement'].items():
print(f" Subcase {sc}: Z range [{disp.min():.4f}, {disp.max():.4f}] mm")

View File

@@ -0,0 +1,718 @@
"""
GNN-Based Optimizer for Zernike Mirror Optimization
====================================================
This module provides a fast GNN-based optimization workflow:
1. Load trained GNN checkpoint
2. Run thousands of fast GNN predictions
3. Select top candidates
4. Validate with FEA (optional)
Usage:
from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer
optimizer = ZernikeGNNOptimizer.from_checkpoint('zernike_gnn_checkpoint.pt')
results = optimizer.turbo_optimize(n_trials=5000)
# Get best designs
best = results.get_best(n=10)
# Validate with FEA
validated = optimizer.validate_with_fea(best, study_dir)
"""
import json
import numpy as np
import torch
import torch.nn as nn
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, field
from datetime import datetime
import optuna
from optimization_engine.gnn.polar_graph import PolarMirrorGraph
from optimization_engine.gnn.zernike_gnn import create_model, load_model
from optimization_engine.gnn.differentiable_zernike import ZernikeObjectiveLayer
@dataclass
class GNNPrediction:
"""Single GNN prediction result."""
design_vars: Dict[str, float]
objectives: Dict[str, float]
z_displacement: Optional[np.ndarray] = None # [3000, 4] if stored
def to_dict(self) -> Dict:
return {
'design_vars': self.design_vars,
'objectives': self.objectives,
}
@dataclass
class OptimizationResults:
"""Container for optimization results."""
predictions: List[GNNPrediction] = field(default_factory=list)
pareto_front: List[int] = field(default_factory=list) # Indices
def add(self, pred: GNNPrediction):
self.predictions.append(pred)
def get_best(self, n: int = 10, objective: str = 'rel_filtered_rms_40_vs_20') -> List[GNNPrediction]:
"""Get top N designs by a single objective."""
sorted_preds = sorted(self.predictions, key=lambda p: p.objectives.get(objective, float('inf')))
return sorted_preds[:n]
def get_pareto_front(self, objectives: List[str] = None) -> List[GNNPrediction]:
"""Get Pareto-optimal designs."""
if objectives is None:
objectives = ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']
# Extract objective values
obj_values = np.array([
[p.objectives.get(obj, float('inf')) for obj in objectives]
for p in self.predictions
])
# Find Pareto front (all objectives are minimized)
pareto_indices = []
for i in range(len(self.predictions)):
is_dominated = False
for j in range(len(self.predictions)):
if i != j:
# j dominates i if j is <= in all objectives and < in at least one
if np.all(obj_values[j] <= obj_values[i]) and np.any(obj_values[j] < obj_values[i]):
is_dominated = True
break
if not is_dominated:
pareto_indices.append(i)
self.pareto_front = pareto_indices
return [self.predictions[i] for i in pareto_indices]
def to_dataframe(self):
"""Convert to pandas DataFrame."""
import pandas as pd
rows = []
for i, pred in enumerate(self.predictions):
row = {'index': i}
row.update(pred.design_vars)
row.update({f'obj_{k}': v for k, v in pred.objectives.items()})
rows.append(row)
return pd.DataFrame(rows)
def save(self, path: Path):
"""Save results to JSON."""
data = {
'n_predictions': len(self.predictions),
'pareto_front_indices': self.pareto_front,
'predictions': [p.to_dict() for p in self.predictions],
'timestamp': datetime.now().isoformat(),
}
with open(path, 'w') as f:
json.dump(data, f, indent=2)
class ZernikeGNNOptimizer:
"""
GNN-based optimizer for Zernike mirror optimization.
Provides fast objective prediction using trained GNN surrogate.
"""
def __init__(
self,
model: nn.Module,
polar_graph: PolarMirrorGraph,
design_names: List[str],
design_bounds: Dict[str, Tuple[float, float]],
design_mean: torch.Tensor,
design_std: torch.Tensor,
device: str = 'cpu',
disp_scale: float = 1.0
):
self.model = model.to(device)
self.model.eval()
self.polar_graph = polar_graph
self.design_names = design_names
self.design_bounds = design_bounds
self.design_mean = design_mean.to(device)
self.design_std = design_std.to(device)
self.device = torch.device(device)
self.disp_scale = disp_scale # Scaling factor from training
# Prepare fixed graph tensors
self.node_features = torch.tensor(
polar_graph.get_node_features(normalized=True),
dtype=torch.float32
).to(device)
self.edge_index = torch.tensor(
polar_graph.edge_index,
dtype=torch.long
).to(device)
self.edge_attr = torch.tensor(
polar_graph.get_edge_features(normalized=True),
dtype=torch.float32
).to(device)
# Objective computation layer (must be on same device as model)
self.objective_layer = ZernikeObjectiveLayer(polar_graph, n_modes=50).to(device)
@classmethod
def from_checkpoint(
cls,
checkpoint_path: Path,
config_path: Optional[Path] = None,
device: str = 'auto'
) -> 'ZernikeGNNOptimizer':
"""
Load optimizer from trained checkpoint.
Args:
checkpoint_path: Path to zernike_gnn_checkpoint.pt
config_path: Path to optimization_config.json (for design bounds)
device: Device to use ('cpu', 'cuda', 'auto')
"""
if device == 'auto':
device = 'cuda' if torch.cuda.is_available() else 'cpu'
checkpoint_path = Path(checkpoint_path)
checkpoint = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
# Create polar graph
polar_graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
# Create model - handle both old ('model_config') and new ('config') format
model_config = checkpoint.get('model_config') or checkpoint.get('config', {})
model = create_model(**model_config)
model.load_state_dict(checkpoint['model_state_dict'])
# Get design info from checkpoint
design_mean = checkpoint['design_mean']
design_std = checkpoint['design_std']
disp_scale = checkpoint.get('disp_scale', 1.0) # Displacement scaling factor
# Try to get design names and bounds from config
design_names = []
design_bounds = {}
if config_path and Path(config_path).exists():
with open(config_path, 'r') as f:
config = json.load(f)
for var in config.get('design_variables', []):
name = var['name']
design_names.append(name)
design_bounds[name] = (var['min'], var['max'])
else:
# Use generic names based on checkpoint
n_vars = len(design_mean)
design_names = [f'var_{i}' for i in range(n_vars)]
# Default bounds (will be overridden if config provided)
for name in design_names:
design_bounds[name] = (-100, 100)
return cls(
model=model,
polar_graph=polar_graph,
design_names=design_names,
design_bounds=design_bounds,
design_mean=design_mean,
design_std=design_std,
device=device,
disp_scale=disp_scale
)
@torch.no_grad()
def predict(self, design_vars: Dict[str, float], return_field: bool = False) -> GNNPrediction:
"""
Predict objectives for a single design.
Args:
design_vars: Dict mapping variable names to values
return_field: Whether to include displacement field in result
Returns:
GNNPrediction with objectives
"""
# Convert to tensor
design_values = [design_vars.get(name, 0.0) for name in self.design_names]
design_tensor = torch.tensor(design_values, dtype=torch.float32).to(self.device)
# Normalize
design_norm = (design_tensor - self.design_mean) / self.design_std
# Forward pass
z_disp_scaled = self.model(
self.node_features,
self.edge_index,
self.edge_attr,
design_norm
) # [3000, 4] in scaled units (μm if disp_scale=1e6)
# Convert back to mm before computing objectives
# During training: z_disp_mm * disp_scale = z_disp_scaled
# So: z_disp_mm = z_disp_scaled / disp_scale
z_disp_mm = z_disp_scaled / self.disp_scale
# Compute objectives (ZernikeObjectiveLayer expects mm input)
objectives = self.objective_layer(z_disp_mm)
# Objectives are now directly in nm (no additional scaling needed)
obj_dict = {
'rel_filtered_rms_40_vs_20': objectives['rel_filtered_rms_40_vs_20'].item(),
'rel_filtered_rms_60_vs_20': objectives['rel_filtered_rms_60_vs_20'].item(),
'mfg_90_optician_workload': objectives['mfg_90_optician_workload'].item(),
}
field_data = z_disp_mm.cpu().numpy() if return_field else None
return GNNPrediction(
design_vars=design_vars,
objectives=obj_dict,
z_displacement=field_data
)
@torch.no_grad()
def predict_batch(self, designs: List[Dict[str, float]]) -> List[GNNPrediction]:
"""
Predict objectives for multiple designs (batched for efficiency).
Args:
designs: List of design variable dicts
Returns:
List of GNNPrediction
"""
results = []
for design in designs:
results.append(self.predict(design))
return results
def random_design(self) -> Dict[str, float]:
"""Generate a random design within bounds."""
design = {}
for name in self.design_names:
low, high = self.design_bounds.get(name, (-100, 100))
design[name] = np.random.uniform(low, high)
return design
def turbo_optimize(
self,
n_trials: int = 5000,
sampler: str = 'tpe',
seed: int = 42,
verbose: bool = True
) -> OptimizationResults:
"""
Run fast GNN-based optimization.
Args:
n_trials: Number of GNN trials to run
sampler: Optuna sampler ('tpe', 'random', 'cmaes')
seed: Random seed
verbose: Print progress
Returns:
OptimizationResults with all predictions
"""
np.random.seed(seed)
results = OptimizationResults()
if verbose:
print(f"\n{'='*60}")
print("GNN TURBO OPTIMIZATION")
print(f"{'='*60}")
print(f"Trials: {n_trials}")
print(f"Sampler: {sampler}")
print(f"Design variables: {len(self.design_names)}")
print(f"Device: {self.device}")
# Create Optuna study for smart sampling
if sampler == 'tpe':
optuna_sampler = optuna.samplers.TPESampler(seed=seed)
elif sampler == 'random':
optuna_sampler = optuna.samplers.RandomSampler(seed=seed)
elif sampler == 'cmaes':
optuna_sampler = optuna.samplers.CmaEsSampler(seed=seed)
else:
optuna_sampler = optuna.samplers.TPESampler(seed=seed)
study = optuna.create_study(
directions=['minimize', 'minimize', 'minimize'], # 3 objectives
sampler=optuna_sampler
)
start_time = datetime.now()
def objective(trial):
# Sample design
design = {}
for name in self.design_names:
low, high = self.design_bounds.get(name, (-100, 100))
design[name] = trial.suggest_float(name, low, high)
# Predict with GNN
pred = self.predict(design)
results.add(pred)
return (
pred.objectives['rel_filtered_rms_40_vs_20'],
pred.objectives['rel_filtered_rms_60_vs_20'],
pred.objectives['mfg_90_optician_workload']
)
# Run optimization
if verbose:
print(f"\nRunning {n_trials} GNN trials...")
optuna.logging.set_verbosity(optuna.logging.WARNING)
study.optimize(objective, n_trials=n_trials, show_progress_bar=verbose)
elapsed = (datetime.now() - start_time).total_seconds()
if verbose:
print(f"\nCompleted in {elapsed:.1f}s ({n_trials/elapsed:.0f} trials/sec)")
# Compute Pareto front
pareto = results.get_pareto_front()
print(f"Pareto front: {len(pareto)} designs")
# Best by each objective
print("\nBest by objective:")
for obj in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
best = results.get_best(n=1, objective=obj)[0]
print(f" {obj}: {best.objectives[obj]:.2f} nm")
return results
def validate_with_fea(
self,
candidates: List[GNNPrediction],
study_dir: Path,
verbose: bool = True,
start_trial_num: int = 9000
) -> List[Dict]:
"""
Validate GNN predictions with actual FEA.
This runs the full NX + Nastran workflow on each candidate
to get true objective values.
Args:
candidates: GNN predictions to validate
study_dir: Path to study directory (for config and scripts)
verbose: Print progress
start_trial_num: Starting trial number for iteration folders
Returns:
List of dicts with 'gnn' and 'fea' objectives for comparison
"""
import time
import re
from optimization_engine.nx_solver import NXSolver
from optimization_engine.extractors import ZernikeExtractor
study_dir = Path(study_dir)
config_path = study_dir / "1_setup" / "optimization_config.json"
model_dir = study_dir / "1_setup" / "model"
iterations_dir = study_dir / "2_iterations"
# Load config
if not config_path.exists():
raise FileNotFoundError(f"Config not found: {config_path}")
with open(config_path) as f:
config = json.load(f)
# Initialize NX Solver
nx_settings = config.get('nx_settings', {})
nx_install_dir = nx_settings.get('nx_install_path', 'C:\\Program Files\\Siemens\\NX2506')
version_match = re.search(r'NX(\d+)', nx_install_dir)
nastran_version = version_match.group(1) if version_match else "2506"
solver = NXSolver(
master_model_dir=str(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="gnn_validation"
)
iterations_dir.mkdir(exist_ok=True)
results = []
if verbose:
print(f"\n{'='*60}")
print("FEA VALIDATION OF GNN PREDICTIONS")
print(f"{'='*60}")
print(f"Validating {len(candidates)} candidates")
print(f"Study: {study_dir.name}")
for i, candidate in enumerate(candidates):
trial_num = start_trial_num + i
if verbose:
print(f"\n[{i+1}/{len(candidates)}] Trial {trial_num}")
print(f" GNN predicted: 40vs20={candidate.objectives['rel_filtered_rms_40_vs_20']:.2f} nm")
# Build expression updates from design variables
expressions = {}
for var in config.get('design_variables', []):
var_name = var['name']
expr_name = var.get('expression_name', var_name)
if var_name in candidate.design_vars:
expressions[expr_name] = candidate.design_vars[var_name]
# Create iteration folder with model copies
try:
iter_folder = solver.create_iteration_folder(
iterations_base_dir=iterations_dir,
iteration_number=trial_num,
expression_updates=expressions
)
except Exception as e:
if verbose:
print(f" ERROR creating iteration folder: {e}")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': None,
'status': 'error',
'error': str(e)
})
continue
# Run simulation
sim_file = iter_folder / nx_settings.get('sim_file', 'ASSY_M1_assyfem1_sim1.sim')
solution_name = nx_settings.get('solution_name', 'Solution 1')
t_start = time.time()
try:
solve_result = solver.run_simulation(
sim_file=sim_file,
working_dir=iter_folder,
expression_updates=expressions,
solution_name=solution_name,
cleanup=False
)
except Exception as e:
if verbose:
print(f" ERROR in simulation: {e}")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': None,
'status': 'solve_error',
'error': str(e)
})
continue
solve_time = time.time() - t_start
if not solve_result['success']:
if verbose:
print(f" Solve FAILED: {solve_result.get('errors', ['Unknown'])}")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': None,
'status': 'solve_failed',
'errors': solve_result.get('errors', [])
})
continue
if verbose:
print(f" Solved in {solve_time:.1f}s")
# Extract objectives using ZernikeExtractor
op2_path = solve_result['op2_file']
if op2_path is None or not Path(op2_path).exists():
if verbose:
print(f" ERROR: OP2 file not found")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': None,
'status': 'no_op2',
})
continue
try:
zernike_settings = config.get('zernike_settings', {})
extractor = ZernikeExtractor(
op2_path,
bdf_path=None,
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)
)
ref = zernike_settings.get('reference_subcase', '2')
# Extract objectives: 40 vs 20, 60 vs 20, mfg 90
rel_40 = extractor.extract_relative("3", ref)
rel_60 = extractor.extract_relative("4", ref)
rel_90 = extractor.extract_relative("1", ref)
fea_objectives = {
'rel_filtered_rms_40_vs_20': rel_40['relative_filtered_rms_nm'],
'rel_filtered_rms_60_vs_20': rel_60['relative_filtered_rms_nm'],
'mfg_90_optician_workload': rel_90['relative_rms_filter_j1to3'],
}
except Exception as e:
if verbose:
print(f" ERROR in Zernike extraction: {e}")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': None,
'status': 'extraction_error',
'error': str(e)
})
continue
# Compute errors
errors = {}
for obj_name in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
gnn_val = candidate.objectives[obj_name]
fea_val = fea_objectives[obj_name]
errors[f'{obj_name}_abs_error'] = abs(gnn_val - fea_val)
errors[f'{obj_name}_pct_error'] = 100 * abs(gnn_val - fea_val) / max(fea_val, 0.01)
if verbose:
print(f" FEA results:")
print(f" 40vs20: {fea_objectives['rel_filtered_rms_40_vs_20']:.2f} nm "
f"(GNN: {candidate.objectives['rel_filtered_rms_40_vs_20']:.2f}, "
f"err: {errors['rel_filtered_rms_40_vs_20_pct_error']:.1f}%)")
print(f" 60vs20: {fea_objectives['rel_filtered_rms_60_vs_20']:.2f} nm "
f"(GNN: {candidate.objectives['rel_filtered_rms_60_vs_20']:.2f}, "
f"err: {errors['rel_filtered_rms_60_vs_20_pct_error']:.1f}%)")
print(f" mfg90: {fea_objectives['mfg_90_optician_workload']:.2f} nm "
f"(GNN: {candidate.objectives['mfg_90_optician_workload']:.2f}, "
f"err: {errors['mfg_90_optician_workload_pct_error']:.1f}%)")
results.append({
'design': candidate.design_vars,
'gnn_objectives': candidate.objectives,
'fea_objectives': fea_objectives,
'errors': errors,
'solve_time': solve_time,
'trial_num': trial_num,
'status': 'success'
})
# Summary
if verbose:
successful = [r for r in results if r['status'] == 'success']
print(f"\n{'='*60}")
print(f"VALIDATION SUMMARY")
print(f"{'='*60}")
print(f"Successful: {len(successful)}/{len(candidates)}")
if successful:
avg_errors = {}
for obj in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
avg_errors[obj] = np.mean([r['errors'][f'{obj}_pct_error'] for r in successful])
print(f"\nAverage prediction errors:")
print(f" 40 vs 20: {avg_errors['rel_filtered_rms_40_vs_20']:.1f}%")
print(f" 60 vs 20: {avg_errors['rel_filtered_rms_60_vs_20']:.1f}%")
print(f" mfg 90: {avg_errors['mfg_90_optician_workload']:.1f}%")
return results
def save_validation_report(
self,
validation_results: List[Dict],
output_path: Path
):
"""Save validation results to JSON file."""
report = {
'timestamp': datetime.now().isoformat(),
'n_candidates': len(validation_results),
'n_successful': len([r for r in validation_results if r['status'] == 'success']),
'results': validation_results,
}
# Compute summary statistics if we have successful results
successful = [r for r in validation_results if r['status'] == 'success']
if successful:
avg_errors = {}
for obj in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
errors = [r['errors'][f'{obj}_pct_error'] for r in successful]
avg_errors[obj] = {
'mean_pct': float(np.mean(errors)),
'std_pct': float(np.std(errors)),
'max_pct': float(np.max(errors)),
}
report['error_summary'] = avg_errors
with open(output_path, 'w') as f:
json.dump(report, f, indent=2)
print(f"Validation report saved to: {output_path}")
def main():
"""Example usage of GNN optimizer."""
import argparse
parser = argparse.ArgumentParser(description='GNN-based Zernike optimization')
parser.add_argument('checkpoint', type=Path, help='Path to GNN checkpoint')
parser.add_argument('--config', type=Path, help='Path to optimization_config.json')
parser.add_argument('--trials', type=int, default=5000, help='Number of GNN trials')
parser.add_argument('--output', '-o', type=Path, help='Output results JSON')
parser.add_argument('--top-n', type=int, default=20, help='Number of top candidates to show')
args = parser.parse_args()
# Load optimizer
print(f"Loading GNN from {args.checkpoint}...")
optimizer = ZernikeGNNOptimizer.from_checkpoint(
args.checkpoint,
config_path=args.config
)
# Run turbo optimization
results = optimizer.turbo_optimize(n_trials=args.trials)
# Show top candidates
print(f"\n{'='*60}")
print(f"TOP {args.top_n} CANDIDATES (by rel_filtered_rms_40_vs_20)")
print(f"{'='*60}")
top = results.get_best(n=args.top_n, objective='rel_filtered_rms_40_vs_20')
for i, pred in enumerate(top):
print(f"\n#{i+1}:")
print(f" 40 vs 20: {pred.objectives['rel_filtered_rms_40_vs_20']:.2f} nm")
print(f" 60 vs 20: {pred.objectives['rel_filtered_rms_60_vs_20']:.2f} nm")
print(f" mfg_90: {pred.objectives['mfg_90_optician_workload']:.2f} nm")
# Save results
if args.output:
results.save(args.output)
print(f"\nResults saved to {args.output}")
# Show Pareto front
pareto = results.get_pareto_front()
print(f"\n{'='*60}")
print(f"PARETO FRONT: {len(pareto)} designs")
print(f"{'='*60}")
for i, pred in enumerate(pareto[:10]): # Show first 10
print(f" [{i+1}] 40vs20={pred.objectives['rel_filtered_rms_40_vs_20']:.1f}, "
f"60vs20={pred.objectives['rel_filtered_rms_60_vs_20']:.1f}, "
f"mfg={pred.objectives['mfg_90_optician_workload']:.1f}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,617 @@
"""
Polar Mirror Graph for GNN Training
====================================
This module creates a fixed polar grid graph structure for the mirror optical surface.
The key insight is that the mirror has a fixed topology (circular annulus), so we can
use a fixed graph structure regardless of FEA mesh variations.
Why Polar Grid?
1. Matches mirror geometry (annulus)
2. Same approach as extract_zernike_surface.py
3. Enables mesh-independent training
4. Edge structure respects radial/angular physics
Grid Structure:
- n_radial points from r_inner to r_outer
- n_angular points from 0 to 2π (not including 2π to avoid duplicate)
- Total nodes = n_radial × n_angular
- Edges connect radial neighbors and angular neighbors (wrap-around)
Usage:
from optimization_engine.gnn.polar_graph import PolarMirrorGraph
graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
# Interpolate FEA results to fixed grid
z_disp_grid = graph.interpolate_from_mesh(fea_coords, fea_z_disp)
# Get PyTorch Geometric data
data = graph.to_pyg_data(z_disp_grid, design_vars)
"""
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
import json
try:
import torch
HAS_TORCH = True
except ImportError:
HAS_TORCH = False
try:
from scipy.interpolate import RBFInterpolator, LinearNDInterpolator, CloughTocher2DInterpolator
from scipy.spatial import Delaunay
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
class PolarMirrorGraph:
"""
Fixed polar grid graph for mirror optical surface.
This creates a mesh-independent graph structure that can be used for GNN training
regardless of the underlying FEA mesh. FEA results are interpolated to this fixed grid.
Attributes:
n_nodes: Total number of nodes (n_radial × n_angular)
r: Radial coordinates [n_nodes]
theta: Angular coordinates [n_nodes]
x: Cartesian X coordinates [n_nodes]
y: Cartesian Y coordinates [n_nodes]
edge_index: Graph edges [2, n_edges]
edge_attr: Edge features [n_edges, 4] - (dr, dtheta, distance, angle)
"""
def __init__(
self,
r_inner: float = 100.0,
r_outer: float = 650.0,
n_radial: int = 50,
n_angular: int = 60
):
"""
Initialize polar grid graph.
Args:
r_inner: Inner radius (central hole), mm
r_outer: Outer radius, mm
n_radial: Number of radial samples
n_angular: Number of angular samples
"""
self.r_inner = r_inner
self.r_outer = r_outer
self.n_radial = n_radial
self.n_angular = n_angular
self.n_nodes = n_radial * n_angular
# Create polar grid coordinates
r_1d = np.linspace(r_inner, r_outer, n_radial)
theta_1d = np.linspace(0, 2 * np.pi, n_angular, endpoint=False)
# Meshgrid: theta varies fast (angular index), r varies slow (radial index)
# Shape after flatten: [n_angular * n_radial] with angular varying fastest
Theta, R = np.meshgrid(theta_1d, r_1d) # R shape: [n_radial, n_angular]
# Flatten: radial index varies slowest
self.r = R.flatten().astype(np.float32)
self.theta = Theta.flatten().astype(np.float32)
self.x = (self.r * np.cos(self.theta)).astype(np.float32)
self.y = (self.r * np.sin(self.theta)).astype(np.float32)
# Build graph edges
self.edge_index, self.edge_attr = self._build_polar_edges()
# Precompute normalization factors
self._r_mean = (r_inner + r_outer) / 2
self._r_std = (r_outer - r_inner) / 2
def _node_index(self, i_r: int, i_theta: int) -> int:
"""Convert (radial_index, angular_index) to flat node index."""
# Angular wraps around
i_theta = i_theta % self.n_angular
return i_r * self.n_angular + i_theta
def _build_polar_edges(self) -> Tuple[np.ndarray, np.ndarray]:
"""
Create graph edges respecting polar topology.
Edge types:
1. Radial edges: Connect adjacent radial rings
2. Angular edges: Connect adjacent angular positions (with wrap-around)
3. Diagonal edges: Connect diagonal neighbors for better message passing
Returns:
edge_index: [2, n_edges] array of (source, target) pairs
edge_attr: [n_edges, 4] array of (dr, dtheta, distance, angle)
"""
edges = []
edge_features = []
for i_r in range(self.n_radial):
for i_theta in range(self.n_angular):
node = self._node_index(i_r, i_theta)
# Radial neighbor (outward)
if i_r < self.n_radial - 1:
neighbor = self._node_index(i_r + 1, i_theta)
edges.append([node, neighbor])
edges.append([neighbor, node]) # Bidirectional
# Edge features: (dr, dtheta, distance, relative_angle)
dr = self.r[neighbor] - self.r[node]
dtheta = 0.0
dist = abs(dr)
angle = 0.0 # Radial direction
edge_features.append([dr, dtheta, dist, angle])
edge_features.append([-dr, dtheta, dist, np.pi]) # Reverse
# Angular neighbor (counterclockwise, with wrap-around)
neighbor = self._node_index(i_r, i_theta + 1)
edges.append([node, neighbor])
edges.append([neighbor, node]) # Bidirectional
# Edge features for angular edge
dr = 0.0
dtheta = 2 * np.pi / self.n_angular
# Arc length at this radius
dist = self.r[node] * dtheta
angle = np.pi / 2 # Tangential direction
edge_features.append([dr, dtheta, dist, angle])
edge_features.append([dr, -dtheta, dist, -np.pi / 2]) # Reverse
# Diagonal neighbor (outward + counterclockwise) for better connectivity
if i_r < self.n_radial - 1:
neighbor = self._node_index(i_r + 1, i_theta + 1)
edges.append([node, neighbor])
edges.append([neighbor, node])
dr = self.r[neighbor] - self.r[node]
dtheta = 2 * np.pi / self.n_angular
dx = self.x[neighbor] - self.x[node]
dy = self.y[neighbor] - self.y[node]
dist = np.sqrt(dx**2 + dy**2)
angle = np.arctan2(dy, dx)
edge_features.append([dr, dtheta, dist, angle])
edge_features.append([-dr, -dtheta, dist, angle + np.pi])
edge_index = np.array(edges, dtype=np.int64).T # [2, n_edges]
edge_attr = np.array(edge_features, dtype=np.float32) # [n_edges, 4]
return edge_index, edge_attr
def get_node_features(self, normalized: bool = True) -> np.ndarray:
"""
Get node features for GNN input.
Features: (r, theta, x, y) - polar and Cartesian coordinates
Args:
normalized: If True, normalize features to ~[-1, 1] range
Returns:
Node features [n_nodes, 4]
"""
if normalized:
r_norm = (self.r - self._r_mean) / self._r_std
theta_norm = self.theta / np.pi - 1 # [0, 2π] → [-1, 1]
x_norm = self.x / self.r_outer
y_norm = self.y / self.r_outer
return np.column_stack([r_norm, theta_norm, x_norm, y_norm]).astype(np.float32)
else:
return np.column_stack([self.r, self.theta, self.x, self.y]).astype(np.float32)
def get_edge_features(self, normalized: bool = True) -> np.ndarray:
"""
Get edge features for GNN input.
Features: (dr, dtheta, distance, angle)
Args:
normalized: If True, normalize features
Returns:
Edge features [n_edges, 4]
"""
if normalized:
edge_attr = self.edge_attr.copy()
edge_attr[:, 0] /= self._r_std # dr
edge_attr[:, 1] /= np.pi # dtheta
edge_attr[:, 2] /= self.r_outer # distance
edge_attr[:, 3] /= np.pi # angle
return edge_attr
else:
return self.edge_attr
def interpolate_from_mesh(
self,
mesh_coords: np.ndarray,
mesh_values: np.ndarray,
method: str = 'rbf'
) -> np.ndarray:
"""
Interpolate FEA results from mesh nodes to fixed polar grid.
Args:
mesh_coords: FEA node coordinates [n_fea_nodes, 2] or [n_fea_nodes, 3] (X, Y, [Z])
mesh_values: Values to interpolate [n_fea_nodes] or [n_fea_nodes, n_features]
method: Interpolation method ('rbf', 'linear', 'clough_tocher')
Returns:
Interpolated values on polar grid [n_nodes] or [n_nodes, n_features]
"""
if not HAS_SCIPY:
raise ImportError("scipy required for interpolation: pip install scipy")
# Use only X, Y coordinates
xy = mesh_coords[:, :2] if mesh_coords.shape[1] > 2 else mesh_coords
# Handle multi-dimensional values
values_1d = mesh_values.ndim == 1
if values_1d:
mesh_values = mesh_values.reshape(-1, 1)
# Target coordinates
target_xy = np.column_stack([self.x, self.y])
result = np.zeros((self.n_nodes, mesh_values.shape[1]), dtype=np.float32)
for i in range(mesh_values.shape[1]):
vals = mesh_values[:, i]
if method == 'rbf':
# RBF interpolation - smooth, handles scattered data well
interp = RBFInterpolator(
xy, vals,
kernel='thin_plate_spline',
smoothing=0.0
)
result[:, i] = interp(target_xy)
elif method == 'linear':
# Linear interpolation via Delaunay triangulation
interp = LinearNDInterpolator(xy, vals, fill_value=np.nan)
result[:, i] = interp(target_xy)
# Handle NaN (points outside convex hull) with nearest neighbor
nan_mask = np.isnan(result[:, i])
if nan_mask.any():
from scipy.spatial import cKDTree
tree = cKDTree(xy)
_, idx = tree.query(target_xy[nan_mask])
result[nan_mask, i] = vals[idx]
elif method == 'clough_tocher':
# Clough-Tocher (C1 smooth) interpolation
interp = CloughTocher2DInterpolator(xy, vals, fill_value=np.nan)
result[:, i] = interp(target_xy)
# Handle NaN
nan_mask = np.isnan(result[:, i])
if nan_mask.any():
from scipy.spatial import cKDTree
tree = cKDTree(xy)
_, idx = tree.query(target_xy[nan_mask])
result[nan_mask, i] = vals[idx]
else:
raise ValueError(f"Unknown interpolation method: {method}")
return result[:, 0] if values_1d else result
def interpolate_field_data(
self,
field_data: Dict[str, Any],
subcases: List[int] = [1, 2, 3, 4],
method: str = 'linear' # Changed from 'rbf' - much faster
) -> Dict[str, np.ndarray]:
"""
Interpolate field data from extract_displacement_field() to polar grid.
Args:
field_data: Output from extract_displacement_field()
subcases: List of subcases to interpolate
method: Interpolation method
Returns:
Dictionary with:
- z_displacement: [n_nodes, n_subcases] array
- original_n_nodes: Number of FEA nodes
"""
mesh_coords = field_data['node_coords']
z_disp_dict = field_data['z_displacement']
# Stack subcases
z_disp_list = []
for sc in subcases:
if sc in z_disp_dict:
z_disp_list.append(z_disp_dict[sc])
else:
raise KeyError(f"Subcase {sc} not found in field_data")
# [n_fea_nodes, n_subcases]
z_disp_mesh = np.column_stack(z_disp_list)
# Interpolate to polar grid
z_disp_grid = self.interpolate_from_mesh(mesh_coords, z_disp_mesh, method=method)
return {
'z_displacement': z_disp_grid, # [n_nodes, n_subcases]
'original_n_nodes': len(mesh_coords),
}
def to_pyg_data(
self,
z_displacement: np.ndarray,
design_vars: np.ndarray,
objectives: Optional[Dict[str, float]] = None
):
"""
Convert to PyTorch Geometric Data object.
Args:
z_displacement: [n_nodes, n_subcases] displacement field
design_vars: [n_design_vars] design parameters
objectives: Optional dict of objective values (ground truth)
Returns:
torch_geometric.data.Data object
"""
if not HAS_TORCH:
raise ImportError("PyTorch required: pip install torch")
try:
from torch_geometric.data import Data
except ImportError:
raise ImportError("PyTorch Geometric required: pip install torch-geometric")
# Node features: (r, theta, x, y)
node_features = torch.tensor(self.get_node_features(normalized=True), dtype=torch.float32)
# Edge index and features
edge_index = torch.tensor(self.edge_index, dtype=torch.long)
edge_attr = torch.tensor(self.get_edge_features(normalized=True), dtype=torch.float32)
# Target: Z-displacement field
y = torch.tensor(z_displacement, dtype=torch.float32)
# Design variables (global feature)
design = torch.tensor(design_vars, dtype=torch.float32)
data = Data(
x=node_features,
edge_index=edge_index,
edge_attr=edge_attr,
y=y,
design=design,
)
# Add objectives if provided
if objectives:
for key, value in objectives.items():
setattr(data, key, torch.tensor([value], dtype=torch.float32))
return data
def save(self, path: Path) -> None:
"""Save graph structure to JSON file."""
path = Path(path)
data = {
'r_inner': self.r_inner,
'r_outer': self.r_outer,
'n_radial': self.n_radial,
'n_angular': self.n_angular,
'n_nodes': self.n_nodes,
'n_edges': self.edge_index.shape[1],
}
with open(path, 'w') as f:
json.dump(data, f, indent=2)
# Save arrays separately for efficiency
np.savez_compressed(
path.with_suffix('.npz'),
r=self.r,
theta=self.theta,
x=self.x,
y=self.y,
edge_index=self.edge_index,
edge_attr=self.edge_attr,
)
@classmethod
def load(cls, path: Path) -> 'PolarMirrorGraph':
"""Load graph structure from file."""
path = Path(path)
with open(path, 'r') as f:
data = json.load(f)
return cls(
r_inner=data['r_inner'],
r_outer=data['r_outer'],
n_radial=data['n_radial'],
n_angular=data['n_angular'],
)
def __repr__(self) -> str:
return (
f"PolarMirrorGraph("
f"r=[{self.r_inner}, {self.r_outer}]mm, "
f"grid={self.n_radial}×{self.n_angular}, "
f"nodes={self.n_nodes}, "
f"edges={self.edge_index.shape[1]})"
)
# =============================================================================
# Convenience functions
# =============================================================================
def create_mirror_dataset(
study_dir: Path,
polar_graph: Optional[PolarMirrorGraph] = None,
subcases: List[int] = [1, 2, 3, 4],
verbose: bool = True
) -> List[Dict[str, Any]]:
"""
Create GNN dataset from a study's gnn_data folder.
Args:
study_dir: Path to study directory
polar_graph: PolarMirrorGraph instance (created if None)
subcases: Subcases to include
verbose: Print progress
Returns:
List of data dictionaries, each containing:
- z_displacement: [n_nodes, n_subcases]
- design_vars: [n_vars]
- trial_number: int
- original_n_nodes: int
"""
from optimization_engine.gnn.extract_displacement_field import load_field
study_dir = Path(study_dir)
gnn_data_dir = study_dir / "gnn_data"
if not gnn_data_dir.exists():
raise FileNotFoundError(f"No gnn_data folder in {study_dir}")
# Load index
index_path = gnn_data_dir / "dataset_index.json"
with open(index_path, 'r') as f:
index = json.load(f)
if polar_graph is None:
polar_graph = PolarMirrorGraph()
dataset = []
for trial_num, trial_info in index['trials'].items():
if trial_info.get('status') != 'success':
continue
trial_dir = study_dir / trial_info['trial_dir']
# Find field file
field_path = None
for ext in ['.h5', '.npz']:
candidate = trial_dir / f"displacement_field{ext}"
if candidate.exists():
field_path = candidate
break
if field_path is None:
if verbose:
print(f"[WARN] No field file for trial {trial_num}")
continue
try:
# Load field data
field_data = load_field(field_path)
# Interpolate to polar grid
interp_result = polar_graph.interpolate_field_data(field_data, subcases=subcases)
# Get design parameters
params = trial_info.get('params', {})
design_vars = np.array(list(params.values()), dtype=np.float32) if params else np.array([])
dataset.append({
'z_displacement': interp_result['z_displacement'],
'design_vars': design_vars,
'design_names': list(params.keys()) if params else [],
'trial_number': int(trial_num),
'original_n_nodes': interp_result['original_n_nodes'],
})
if verbose:
print(f"[OK] Trial {trial_num}: {interp_result['original_n_nodes']}{polar_graph.n_nodes} nodes")
except Exception as e:
if verbose:
print(f"[ERR] Trial {trial_num}: {e}")
return dataset
# =============================================================================
# CLI
# =============================================================================
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Test PolarMirrorGraph')
parser.add_argument('--test', action='store_true', help='Run basic tests')
parser.add_argument('--study', type=Path, help='Create dataset from study')
args = parser.parse_args()
if args.test:
print("="*60)
print("TESTING PolarMirrorGraph")
print("="*60)
# Create graph
graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
print(f"\n{graph}")
# Check node features
node_feat = graph.get_node_features(normalized=True)
print(f"\nNode features shape: {node_feat.shape}")
print(f" r range: [{node_feat[:, 0].min():.2f}, {node_feat[:, 0].max():.2f}]")
print(f" theta range: [{node_feat[:, 1].min():.2f}, {node_feat[:, 1].max():.2f}]")
# Check edge features
edge_feat = graph.get_edge_features(normalized=True)
print(f"\nEdge features shape: {edge_feat.shape}")
print(f" dr range: [{edge_feat[:, 0].min():.2f}, {edge_feat[:, 0].max():.2f}]")
print(f" distance range: [{edge_feat[:, 2].min():.2f}, {edge_feat[:, 2].max():.2f}]")
# Test interpolation with synthetic data
print("\n--- Testing Interpolation ---")
# Create fake mesh data (random points in annulus)
np.random.seed(42)
n_mesh = 5000
r_mesh = np.random.uniform(100, 650, n_mesh)
theta_mesh = np.random.uniform(0, 2*np.pi, n_mesh)
x_mesh = r_mesh * np.cos(theta_mesh)
y_mesh = r_mesh * np.sin(theta_mesh)
mesh_coords = np.column_stack([x_mesh, y_mesh])
# Synthetic displacement: smooth function
mesh_values = 0.001 * (r_mesh / 650) ** 2 * np.cos(2 * theta_mesh)
# Interpolate
grid_values = graph.interpolate_from_mesh(mesh_coords, mesh_values, method='rbf')
print(f"Interpolated {n_mesh} mesh nodes → {len(grid_values)} grid nodes")
print(f" Input range: [{mesh_values.min():.6f}, {mesh_values.max():.6f}]")
print(f" Output range: [{grid_values.min():.6f}, {grid_values.max():.6f}]")
print("\n✓ All tests passed!")
elif args.study:
# Create dataset from study
print(f"Creating dataset from: {args.study}")
graph = PolarMirrorGraph()
dataset = create_mirror_dataset(args.study, polar_graph=graph)
print(f"\nDataset: {len(dataset)} samples")
if dataset:
print(f" Z-displacement shape: {dataset[0]['z_displacement'].shape}")
print(f" Design vars: {len(dataset[0]['design_vars'])} variables")
else:
# Default: just show info
graph = PolarMirrorGraph()
print(graph)
print(f"\nNode features: {graph.get_node_features().shape}")
print(f"Edge index: {graph.edge_index.shape}")
print(f"Edge features: {graph.edge_attr.shape}")

View File

@@ -0,0 +1,37 @@
"""Quick test script for displacement field extraction."""
import h5py
import numpy as np
from pathlib import Path
# Test file
h5_path = Path("C:/Users/Antoine/Atomizer/studies/m1_mirror_adaptive_V11/gnn_data/trial_0091/displacement_field.h5")
print(f"Testing: {h5_path}")
print(f"Exists: {h5_path.exists()}")
if h5_path.exists():
with h5py.File(h5_path, 'r') as f:
print(f"\nDatasets in file: {list(f.keys())}")
node_coords = f['node_coords'][:]
node_ids = f['node_ids'][:]
print(f"\nTotal nodes: {len(node_ids)}")
# Calculate radial position
r = np.sqrt(node_coords[:, 0]**2 + node_coords[:, 1]**2)
print(f"Radial range: [{r.min():.1f}, {r.max():.1f}] mm")
print(f"Z range: [{node_coords[:, 2].min():.1f}, {node_coords[:, 2].max():.1f}] mm")
# Check nodes in optical surface range (100-650 mm radius)
surface_mask = (r >= 100) & (r <= 650)
print(f"Nodes in r=[100, 650]: {np.sum(surface_mask)}")
# Check subcases
subcases = [k for k in f.keys() if k.startswith("subcase_")]
print(f"Subcases: {subcases}")
if subcases:
for sc in subcases:
disp = f[sc][:]
print(f" {sc}: Z-disp range [{disp.min():.4f}, {disp.max():.4f}] mm")

View File

@@ -0,0 +1,35 @@
"""Test the fixed extraction function directly on OP2."""
import sys
sys.path.insert(0, "C:/Users/Antoine/Atomizer")
import numpy as np
from pathlib import Path
from optimization_engine.gnn.extract_displacement_field import extract_displacement_field
# Test direct extraction from OP2
op2_path = Path("C:/Users/Antoine/Atomizer/studies/m1_mirror_adaptive_V11/2_iterations/iter91/assy_m1_assyfem1_sim1-solution_1.op2")
print(f"Testing extraction from: {op2_path.name}")
print(f"Exists: {op2_path.exists()}")
if op2_path.exists():
field_data = extract_displacement_field(op2_path, r_inner=100.0, r_outer=650.0)
print(f"\n=== EXTRACTION RESULT ===")
print(f"Total surface nodes: {len(field_data['node_ids'])}")
coords = field_data['node_coords']
r = np.sqrt(coords[:, 0]**2 + coords[:, 1]**2)
print(f"Radial range: [{r.min():.1f}, {r.max():.1f}] mm")
print(f"Z range: [{coords[:, 2].min():.1f}, {coords[:, 2].max():.1f}] mm")
print(f"\nSubcases: {list(field_data['z_displacement'].keys())}")
for sc, disp in field_data['z_displacement'].items():
nan_count = np.sum(np.isnan(disp))
if nan_count == 0:
print(f" Subcase {sc}: Z-disp range [{disp.min():.6f}, {disp.max():.6f}] mm")
else:
valid = disp[~np.isnan(disp)]
print(f" Subcase {sc}: {nan_count}/{len(disp)} NaN values, valid range: [{valid.min():.6f}, {valid.max():.6f}]")
else:
print("OP2 file not found!")

View File

@@ -0,0 +1,108 @@
"""Test PolarMirrorGraph with actual V11 data."""
import sys
sys.path.insert(0, "C:/Users/Antoine/Atomizer")
import numpy as np
from pathlib import Path
from optimization_engine.gnn.polar_graph import PolarMirrorGraph, create_mirror_dataset
from optimization_engine.gnn.extract_displacement_field import load_field
# Test 1: Basic graph construction
print("="*60)
print("TEST 1: Graph Construction")
print("="*60)
graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
print(f"\n{graph}")
node_feat = graph.get_node_features(normalized=True)
edge_feat = graph.get_edge_features(normalized=True)
print(f"\nNode features: {node_feat.shape}")
print(f" r normalized: [{node_feat[:, 0].min():.3f}, {node_feat[:, 0].max():.3f}]")
print(f" theta normalized: [{node_feat[:, 1].min():.3f}, {node_feat[:, 1].max():.3f}]")
print(f" x normalized: [{node_feat[:, 2].min():.3f}, {node_feat[:, 2].max():.3f}]")
print(f" y normalized: [{node_feat[:, 3].min():.3f}, {node_feat[:, 3].max():.3f}]")
print(f"\nEdge features: {edge_feat.shape}")
print(f" Edges per node: {edge_feat.shape[0] / graph.n_nodes:.1f}")
# Test 2: Load actual V11 field data and interpolate
print("\n" + "="*60)
print("TEST 2: Interpolation from V11 Data")
print("="*60)
field_path = Path("C:/Users/Antoine/Atomizer/studies/m1_mirror_adaptive_V11/gnn_data/trial_0091/displacement_field.h5")
if field_path.exists():
field_data = load_field(field_path)
print(f"\nLoaded field data:")
print(f" FEA nodes: {len(field_data['node_ids'])}")
print(f" Subcases: {list(field_data['z_displacement'].keys())}")
# Interpolate to polar grid
result = graph.interpolate_field_data(field_data, subcases=[1, 2, 3, 4])
z_grid = result['z_displacement']
print(f"\nInterpolation result:")
print(f" Shape: {z_grid.shape} (expected: {graph.n_nodes} × 4)")
print(f" NaN count: {np.sum(np.isnan(z_grid))}")
for i, sc in enumerate([1, 2, 3, 4]):
disp = z_grid[:, i]
print(f" Subcase {sc}: [{disp.min():.6f}, {disp.max():.6f}] mm")
# Test relative deformation computation
print("\n--- Relative Deformations (like Zernike extraction) ---")
disp_90 = z_grid[:, 0] # Subcase 1 = 90°
disp_20 = z_grid[:, 1] # Subcase 2 = 20° (reference)
disp_40 = z_grid[:, 2] # Subcase 3 = 40°
disp_60 = z_grid[:, 3] # Subcase 4 = 60°
rel_40_vs_20 = disp_40 - disp_20
rel_60_vs_20 = disp_60 - disp_20
rel_90_vs_20 = disp_90 - disp_20
print(f" 40° - 20°: [{rel_40_vs_20.min():.6f}, {rel_40_vs_20.max():.6f}] mm, RMS={np.std(rel_40_vs_20)*1e6:.2f} nm")
print(f" 60° - 20°: [{rel_60_vs_20.min():.6f}, {rel_60_vs_20.max():.6f}] mm, RMS={np.std(rel_60_vs_20)*1e6:.2f} nm")
print(f" 90° - 20°: [{rel_90_vs_20.min():.6f}, {rel_90_vs_20.max():.6f}] mm, RMS={np.std(rel_90_vs_20)*1e6:.2f} nm")
else:
print(f"Field file not found: {field_path}")
# Test 3: Create full dataset from V11
print("\n" + "="*60)
print("TEST 3: Create Dataset from V11")
print("="*60)
study_dir = Path("C:/Users/Antoine/Atomizer/studies/m1_mirror_adaptive_V11")
if (study_dir / "gnn_data").exists():
dataset = create_mirror_dataset(study_dir, polar_graph=graph, verbose=True)
print(f"\n--- Dataset Summary ---")
print(f"Total samples: {len(dataset)}")
if dataset:
# Check consistency
shapes = [d['z_displacement'].shape for d in dataset]
unique_shapes = set(shapes)
print(f"Unique shapes: {unique_shapes}")
# Design variable info
n_vars = len(dataset[0]['design_vars'])
print(f"Design variables: {n_vars}")
if dataset[0]['design_names']:
print(f" Names: {dataset[0]['design_names'][:3]}...")
# Stack for statistics
all_z = np.stack([d['z_displacement'] for d in dataset])
print(f"\nAll data shape: {all_z.shape}")
print(f" Per-subcase ranges:")
for i in range(4):
print(f" Subcase {i+1}: [{all_z[:,:,i].min():.6f}, {all_z[:,:,i].max():.6f}] mm")
else:
print(f"No gnn_data folder found in {study_dir}")
print("\n" + "="*60)
print("✓ All tests completed!")
print("="*60)

View File

@@ -0,0 +1,600 @@
"""
Training Pipeline for ZernikeGNN
=================================
This module provides the complete training pipeline for the Zernike GNN surrogate.
Training Flow:
1. Load displacement field data from gnn_data/ folders
2. Interpolate to fixed polar grid
3. Normalize inputs (design vars) and outputs (displacements)
4. Train with multi-task loss (field + objectives)
5. Validate on held-out data
6. Save best model checkpoint
Usage:
# Command line
python -m optimization_engine.gnn.train_zernike_gnn V11 V12 --epochs 200
# Python API
from optimization_engine.gnn.train_zernike_gnn import ZernikeGNNTrainer
trainer = ZernikeGNNTrainer(['V11', 'V12'])
trainer.train(epochs=200)
trainer.save_checkpoint('model.pt')
"""
import json
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from optimization_engine.gnn.polar_graph import PolarMirrorGraph, create_mirror_dataset
from optimization_engine.gnn.zernike_gnn import ZernikeGNN, ZernikeGNNLite, create_model
from optimization_engine.gnn.differentiable_zernike import ZernikeObjectiveLayer, ZernikeRMSLoss
class MirrorDataset(Dataset):
"""PyTorch Dataset for mirror displacement fields."""
def __init__(
self,
data_list: List[Dict[str, Any]],
design_mean: Optional[torch.Tensor] = None,
design_std: Optional[torch.Tensor] = None,
disp_scale: float = 1e6 # mm → μm for numerical stability
):
"""
Args:
data_list: Output from create_mirror_dataset()
design_mean: Mean for design normalization (computed if None)
design_std: Std for design normalization (computed if None)
disp_scale: Scale factor for displacements
"""
self.data_list = data_list
self.disp_scale = disp_scale
# Stack all design variables for normalization
all_designs = np.stack([d['design_vars'] for d in data_list])
if design_mean is None:
self.design_mean = torch.tensor(np.mean(all_designs, axis=0), dtype=torch.float32)
else:
self.design_mean = design_mean
if design_std is None:
self.design_std = torch.tensor(np.std(all_designs, axis=0) + 1e-6, dtype=torch.float32)
else:
self.design_std = design_std
def __len__(self) -> int:
return len(self.data_list)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
item = self.data_list[idx]
# Normalize design variables
design = torch.tensor(item['design_vars'], dtype=torch.float32)
design_norm = (design - self.design_mean) / self.design_std
# Scale displacements for numerical stability
z_disp = torch.tensor(item['z_displacement'], dtype=torch.float32)
z_disp_scaled = z_disp * self.disp_scale
return {
'design': design_norm,
'design_raw': design,
'z_displacement': z_disp_scaled,
'trial_number': item['trial_number'],
}
class ZernikeGNNTrainer:
"""
Complete training pipeline for ZernikeGNN.
Handles:
- Data loading and preprocessing
- Model initialization
- Training loop with validation
- Checkpointing
- Metrics tracking
"""
def __init__(
self,
study_versions: List[str],
base_dir: Optional[Path] = None,
model_type: str = 'full',
hidden_dim: int = 128,
n_layers: int = 6,
device: str = 'auto'
):
"""
Args:
study_versions: List of study versions (e.g., ['V11', 'V12'])
base_dir: Base Atomizer directory
model_type: 'full' or 'lite'
hidden_dim: Model hidden dimension
n_layers: Number of message passing layers
device: 'cpu', 'cuda', or 'auto'
"""
if base_dir is None:
base_dir = Path(__file__).parent.parent.parent
self.base_dir = Path(base_dir)
self.study_versions = study_versions
# Determine device
if device == 'auto':
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
else:
self.device = torch.device(device)
print(f"[TRAINER] Device: {self.device}", flush=True)
# Create polar graph (fixed structure)
self.polar_graph = PolarMirrorGraph(r_inner=100, r_outer=650, n_radial=50, n_angular=60)
print(f"[TRAINER] Polar graph: {self.polar_graph.n_nodes} nodes, {self.polar_graph.edge_index.shape[1]} edges", flush=True)
# Prepare graph tensors
self.node_features = torch.tensor(
self.polar_graph.get_node_features(normalized=True),
dtype=torch.float32
).to(self.device)
self.edge_index = torch.tensor(
self.polar_graph.edge_index,
dtype=torch.long
).to(self.device)
self.edge_attr = torch.tensor(
self.polar_graph.get_edge_features(normalized=True),
dtype=torch.float32
).to(self.device)
# Load data
self._load_data()
# Create model
self.model_config = {
'model_type': model_type,
'n_design_vars': len(self.train_dataset.data_list[0]['design_vars']),
'n_subcases': 4,
'hidden_dim': hidden_dim,
'n_layers': n_layers,
}
self.model = create_model(**self.model_config).to(self.device)
print(f"[TRAINER] Model: {self.model.__class__.__name__} with {sum(p.numel() for p in self.model.parameters()):,} parameters", flush=True)
# Objective layer for evaluation
self.objective_layer = ZernikeObjectiveLayer(self.polar_graph, n_modes=50)
# Training state
self.best_val_loss = float('inf')
self.history = {'train_loss': [], 'val_loss': [], 'val_r2': []}
def _load_data(self):
"""Load and prepare training data from studies."""
all_data = []
for version in self.study_versions:
study_dir = self.base_dir / "studies" / f"m1_mirror_adaptive_{version}"
if not study_dir.exists():
print(f"[WARN] Study not found: {study_dir}", flush=True)
continue
print(f"[TRAINER] Loading data from {study_dir.name}...", flush=True)
dataset = create_mirror_dataset(study_dir, polar_graph=self.polar_graph, verbose=True)
print(f"[TRAINER] Loaded {len(dataset)} samples", flush=True)
all_data.extend(dataset)
if not all_data:
raise ValueError("No data loaded!")
print(f"[TRAINER] Total samples: {len(all_data)}", flush=True)
# Train/val split (80/20)
np.random.seed(42)
indices = np.random.permutation(len(all_data))
n_train = int(0.8 * len(all_data))
train_data = [all_data[i] for i in indices[:n_train]]
val_data = [all_data[i] for i in indices[n_train:]]
print(f"[TRAINER] Train: {len(train_data)}, Val: {len(val_data)}", flush=True)
# Create datasets
self.train_dataset = MirrorDataset(train_data)
self.val_dataset = MirrorDataset(
val_data,
design_mean=self.train_dataset.design_mean,
design_std=self.train_dataset.design_std
)
# Store normalization params for inference
self.design_mean = self.train_dataset.design_mean
self.design_std = self.train_dataset.design_std
self.disp_scale = self.train_dataset.disp_scale
def train(
self,
epochs: int = 200,
lr: float = 1e-3,
weight_decay: float = 1e-5,
batch_size: int = 4,
field_weight: float = 1.0,
patience: int = 50,
verbose: bool = True
):
"""
Train the GNN model.
Args:
epochs: Number of training epochs
lr: Learning rate
weight_decay: Weight decay for regularization
batch_size: Training batch size
field_weight: Weight for field loss
patience: Early stopping patience
verbose: Print training progress
"""
# Create data loaders
train_loader = DataLoader(
self.train_dataset, batch_size=batch_size, shuffle=True
)
val_loader = DataLoader(
self.val_dataset, batch_size=batch_size, shuffle=False
)
# Optimizer
optimizer = torch.optim.AdamW(
self.model.parameters(), lr=lr, weight_decay=weight_decay
)
# Learning rate scheduler
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
# Training loop
no_improve = 0
for epoch in range(epochs):
# Training
self.model.train()
train_loss = 0.0
for batch in train_loader:
optimizer.zero_grad()
# Move to device
design = batch['design'].to(self.device)
z_disp_true = batch['z_displacement'].to(self.device)
# Forward pass for each sample in batch
batch_loss = 0.0
for i in range(design.size(0)):
z_disp_pred = self.model(
self.node_features,
self.edge_index,
self.edge_attr,
design[i]
)
# MSE loss on displacement field
loss = F.mse_loss(z_disp_pred, z_disp_true[i])
batch_loss = batch_loss + loss
batch_loss = batch_loss / design.size(0)
batch_loss.backward()
optimizer.step()
train_loss += batch_loss.item()
train_loss /= len(train_loader)
scheduler.step()
# Validation
val_loss, val_metrics = self._validate(val_loader)
# Track history
self.history['train_loss'].append(train_loss)
self.history['val_loss'].append(val_loss)
self.history['val_r2'].append(val_metrics.get('r2_mean', 0))
# Early stopping
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self.best_model_state = {k: v.cpu().clone() for k, v in self.model.state_dict().items()}
no_improve = 0
else:
no_improve += 1
if verbose and epoch % 10 == 0:
print(f"[Epoch {epoch:3d}] Train: {train_loss:.6f}, Val: {val_loss:.6f}, "
f"R²: {val_metrics.get('r2_mean', 0):.4f}, LR: {scheduler.get_last_lr()[0]:.2e}", flush=True)
if no_improve >= patience:
print(f"[TRAINER] Early stopping at epoch {epoch}", flush=True)
break
# Restore best model
self.model.load_state_dict(self.best_model_state)
print(f"[TRAINER] Training complete. Best val loss: {self.best_val_loss:.6f}", flush=True)
def _validate(self, val_loader: DataLoader) -> Tuple[float, Dict[str, float]]:
"""Run validation and compute metrics."""
self.model.eval()
val_loss = 0.0
all_pred = []
all_true = []
with torch.no_grad():
for batch in val_loader:
design = batch['design'].to(self.device)
z_disp_true = batch['z_displacement'].to(self.device)
for i in range(design.size(0)):
z_disp_pred = self.model(
self.node_features,
self.edge_index,
self.edge_attr,
design[i]
)
loss = F.mse_loss(z_disp_pred, z_disp_true[i])
val_loss += loss.item()
all_pred.append(z_disp_pred.cpu())
all_true.append(z_disp_true[i].cpu())
val_loss /= len(self.val_dataset)
# Compute R² for each subcase
all_pred = torch.stack(all_pred) # [n_val, n_nodes, 4]
all_true = torch.stack(all_true)
r2_per_subcase = []
for sc in range(4):
pred_flat = all_pred[:, :, sc].flatten()
true_flat = all_true[:, :, sc].flatten()
ss_res = ((true_flat - pred_flat) ** 2).sum()
ss_tot = ((true_flat - true_flat.mean()) ** 2).sum()
r2 = 1 - ss_res / (ss_tot + 1e-8)
r2_per_subcase.append(r2.item())
metrics = {
'r2_mean': np.mean(r2_per_subcase),
'r2_per_subcase': r2_per_subcase,
}
return val_loss, metrics
def evaluate_objectives(self) -> Dict[str, Any]:
"""
Evaluate objective prediction accuracy on validation set.
Returns:
Dictionary with per-objective metrics
"""
self.model.eval()
obj_pred_all = {k: [] for k in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']}
obj_true_all = {k: [] for k in obj_pred_all}
# Move objective layer to CPU for now (small dataset)
with torch.no_grad():
for i in range(len(self.val_dataset)):
item = self.val_dataset[i]
design = item['design'].to(self.device)
z_disp_true = item['z_displacement'] # Already scaled
# Predict
z_disp_pred = self.model(
self.node_features,
self.edge_index,
self.edge_attr,
design
).cpu()
# Unscale for objective computation
z_disp_pred_mm = z_disp_pred / self.disp_scale
z_disp_true_mm = z_disp_true / self.disp_scale
# Compute objectives
obj_pred = self.objective_layer(z_disp_pred_mm)
obj_true = self.objective_layer(z_disp_true_mm)
for k in obj_pred_all:
obj_pred_all[k].append(obj_pred[k].item())
obj_true_all[k].append(obj_true[k].item())
# Compute metrics per objective
results = {}
for k in obj_pred_all:
pred = np.array(obj_pred_all[k])
true = np.array(obj_true_all[k])
mae = np.mean(np.abs(pred - true))
mape = np.mean(np.abs(pred - true) / (np.abs(true) + 1e-6)) * 100
ss_res = np.sum((true - pred) ** 2)
ss_tot = np.sum((true - np.mean(true)) ** 2)
r2 = 1 - ss_res / (ss_tot + 1e-8)
results[k] = {
'mae': mae,
'mape': mape,
'r2': r2,
'pred_range': [pred.min(), pred.max()],
'true_range': [true.min(), true.max()],
}
return results
def save_checkpoint(self, path: Path) -> None:
"""Save model checkpoint."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
checkpoint = {
'model_state_dict': self.model.state_dict(),
'config': self.model_config,
'design_mean': self.design_mean,
'design_std': self.design_std,
'disp_scale': self.disp_scale,
'history': self.history,
'best_val_loss': self.best_val_loss,
'study_versions': self.study_versions,
'timestamp': datetime.now().isoformat(),
}
torch.save(checkpoint, path)
print(f"[TRAINER] Saved checkpoint to {path}", flush=True)
@classmethod
def load_checkpoint(cls, path: Path, device: str = 'auto') -> 'ZernikeGNNTrainer':
"""Load trainer from checkpoint."""
checkpoint = torch.load(path, map_location='cpu')
# Create trainer with same config
trainer = cls(
study_versions=checkpoint['study_versions'],
model_type=checkpoint['config']['model_type'],
hidden_dim=checkpoint['config']['hidden_dim'],
n_layers=checkpoint['config']['n_layers'],
device=device,
)
# Load model weights
trainer.model.load_state_dict(checkpoint['model_state_dict'])
# Restore normalization
trainer.design_mean = checkpoint['design_mean']
trainer.design_std = checkpoint['design_std']
trainer.disp_scale = checkpoint['disp_scale']
# Restore history
trainer.history = checkpoint['history']
trainer.best_val_loss = checkpoint['best_val_loss']
return trainer
def predict(self, design_vars: Dict[str, float]) -> Dict[str, Any]:
"""
Make prediction for new design.
Args:
design_vars: Dictionary of design parameter values
Returns:
Dictionary with displacement field and objectives
"""
self.model.eval()
# Convert to tensor
design_names = self.train_dataset.data_list[0]['design_names']
design = torch.tensor(
[design_vars[name] for name in design_names],
dtype=torch.float32
)
# Normalize
design_norm = (design - self.design_mean) / self.design_std
with torch.no_grad():
z_disp_scaled = self.model(
self.node_features,
self.edge_index,
self.edge_attr,
design_norm.to(self.device)
).cpu()
# Unscale
z_disp_mm = z_disp_scaled / self.disp_scale
# Compute objectives
objectives = self.objective_layer(z_disp_mm)
return {
'z_displacement': z_disp_mm.numpy(),
'objectives': {k: v.item() for k, v in objectives.items()},
}
# =============================================================================
# CLI
# =============================================================================
def main():
import argparse
parser = argparse.ArgumentParser(description='Train ZernikeGNN surrogate')
parser.add_argument('studies', nargs='+', help='Study versions (e.g., V11 V12)')
parser.add_argument('--epochs', type=int, default=200, help='Training epochs')
parser.add_argument('--lr', type=float, default=1e-3, help='Learning rate')
parser.add_argument('--batch-size', type=int, default=4, help='Batch size')
parser.add_argument('--hidden-dim', type=int, default=128, help='Hidden dimension')
parser.add_argument('--n-layers', type=int, default=6, help='Message passing layers')
parser.add_argument('--model-type', choices=['full', 'lite'], default='full')
parser.add_argument('--output', '-o', type=Path, help='Output checkpoint path')
parser.add_argument('--device', default='auto', help='Device (cpu, cuda, auto)')
args = parser.parse_args()
# Create trainer
print("="*60, flush=True)
print("ZERNIKE GNN TRAINING", flush=True)
print("="*60, flush=True)
trainer = ZernikeGNNTrainer(
study_versions=args.studies,
model_type=args.model_type,
hidden_dim=args.hidden_dim,
n_layers=args.n_layers,
device=args.device,
)
# Train
trainer.train(
epochs=args.epochs,
lr=args.lr,
batch_size=args.batch_size,
)
# Evaluate objectives
print("\n--- Objective Prediction Evaluation ---", flush=True)
obj_results = trainer.evaluate_objectives()
for k, v in obj_results.items():
print(f"\n{k}:", flush=True)
print(f" MAE: {v['mae']:.2f} nm", flush=True)
print(f" MAPE: {v['mape']:.1f}%", flush=True)
print(f" R²: {v['r2']:.4f}", flush=True)
# Save checkpoint
if args.output:
output_path = args.output
else:
output_path = Path("zernike_gnn_checkpoint.pt")
trainer.save_checkpoint(output_path)
print("\n" + "="*60, flush=True)
print("✓ Training complete!", flush=True)
print("="*60, flush=True)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,582 @@
"""
Zernike GNN Model for Mirror Surface Deformation Prediction
============================================================
This module implements a Graph Neural Network specifically designed for predicting
mirror surface displacement fields from design parameters. The key innovation is
using design-conditioned message passing on a polar grid graph.
Architecture:
Design Variables [11]
Design Encoder [11 → 128]
└──────────────────┐
Node Features │
[r, θ, x, y] │
│ │
▼ │
Node Encoder │
[4 → 128] │
│ │
└─────────┬────────┘
┌─────────────────────────────┐
│ Design-Conditioned │
│ Message Passing (× 6) │
│ │
│ • Polar-aware edges │
│ • Design modulates messages │
│ • Residual connections │
└─────────────┬───────────────┘
Per-Node Decoder [128 → 4]
Z-Displacement Field [3000, 4]
(one value per node per subcase)
Usage:
from optimization_engine.gnn.zernike_gnn import ZernikeGNN
from optimization_engine.gnn.polar_graph import PolarMirrorGraph
graph = PolarMirrorGraph()
model = ZernikeGNN(n_design_vars=11, n_subcases=4)
# Forward pass
z_disp = model(
node_features=graph.get_node_features(),
edge_index=graph.edge_index,
edge_attr=graph.get_edge_features(),
design_vars=design_tensor
)
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Optional
try:
from torch_geometric.nn import MessagePassing
HAS_PYG = True
except ImportError:
HAS_PYG = False
MessagePassing = nn.Module # Fallback for type hints
class DesignConditionedConv(MessagePassing if HAS_PYG else nn.Module):
"""
Message passing layer conditioned on global design parameters.
This layer propagates information through the polar graph while
conditioning on design parameters. The design embedding modulates
how messages flow between nodes.
Key insight: Design parameters affect the stiffness distribution
in the mirror support structure. This layer learns how those changes
propagate spatially through the optical surface.
"""
def __init__(
self,
in_channels: int,
out_channels: int,
design_channels: int,
edge_channels: int = 4,
aggr: str = 'mean'
):
"""
Args:
in_channels: Input node feature dimension
out_channels: Output node feature dimension
design_channels: Design embedding dimension
edge_channels: Edge feature dimension
aggr: Aggregation method ('mean', 'sum', 'max')
"""
if HAS_PYG:
super().__init__(aggr=aggr)
else:
super().__init__()
self.aggr = aggr
self.in_channels = in_channels
self.out_channels = out_channels
# Message network: source node + target node + design + edge
msg_input_dim = 2 * in_channels + design_channels + edge_channels
self.message_net = nn.Sequential(
nn.Linear(msg_input_dim, out_channels * 2),
nn.LayerNorm(out_channels * 2),
nn.SiLU(),
nn.Dropout(0.1),
nn.Linear(out_channels * 2, out_channels),
)
# Update network: combines aggregated messages with original features
self.update_net = nn.Sequential(
nn.Linear(in_channels + out_channels, out_channels),
nn.LayerNorm(out_channels),
nn.SiLU(),
)
# Design gate: allows design to modulate message importance
self.design_gate = nn.Sequential(
nn.Linear(design_channels, out_channels),
nn.Sigmoid(),
)
def forward(
self,
x: torch.Tensor,
edge_index: torch.Tensor,
edge_attr: torch.Tensor,
design_embed: torch.Tensor
) -> torch.Tensor:
"""
Forward pass with design conditioning.
Args:
x: Node features [n_nodes, in_channels]
edge_index: Graph connectivity [2, n_edges]
edge_attr: Edge features [n_edges, edge_channels]
design_embed: Design embedding [design_channels]
Returns:
Updated node features [n_nodes, out_channels]
"""
if HAS_PYG:
# Use PyG's message passing
out = self.propagate(
edge_index, x=x, edge_attr=edge_attr, design=design_embed
)
else:
# Fallback implementation without PyG
out = self._manual_propagate(x, edge_index, edge_attr, design_embed)
# Apply design-based gating
gate = self.design_gate(design_embed)
out = out * gate
return out
def message(
self,
x_i: torch.Tensor,
x_j: torch.Tensor,
edge_attr: torch.Tensor,
design: torch.Tensor
) -> torch.Tensor:
"""
Compute messages from source (j) to target (i) nodes.
Args:
x_i: Target node features [n_edges, in_channels]
x_j: Source node features [n_edges, in_channels]
edge_attr: Edge features [n_edges, edge_channels]
design: Design embedding, broadcast to edges
Returns:
Messages [n_edges, out_channels]
"""
# Broadcast design to all edges
design_broadcast = design.expand(x_i.size(0), -1)
# Concatenate all inputs
msg_input = torch.cat([x_i, x_j, design_broadcast, edge_attr], dim=-1)
return self.message_net(msg_input)
def update(self, aggr_out: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
"""
Update node features with aggregated messages.
Args:
aggr_out: Aggregated messages [n_nodes, out_channels]
x: Original node features [n_nodes, in_channels]
Returns:
Updated node features [n_nodes, out_channels]
"""
return self.update_net(torch.cat([x, aggr_out], dim=-1))
def _manual_propagate(
self,
x: torch.Tensor,
edge_index: torch.Tensor,
edge_attr: torch.Tensor,
design: torch.Tensor
) -> torch.Tensor:
"""Fallback message passing without PyG."""
row, col = edge_index # row = target, col = source
# Gather features
x_i = x[row] # Target features
x_j = x[col] # Source features
# Compute messages
design_broadcast = design.expand(x_i.size(0), -1)
msg_input = torch.cat([x_i, x_j, design_broadcast, edge_attr], dim=-1)
messages = self.message_net(msg_input)
# Aggregate (mean)
n_nodes = x.size(0)
aggr_out = torch.zeros(n_nodes, messages.size(-1), device=x.device)
count = torch.zeros(n_nodes, 1, device=x.device)
aggr_out.scatter_add_(0, row.unsqueeze(-1).expand_as(messages), messages)
count.scatter_add_(0, row.unsqueeze(-1), torch.ones_like(row, dtype=torch.float).unsqueeze(-1))
count = count.clamp(min=1)
aggr_out = aggr_out / count
# Update
return self.update_net(torch.cat([x, aggr_out], dim=-1))
class ZernikeGNN(nn.Module):
"""
Graph Neural Network for mirror surface displacement prediction.
This model learns to predict Z-displacement fields for all 4 gravity
subcases from 11 design parameters. It uses a fixed polar grid graph
structure and design-conditioned message passing.
The key advantages over MLP:
1. Spatial awareness through message passing
2. Design conditioning modulates spatial information flow
3. Predicts full field (enabling correct relative computation)
4. Respects physics: smooth fields, radial/angular structure
"""
def __init__(
self,
n_design_vars: int = 11,
n_subcases: int = 4,
hidden_dim: int = 128,
n_layers: int = 6,
node_feat_dim: int = 4,
edge_feat_dim: int = 4,
dropout: float = 0.1
):
"""
Args:
n_design_vars: Number of design parameters (11 for mirror)
n_subcases: Number of gravity subcases (4: 90°, 20°, 40°, 60°)
hidden_dim: Hidden layer dimension
n_layers: Number of message passing layers
node_feat_dim: Node feature dimension (r, theta, x, y)
edge_feat_dim: Edge feature dimension (dr, dtheta, dist, angle)
dropout: Dropout rate
"""
super().__init__()
self.n_design_vars = n_design_vars
self.n_subcases = n_subcases
self.hidden_dim = hidden_dim
self.n_layers = n_layers
# === Design Encoder ===
# Maps design parameters to hidden space
self.design_encoder = nn.Sequential(
nn.Linear(n_design_vars, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.SiLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
)
# === Node Encoder ===
# Maps polar coordinates to hidden space
self.node_encoder = nn.Sequential(
nn.Linear(node_feat_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.SiLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
)
# === Edge Encoder ===
# Maps edge features (dr, dtheta, distance, angle) to hidden space
edge_hidden = hidden_dim // 2
self.edge_encoder = nn.Sequential(
nn.Linear(edge_feat_dim, edge_hidden),
nn.SiLU(),
nn.Linear(edge_hidden, edge_hidden),
)
# === Message Passing Layers ===
self.conv_layers = nn.ModuleList([
DesignConditionedConv(
in_channels=hidden_dim,
out_channels=hidden_dim,
design_channels=hidden_dim,
edge_channels=edge_hidden,
)
for _ in range(n_layers)
])
# Layer norms for residual connections
self.layer_norms = nn.ModuleList([
nn.LayerNorm(hidden_dim) for _ in range(n_layers)
])
# === Displacement Decoder ===
# Predicts Z-displacement for each subcase
self.displacement_decoder = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.SiLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim // 2),
nn.SiLU(),
nn.Linear(hidden_dim // 2, n_subcases),
)
# Initialize weights
self._init_weights()
def _init_weights(self):
"""Initialize weights with Xavier/Glorot initialization."""
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.zeros_(module.bias)
def forward(
self,
node_features: torch.Tensor,
edge_index: torch.Tensor,
edge_attr: torch.Tensor,
design_vars: torch.Tensor
) -> torch.Tensor:
"""
Forward pass: design parameters → displacement field.
Args:
node_features: [n_nodes, 4] - (r, theta, x, y) normalized
edge_index: [2, n_edges] - graph connectivity
edge_attr: [n_edges, 4] - edge features normalized
design_vars: [n_design_vars] or [batch, n_design_vars]
Returns:
z_displacement: [n_nodes, n_subcases] - Z-disp per subcase
or [batch, n_nodes, n_subcases] if batched
"""
# Handle batched vs single design
is_batched = design_vars.dim() == 2
if not is_batched:
design_vars = design_vars.unsqueeze(0) # [1, n_design_vars]
batch_size = design_vars.size(0)
n_nodes = node_features.size(0)
# Encode inputs
design_h = self.design_encoder(design_vars) # [batch, hidden]
node_h = self.node_encoder(node_features) # [n_nodes, hidden]
edge_h = self.edge_encoder(edge_attr) # [n_edges, edge_hidden]
# Process each batch item
outputs = []
for b in range(batch_size):
h = node_h.clone() # Start fresh for each design
# Message passing with residual connections
for conv, norm in zip(self.conv_layers, self.layer_norms):
h_new = conv(h, edge_index, edge_h, design_h[b])
h = norm(h + h_new) # Residual + LayerNorm
# Decode to displacement
z_disp = self.displacement_decoder(h) # [n_nodes, n_subcases]
outputs.append(z_disp)
# Stack outputs
if is_batched:
return torch.stack(outputs, dim=0) # [batch, n_nodes, n_subcases]
else:
return outputs[0] # [n_nodes, n_subcases]
def count_parameters(self) -> int:
"""Count trainable parameters."""
return sum(p.numel() for p in self.parameters() if p.requires_grad)
class ZernikeGNNLite(nn.Module):
"""
Lightweight version of ZernikeGNN for faster training/inference.
Uses fewer layers and smaller hidden dimension, suitable for
initial experiments or when training data is limited.
"""
def __init__(
self,
n_design_vars: int = 11,
n_subcases: int = 4,
hidden_dim: int = 64,
n_layers: int = 4
):
super().__init__()
self.n_subcases = n_subcases
# Simpler design encoder
self.design_encoder = nn.Sequential(
nn.Linear(n_design_vars, hidden_dim),
nn.SiLU(),
nn.Linear(hidden_dim, hidden_dim),
)
# Simpler node encoder
self.node_encoder = nn.Sequential(
nn.Linear(4, hidden_dim),
nn.SiLU(),
nn.Linear(hidden_dim, hidden_dim),
)
# Edge encoder
self.edge_encoder = nn.Linear(4, hidden_dim // 2)
# Message passing
self.conv_layers = nn.ModuleList([
DesignConditionedConv(hidden_dim, hidden_dim, hidden_dim, hidden_dim // 2)
for _ in range(n_layers)
])
# Decoder
self.decoder = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim // 2),
nn.SiLU(),
nn.Linear(hidden_dim // 2, n_subcases),
)
def forward(self, node_features, edge_index, edge_attr, design_vars):
"""Forward pass."""
design_h = self.design_encoder(design_vars)
node_h = self.node_encoder(node_features)
edge_h = self.edge_encoder(edge_attr)
for conv in self.conv_layers:
node_h = node_h + conv(node_h, edge_index, edge_h, design_h)
return self.decoder(node_h)
# =============================================================================
# Utility functions
# =============================================================================
def create_model(
n_design_vars: int = 11,
n_subcases: int = 4,
model_type: str = 'full',
**kwargs
) -> nn.Module:
"""
Factory function to create GNN model.
Args:
n_design_vars: Number of design parameters
n_subcases: Number of subcases
model_type: 'full' or 'lite'
**kwargs: Additional arguments passed to model
Returns:
GNN model instance
"""
if model_type == 'lite':
return ZernikeGNNLite(n_design_vars, n_subcases, **kwargs)
else:
return ZernikeGNN(n_design_vars, n_subcases, **kwargs)
def load_model(checkpoint_path: str, device: str = 'cpu') -> nn.Module:
"""
Load trained model from checkpoint.
Args:
checkpoint_path: Path to .pt checkpoint file
device: Device to load model to
Returns:
Loaded model in eval mode
"""
checkpoint = torch.load(checkpoint_path, map_location=device)
# Get model config
config = checkpoint.get('config', {})
model_type = config.get('model_type', 'full')
# Create model
model = create_model(
n_design_vars=config.get('n_design_vars', 11),
n_subcases=config.get('n_subcases', 4),
model_type=model_type,
hidden_dim=config.get('hidden_dim', 128),
n_layers=config.get('n_layers', 6),
)
# Load weights
model.load_state_dict(checkpoint['model_state_dict'])
model.eval()
return model
# =============================================================================
# Testing
# =============================================================================
if __name__ == '__main__':
print("="*60)
print("Testing ZernikeGNN")
print("="*60)
# Create model
model = ZernikeGNN(n_design_vars=11, n_subcases=4, hidden_dim=128, n_layers=6)
print(f"\nModel: {model.__class__.__name__}")
print(f"Parameters: {model.count_parameters():,}")
# Create dummy inputs
n_nodes = 3000
n_edges = 17760
node_features = torch.randn(n_nodes, 4)
edge_index = torch.randint(0, n_nodes, (2, n_edges))
edge_attr = torch.randn(n_edges, 4)
design_vars = torch.randn(11)
# Forward pass
print("\n--- Single Forward Pass ---")
with torch.no_grad():
output = model(node_features, edge_index, edge_attr, design_vars)
print(f"Input design: {design_vars.shape}")
print(f"Output shape: {output.shape}")
print(f"Output range: [{output.min():.6f}, {output.max():.6f}]")
# Batched forward pass
print("\n--- Batched Forward Pass ---")
batch_design = torch.randn(8, 11)
with torch.no_grad():
output_batch = model(node_features, edge_index, edge_attr, batch_design)
print(f"Batch design: {batch_design.shape}")
print(f"Batch output: {output_batch.shape}")
# Test lite model
print("\n--- Lite Model ---")
model_lite = ZernikeGNNLite(n_design_vars=11, n_subcases=4)
print(f"Lite parameters: {sum(p.numel() for p in model_lite.parameters()):,}")
with torch.no_grad():
output_lite = model_lite(node_features, edge_index, edge_attr, design_vars)
print(f"Lite output shape: {output_lite.shape}")
print("\n" + "="*60)
print("✓ All tests passed!")
print("="*60)

View File

@@ -0,0 +1,344 @@
# Atomizer NX Open Hooks
Direct Python hooks for NX CAD/CAE operations via NX Open API.
## Overview
This module provides a clean Python API for manipulating NX parts programmatically. Each hook executes NX journals via `run_journal.exe` and returns structured JSON results.
## Architecture
```
hooks/
├── __init__.py # Main entry point
├── README.md # This file
├── test_hooks.py # Test script
└── nx_cad/ # CAD manipulation hooks
├── __init__.py
├── part_manager.py # Open/Close/Save parts
├── expression_manager.py # Get/Set expressions
├── geometry_query.py # Mass properties, bodies
└── feature_manager.py # Suppress/Unsuppress features
```
## Requirements
- **NX Installation**: Siemens NX 2506 or compatible version
- **Environment Variable**: `NX_BIN_PATH` (defaults to `C:\Program Files\Siemens\NX2506\NXBIN`)
- **Python**: 3.8+ with `atomizer` conda environment
## Quick Start
```python
from optimization_engine.hooks.nx_cad import (
part_manager,
expression_manager,
geometry_query,
feature_manager,
)
# Path to your NX part
part_path = "C:/path/to/model.prt"
# Get all expressions
result = expression_manager.get_expressions(part_path)
if result["success"]:
for name, expr in result["data"]["expressions"].items():
print(f"{name} = {expr['value']} {expr['units']}")
# Get mass properties
result = geometry_query.get_mass_properties(part_path)
if result["success"]:
print(f"Mass: {result['data']['mass']:.4f} kg")
print(f"Material: {result['data']['material']}")
```
## Module Reference
### part_manager
Manage NX part files (open, close, save).
| Function | Description | Returns |
|----------|-------------|---------|
| `open_part(path)` | Open an NX part file | Part info dict |
| `close_part(path)` | Close an open part | Success status |
| `save_part(path)` | Save a part | Success status |
| `save_part_as(path, new_path)` | Save with new name | Success status |
| `get_part_info(path)` | Get part metadata | Part info dict |
**Example:**
```python
from optimization_engine.hooks.nx_cad import part_manager
# Open a part
result = part_manager.open_part("C:/models/bracket.prt")
if result["success"]:
print(f"Opened: {result['data']['part_name']}")
print(f"Modified: {result['data']['is_modified']}")
# Save the part
result = part_manager.save_part("C:/models/bracket.prt")
# Save as new file
result = part_manager.save_part_as(
"C:/models/bracket.prt",
"C:/models/bracket_v2.prt"
)
```
### expression_manager
Get and set NX expressions (design parameters).
| Function | Description | Returns |
|----------|-------------|---------|
| `get_expressions(path)` | Get all expressions | Dict of expressions |
| `get_expression(path, name)` | Get single expression | Expression dict |
| `set_expression(path, name, value)` | Set single expression | Success status |
| `set_expressions(path, dict)` | Set multiple expressions | Success status |
**Example:**
```python
from optimization_engine.hooks.nx_cad import expression_manager
part = "C:/models/bracket.prt"
# Get all expressions
result = expression_manager.get_expressions(part)
if result["success"]:
for name, expr in result["data"]["expressions"].items():
print(f"{name} = {expr['value']} {expr['units']}")
# Example output:
# thickness = 5.0 MilliMeter
# width = 50.0 MilliMeter
# Get specific expression
result = expression_manager.get_expression(part, "thickness")
if result["success"]:
print(f"Thickness: {result['data']['value']} {result['data']['units']}")
# Set single expression
result = expression_manager.set_expression(part, "thickness", 7.5)
# Set multiple expressions (batch update)
result = expression_manager.set_expressions(part, {
"thickness": 7.5,
"width": 60.0,
"height": 100.0
})
if result["success"]:
print(f"Updated {result['data']['update_count']} expressions")
```
### geometry_query
Query geometric properties (mass, volume, bodies).
| Function | Description | Returns |
|----------|-------------|---------|
| `get_mass_properties(path)` | Get mass, volume, area, centroid | Properties dict |
| `get_bodies(path)` | Get body count and types | Bodies dict |
| `get_volume(path)` | Get total volume | Volume float |
| `get_surface_area(path)` | Get total surface area | Area float |
| `get_material(path)` | Get material name | Material string |
**Example:**
```python
from optimization_engine.hooks.nx_cad import geometry_query
part = "C:/models/bracket.prt"
# Get mass properties
result = geometry_query.get_mass_properties(part)
if result["success"]:
data = result["data"]
print(f"Mass: {data['mass']:.6f} {data['mass_unit']}")
print(f"Volume: {data['volume']:.2f} {data['volume_unit']}")
print(f"Surface Area: {data['surface_area']:.2f} {data['area_unit']}")
print(f"Centroid: ({data['centroid']['x']:.2f}, "
f"{data['centroid']['y']:.2f}, {data['centroid']['z']:.2f}) mm")
print(f"Material: {data['material']}")
# Example output:
# Mass: 0.109838 kg
# Volume: 39311.99 mm^3
# Surface Area: 10876.71 mm^2
# Centroid: (0.00, 42.30, 39.58) mm
# Material: Aluminum_2014
# Get body information
result = geometry_query.get_bodies(part)
if result["success"]:
print(f"Total bodies: {result['data']['count']}")
print(f"Solid bodies: {result['data']['solid_count']}")
```
### feature_manager
Suppress and unsuppress features for design exploration.
| Function | Description | Returns |
|----------|-------------|---------|
| `get_features(path)` | List all features | Features list |
| `get_feature_status(path, name)` | Check if suppressed | Boolean |
| `suppress_feature(path, name)` | Suppress a feature | Success status |
| `unsuppress_feature(path, name)` | Unsuppress a feature | Success status |
| `suppress_features(path, names)` | Suppress multiple | Success status |
| `unsuppress_features(path, names)` | Unsuppress multiple | Success status |
**Example:**
```python
from optimization_engine.hooks.nx_cad import feature_manager
part = "C:/models/bracket.prt"
# List all features
result = feature_manager.get_features(part)
if result["success"]:
print(f"Found {result['data']['count']} features")
for feat in result["data"]["features"]:
status = "suppressed" if feat["is_suppressed"] else "active"
print(f" {feat['name']} ({feat['type']}): {status}")
# Suppress a feature
result = feature_manager.suppress_feature(part, "FILLET(3)")
if result["success"]:
print("Feature suppressed!")
# Unsuppress multiple features
result = feature_manager.unsuppress_features(part, ["FILLET(3)", "CHAMFER(1)"])
```
## Return Format
All hook functions return a consistent dictionary structure:
```python
{
"success": bool, # True if operation succeeded
"error": str | None, # Error message if failed
"data": dict # Operation-specific results
}
```
**Error Handling:**
```python
result = expression_manager.get_expressions(part_path)
if not result["success"]:
print(f"Error: {result['error']}")
# Handle error...
else:
# Process result["data"]...
```
## NX Open API Reference
These hooks use the following NX Open APIs (verified via Siemens MCP documentation):
| Hook | NX Open API |
|------|-------------|
| Open part | `Session.Parts.OpenActiveDisplay()` |
| Close part | `Part.Close()` |
| Save part | `Part.Save()`, `Part.SaveAs()` |
| Get expressions | `Part.Expressions` collection |
| Set expression | `ExpressionCollection.Edit()` |
| Update model | `Session.UpdateManager.DoUpdate()` |
| Mass properties | `MeasureManager.NewMassProperties()` |
| Get bodies | `Part.Bodies` collection |
| Suppress feature | `Feature.Suppress()` |
| Unsuppress feature | `Feature.Unsuppress()` |
## Configuration
### NX Path
Set the NX installation path via environment variable:
```bash
# Windows
set NX_BIN_PATH=C:\Program Files\Siemens\NX2506\NXBIN
# Or in Python before importing
import os
os.environ["NX_BIN_PATH"] = r"C:\Program Files\Siemens\NX2506\NXBIN"
```
### Timeout
Journal execution has a default 2-minute timeout. For large parts, you may need to increase this in the hook source code.
## Integration with Atomizer
These hooks are designed to integrate with Atomizer's optimization workflow:
```python
# In run_optimization.py or custom extractor
from optimization_engine.hooks.nx_cad import expression_manager, geometry_query
def evaluate_design(part_path: str, params: dict) -> dict:
"""Evaluate a design point by updating NX model and extracting metrics."""
# 1. Update design parameters
result = expression_manager.set_expressions(part_path, params)
if not result["success"]:
raise RuntimeError(f"Failed to set expressions: {result['error']}")
# 2. Extract mass (objective)
result = geometry_query.get_mass_properties(part_path)
if not result["success"]:
raise RuntimeError(f"Failed to get mass: {result['error']}")
return {
"mass_kg": result["data"]["mass"],
"volume_mm3": result["data"]["volume"],
"material": result["data"]["material"]
}
```
## Testing
Run the test script to verify hooks work with your NX installation:
```bash
# Activate atomizer environment
conda activate atomizer
# Run tests with default bracket part
python -m optimization_engine.hooks.test_hooks
# Or specify a custom part
python -m optimization_engine.hooks.test_hooks "C:/path/to/your/part.prt"
```
## Troubleshooting
### "Part file not found"
- Verify the path exists and is accessible
- Use forward slashes or raw strings: `r"C:\path\to\file.prt"`
### "Failed to open part"
- Ensure NX license is available
- Check `NX_BIN_PATH` environment variable
- Verify NX version compatibility
### "Expression not found"
- Expression names are case-sensitive
- Use `get_expressions()` to list available names
### Journal execution timeout
- Large parts may need longer timeout
- Check NX is not displaying modal dialogs
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2025-12-06 | Initial release with CAD hooks |
## See Also
- [NX_OPEN_AUTOMATION_ROADMAP.md](../../docs/plans/NX_OPEN_AUTOMATION_ROADMAP.md) - Development roadmap
- [SYS_12_EXTRACTOR_LIBRARY.md](../../docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md) - Extractor catalog
- [NXJournaling.com](https://nxjournaling.com/) - NX Open examples

View File

@@ -0,0 +1,72 @@
"""
Atomizer NX Open Hooks
======================
Direct Python hooks for NX CAD/CAE operations via NX Open API.
This module provides a clean Python interface for manipulating NX parts
programmatically. Each hook executes NX journals via `run_journal.exe`
and returns structured JSON results.
Modules
-------
nx_cad : CAD manipulation hooks
- part_manager : Open, close, save parts
- expression_manager : Get/set design parameters
- geometry_query : Mass properties, bodies, volumes
- feature_manager : Suppress/unsuppress features
nx_cae : CAE/Simulation hooks (Phase 2)
- solver_manager : BDF export, solve simulations
Quick Start
-----------
>>> from optimization_engine.hooks.nx_cad import expression_manager
>>> result = expression_manager.get_expressions("C:/model.prt")
>>> if result["success"]:
... for name, expr in result["data"]["expressions"].items():
... print(f"{name} = {expr['value']}")
>>> from optimization_engine.hooks.nx_cae import solver_manager
>>> result = solver_manager.get_bdf_from_solution_folder("C:/model.sim")
Requirements
------------
- Siemens NX 2506+ installed
- NX_BIN_PATH environment variable (or default path)
- Python 3.8+ with atomizer conda environment
See Also
--------
- optimization_engine/hooks/README.md : Full documentation
- docs/plans/NX_OPEN_AUTOMATION_ROADMAP.md : Development roadmap
Version
-------
1.1.0 (2025-12-06) - Added nx_cae module with solver_manager
1.0.0 (2025-12-06) - Initial release with nx_cad hooks
"""
from .nx_cad import (
part_manager,
expression_manager,
geometry_query,
feature_manager,
)
from .nx_cae import (
solver_manager,
)
__all__ = [
# CAD hooks
'part_manager',
'expression_manager',
'geometry_query',
'feature_manager',
# CAE hooks
'solver_manager',
]
__version__ = '1.1.0'
__author__ = 'Atomizer'

Some files were not shown because too many files have changed in this diff Show More