Compare commits
10 Commits
f8b90156b3
...
96b196de58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b196de58 | ||
|
|
c6f39bfd6c | ||
|
|
0e04457539 | ||
|
|
6cf12d9344 | ||
|
|
3e9488d9f0 | ||
|
|
602560c46a | ||
|
|
0cb2808c44 | ||
|
|
5fb94fdf01 | ||
|
|
5c660ff270 | ||
|
|
fb2d06236a |
371
.claude/ATOMIZER_CONTEXT.md
Normal file
371
.claude/ATOMIZER_CONTEXT.md
Normal 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.*
|
||||
93
.claude/commands/nx-expert.md
Normal file
93
.claude/commands/nx-expert.md
Normal 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
|
||||
116
.claude/commands/protocol-auditor.md
Normal file
116
.claude/commands/protocol-auditor.md
Normal 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
|
||||
132
.claude/commands/results-analyzer.md
Normal file
132
.claude/commands/results-analyzer.md
Normal 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
|
||||
73
.claude/commands/study-builder.md
Normal file
73
.claude/commands/study-builder.md
Normal 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
|
||||
215
.claude/skills/00_BOOTSTRAP.md
Normal file
215
.claude/skills/00_BOOTSTRAP.md
Normal 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`
|
||||
243
.claude/skills/01_CHEATSHEET.md
Normal file
243
.claude/skills/01_CHEATSHEET.md
Normal 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 |
|
||||
323
.claude/skills/02_CONTEXT_LOADER.md
Normal file
323
.claude/skills/02_CONTEXT_LOADER.md
Normal 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
|
||||
398
.claude/skills/DEV_DOCUMENTATION.md
Normal file
398
.claude/skills/DEV_DOCUMENTATION.md
Normal 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
|
||||
361
.claude/skills/PROTOCOL_EXECUTION.md
Normal file
361
.claude/skills/PROTOCOL_EXECUTION.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
751
.claude/skills/core/study-creation-core.md
Normal file
751
.claude/skills/core/study-creation-core.md
Normal 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)
|
||||
402
.claude/skills/create-study-wizard.md
Normal file
402
.claude/skills/create-study-wizard.md
Normal 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
@@ -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
|
||||
|
||||
325
.claude/skills/guided-study-wizard.md
Normal file
325
.claude/skills/guided-study-wizard.md
Normal 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
|
||||
289
.claude/skills/modules/extractors-catalog.md
Normal file
289
.claude/skills/modules/extractors-catalog.md
Normal 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)
|
||||
340
.claude/skills/modules/neural-acceleration.md
Normal file
340
.claude/skills/modules/neural-acceleration.md
Normal 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)
|
||||
209
.claude/skills/modules/nx-docs-lookup.md
Normal file
209
.claude/skills/modules/nx-docs-lookup.md
Normal 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*
|
||||
364
.claude/skills/modules/zernike-optimization.md
Normal file
364
.claude/skills/modules/zernike-optimization.md
Normal 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
490
CLAUDE.md
@@ -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.*
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
458
atomizer-dashboard/frontend/src/components/ConfigEditor.tsx
Normal file
458
atomizer-dashboard/frontend/src/components/ConfigEditor.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
184
atomizer-dashboard/frontend/src/components/MarkdownRenderer.tsx
Normal file
184
atomizer-dashboard/frontend/src/components/MarkdownRenderer.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CurrentTrialPanel } from './CurrentTrialPanel';
|
||||
export { OptimizerStatePanel } from './OptimizerStatePanel';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
172
atomizer-dashboard/frontend/src/hooks/useNotifications.ts
Normal file
172
atomizer-dashboard/frontend/src/hooks/useNotifications.ts
Normal 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;
|
||||
757
atomizer-dashboard/frontend/src/pages/Analysis.tsx
Normal file
757
atomizer-dashboard/frontend/src/pages/Analysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
780
atomizer-dashboard/frontend/src/pages/Setup.tsx
Normal file
780
atomizer-dashboard/frontend/src/pages/Setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
1027
docs/generated/EXTRACTORS.md
Normal file
File diff suppressed because it is too large
Load Diff
29
docs/generated/EXTRACTOR_CHEATSHEET.md
Normal file
29
docs/generated/EXTRACTOR_CHEATSHEET.md
Normal 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
187
docs/generated/TEMPLATES.md
Normal 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
|
||||
|
||||
---
|
||||
717
docs/plans/NX_OPEN_AUTOMATION_ROADMAP.md
Normal file
717
docs/plans/NX_OPEN_AUTOMATION_ROADMAP.md
Normal 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
160
docs/protocols/README.md
Normal 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 |
|
||||
395
docs/protocols/extensions/EXT_01_CREATE_EXTRACTOR.md
Normal file
395
docs/protocols/extensions/EXT_01_CREATE_EXTRACTOR.md
Normal 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 |
|
||||
366
docs/protocols/extensions/EXT_02_CREATE_HOOK.md
Normal file
366
docs/protocols/extensions/EXT_02_CREATE_HOOK.md
Normal 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 |
|
||||
263
docs/protocols/extensions/EXT_03_CREATE_PROTOCOL.md
Normal file
263
docs/protocols/extensions/EXT_03_CREATE_PROTOCOL.md
Normal 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 |
|
||||
331
docs/protocols/extensions/EXT_04_CREATE_SKILL.md
Normal file
331
docs/protocols/extensions/EXT_04_CREATE_SKILL.md
Normal 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 |
|
||||
186
docs/protocols/extensions/templates/extractor_template.py
Normal file
186
docs/protocols/extensions/templates/extractor_template.py
Normal 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
|
||||
# =========================================
|
||||
213
docs/protocols/extensions/templates/hook_template.py
Normal file
213
docs/protocols/extensions/templates/hook_template.py
Normal 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}
|
||||
112
docs/protocols/extensions/templates/protocol_template.md
Normal file
112
docs/protocols/extensions/templates/protocol_template.md
Normal 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 |
|
||||
403
docs/protocols/operations/OP_01_CREATE_STUDY.md
Normal file
403
docs/protocols/operations/OP_01_CREATE_STUDY.md
Normal 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 |
|
||||
297
docs/protocols/operations/OP_02_RUN_OPTIMIZATION.md
Normal file
297
docs/protocols/operations/OP_02_RUN_OPTIMIZATION.md
Normal 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 |
|
||||
246
docs/protocols/operations/OP_03_MONITOR_PROGRESS.md
Normal file
246
docs/protocols/operations/OP_03_MONITOR_PROGRESS.md
Normal 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 |
|
||||
302
docs/protocols/operations/OP_04_ANALYZE_RESULTS.md
Normal file
302
docs/protocols/operations/OP_04_ANALYZE_RESULTS.md
Normal 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 |
|
||||
294
docs/protocols/operations/OP_05_EXPORT_TRAINING_DATA.md
Normal file
294
docs/protocols/operations/OP_05_EXPORT_TRAINING_DATA.md
Normal 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 |
|
||||
437
docs/protocols/operations/OP_06_TROUBLESHOOT.md
Normal file
437
docs/protocols/operations/OP_06_TROUBLESHOOT.md
Normal 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 |
|
||||
341
docs/protocols/system/SYS_10_IMSO.md
Normal file
341
docs/protocols/system/SYS_10_IMSO.md
Normal 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%.
|
||||
338
docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md
Normal file
338
docs/protocols/system/SYS_11_MULTI_OBJECTIVE.md
Normal 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 |
|
||||
610
docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md
Normal file
610
docs/protocols/system/SYS_12_EXTRACTOR_LIBRARY.md
Normal 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 |
|
||||
435
docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md
Normal file
435
docs/protocols/system/SYS_13_DASHBOARD_TRACKING.md
Normal 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 |
|
||||
685
docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md
Normal file
685
docs/protocols/system/SYS_14_NEURAL_ACCELERATION.md
Normal 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 |
|
||||
442
docs/protocols/system/SYS_15_METHOD_SELECTOR.md
Normal file
442
docs/protocols/system/SYS_15_METHOD_SELECTOR.md
Normal 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 |
|
||||
341
optimization_engine/auto_doc.py
Normal file
341
optimization_engine/auto_doc.py
Normal 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()
|
||||
598
optimization_engine/base_runner.py
Normal file
598
optimization_engine/base_runner.py
Normal 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)
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
491
optimization_engine/extractors/extract_modal_mass.py
Normal file
491
optimization_engine/extractors/extract_modal_mass.py
Normal 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]")
|
||||
241
optimization_engine/extractors/extract_principal_stress.py
Normal file
241
optimization_engine/extractors/extract_principal_stress.py
Normal 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]")
|
||||
322
optimization_engine/extractors/extract_spc_forces.py
Normal file
322
optimization_engine/extractors/extract_spc_forces.py
Normal 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>")
|
||||
280
optimization_engine/extractors/extract_strain_energy.py
Normal file
280
optimization_engine/extractors/extract_strain_energy.py
Normal 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>")
|
||||
467
optimization_engine/extractors/extract_temperature.py
Normal file
467
optimization_engine/extractors/extract_temperature.py
Normal 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]")
|
||||
195
optimization_engine/extractors/test_phase3_extractors.py
Normal file
195
optimization_engine/extractors/test_phase3_extractors.py
Normal 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())
|
||||
834
optimization_engine/generic_surrogate.py
Normal file
834
optimization_engine/generic_surrogate.py
Normal 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)
|
||||
69
optimization_engine/gnn/__init__.py
Normal file
69
optimization_engine/gnn/__init__.py
Normal 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',
|
||||
]
|
||||
475
optimization_engine/gnn/backfill_field_data.py
Normal file
475
optimization_engine/gnn/backfill_field_data.py
Normal 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())
|
||||
544
optimization_engine/gnn/differentiable_zernike.py
Normal file
544
optimization_engine/gnn/differentiable_zernike.py
Normal 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)
|
||||
455
optimization_engine/gnn/extract_displacement_field.py
Normal file
455
optimization_engine/gnn/extract_displacement_field.py
Normal 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")
|
||||
718
optimization_engine/gnn/gnn_optimizer.py
Normal file
718
optimization_engine/gnn/gnn_optimizer.py
Normal 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()
|
||||
617
optimization_engine/gnn/polar_graph.py
Normal file
617
optimization_engine/gnn/polar_graph.py
Normal 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}")
|
||||
37
optimization_engine/gnn/test_field_extraction.py
Normal file
37
optimization_engine/gnn/test_field_extraction.py
Normal 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")
|
||||
35
optimization_engine/gnn/test_new_extraction.py
Normal file
35
optimization_engine/gnn/test_new_extraction.py
Normal 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!")
|
||||
108
optimization_engine/gnn/test_polar_graph.py
Normal file
108
optimization_engine/gnn/test_polar_graph.py
Normal 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)
|
||||
600
optimization_engine/gnn/train_zernike_gnn.py
Normal file
600
optimization_engine/gnn/train_zernike_gnn.py
Normal 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()
|
||||
582
optimization_engine/gnn/zernike_gnn.py
Normal file
582
optimization_engine/gnn/zernike_gnn.py
Normal 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)
|
||||
344
optimization_engine/hooks/README.md
Normal file
344
optimization_engine/hooks/README.md
Normal 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
|
||||
72
optimization_engine/hooks/__init__.py
Normal file
72
optimization_engine/hooks/__init__.py
Normal 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
Reference in New Issue
Block a user