feat: Add Zernike GNN surrogate module and M1 mirror V12/V13 studies

This commit introduces the GNN-based surrogate for Zernike mirror optimization
and the M1 mirror study progression from V12 (GNN validation) to V13 (pure NSGA-II).

## GNN Surrogate Module (optimization_engine/gnn/)

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

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

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

## M1 Mirror Studies

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

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

## Documentation Updates

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-10 08:44:04 -05:00
parent c6f39bfd6c
commit 96b196de58
22 changed files with 8329 additions and 2 deletions

View File

@@ -0,0 +1,220 @@
{
"$schema": "Atomizer M1 Mirror Adaptive Surrogate Optimization V12",
"study_name": "m1_mirror_adaptive_V12",
"description": "V12 - Adaptive optimization with tuned hyperparameters, ensemble surrogate, and mass constraint (<99kg).",
"source_study": {
"path": "../m1_mirror_adaptive_V11",
"database": "../m1_mirror_adaptive_V11/3_results/study.db",
"model_dir": "../m1_mirror_adaptive_V11/1_setup/model",
"description": "V11 FEA data (107 samples) used for initial surrogate training"
},
"source_model_dir": "C:\\Users\\Antoine\\CADTOMASTE\\Atomizer\\M1-Gigabit\\Latest",
"design_variables": [
{
"name": "lateral_inner_angle",
"expression_name": "lateral_inner_angle",
"min": 25.0,
"max": 28.5,
"baseline": 26.79,
"units": "degrees",
"enabled": true
},
{
"name": "lateral_outer_angle",
"expression_name": "lateral_outer_angle",
"min": 13.0,
"max": 17.0,
"baseline": 14.64,
"units": "degrees",
"enabled": true
},
{
"name": "lateral_outer_pivot",
"expression_name": "lateral_outer_pivot",
"min": 9.0,
"max": 12.0,
"baseline": 10.40,
"units": "mm",
"enabled": true
},
{
"name": "lateral_inner_pivot",
"expression_name": "lateral_inner_pivot",
"min": 9.0,
"max": 12.0,
"baseline": 10.07,
"units": "mm",
"enabled": true
},
{
"name": "lateral_middle_pivot",
"expression_name": "lateral_middle_pivot",
"min": 18.0,
"max": 23.0,
"baseline": 20.73,
"units": "mm",
"enabled": true
},
{
"name": "lateral_closeness",
"expression_name": "lateral_closeness",
"min": 9.5,
"max": 12.5,
"baseline": 11.02,
"units": "mm",
"enabled": true
},
{
"name": "whiffle_min",
"expression_name": "whiffle_min",
"min": 35.0,
"max": 55.0,
"baseline": 40.55,
"units": "mm",
"enabled": true
},
{
"name": "whiffle_outer_to_vertical",
"expression_name": "whiffle_outer_to_vertical",
"min": 68.0,
"max": 80.0,
"baseline": 75.67,
"units": "degrees",
"enabled": true
},
{
"name": "whiffle_triangle_closeness",
"expression_name": "whiffle_triangle_closeness",
"min": 50.0,
"max": 65.0,
"baseline": 60.00,
"units": "mm",
"enabled": true
},
{
"name": "blank_backface_angle",
"expression_name": "blank_backface_angle",
"min": 4,
"max": 5.0,
"baseline": 4.23,
"units": "degrees",
"enabled": true
},
{
"name": "inner_circular_rib_dia",
"expression_name": "inner_circular_rib_dia",
"min": 480.0,
"max": 620.0,
"baseline": 534.00,
"units": "mm",
"enabled": true
}
],
"objectives": [
{
"name": "rel_filtered_rms_40_vs_20",
"description": "Filtered RMS WFE at 40 deg relative to 20 deg reference (operational tracking)",
"direction": "minimize",
"weight": 5.0,
"target": 4.0,
"units": "nm",
"extractor_config": {
"target_subcase": "3",
"reference_subcase": "2",
"metric": "relative_filtered_rms_nm"
}
},
{
"name": "rel_filtered_rms_60_vs_20",
"description": "Filtered RMS WFE at 60 deg relative to 20 deg reference (operational tracking)",
"direction": "minimize",
"weight": 5.0,
"target": 10.0,
"units": "nm",
"extractor_config": {
"target_subcase": "4",
"reference_subcase": "2",
"metric": "relative_filtered_rms_nm"
}
},
{
"name": "mfg_90_optician_workload",
"description": "Manufacturing deformation at 90 deg polishing (J1-J3 filtered RMS)",
"direction": "minimize",
"weight": 1.0,
"target": 20.0,
"units": "nm",
"extractor_config": {
"target_subcase": "1",
"reference_subcase": "2",
"metric": "relative_rms_filter_j1to3"
}
}
],
"zernike_settings": {
"n_modes": 50,
"filter_low_orders": 4,
"displacement_unit": "mm",
"subcases": ["1", "2", "3", "4"],
"subcase_labels": {"1": "90deg", "2": "20deg", "3": "40deg", "4": "60deg"},
"reference_subcase": "2"
},
"constraints": [
{
"name": "mass_limit",
"type": "upper_bound",
"expression_name": "p173",
"max_value": 99.0,
"units": "kg",
"description": "Mirror assembly mass must be under 99kg",
"penalty_weight": 100.0,
"influenced_by": ["blank_backface_angle"]
}
],
"adaptive_settings": {
"max_iterations": 100,
"surrogate_trials_per_iter": 1000,
"fea_batch_size": 5,
"strategy": "hybrid",
"exploration_ratio": 0.3,
"convergence_threshold_nm": 0.3,
"patience": 5,
"min_training_samples": 30,
"retrain_epochs": 300
},
"surrogate_settings": {
"model_type": "ZernikeSurrogate",
"hidden_dims": [128, 256, 256, 128, 64],
"dropout": 0.1,
"learning_rate": 0.001,
"batch_size": 16,
"mc_dropout_samples": 30
},
"nx_settings": {
"nx_install_path": "C:\\Program Files\\Siemens\\NX2506",
"sim_file": "ASSY_M1_assyfem1_sim1.sim",
"solution_name": "Solution 1",
"op2_pattern": "*-solution_1.op2",
"simulation_timeout_s": 900,
"journal_timeout_s": 120,
"op2_timeout_s": 1800,
"auto_start_nx": true
},
"dashboard_settings": {
"trial_source_tag": true,
"fea_marker": "circle",
"nn_marker": "cross",
"fea_color": "#2196F3",
"nn_color": "#FF9800"
}
}

View File

@@ -0,0 +1,529 @@
# M1 Mirror Adaptive Surrogate Optimization V12
Adaptive neural-accelerated optimization of telescope primary mirror (M1) support structure using Zernike wavefront error decomposition with **auto-tuned hyperparameters**, **ensemble surrogates**, and **mass constraints**.
**Created**: 2024-12-04
**Protocol**: Protocol 12 (Adaptive Hybrid FEA/Neural with Hyperparameter Tuning)
**Status**: Running
**Source Data**: V11 (107 FEA samples)
---
## 1. Engineering Problem
### 1.1 Objective
Optimize the telescope primary mirror (M1) whiffle tree and lateral support structure to minimize wavefront error (WFE) across different gravity orientations while maintaining mass under 99 kg.
### 1.2 Physical System
| Property | Value |
|----------|-------|
| **Component** | M1 primary mirror assembly with whiffle tree support |
| **Material** | Borosilicate glass (mirror blank), steel (support structure) |
| **Loading** | Gravity at multiple zenith angles (90°, 20°, 40°, 60°) |
| **Boundary Conditions** | Whiffle tree kinematic mount with lateral supports |
| **Analysis Type** | Linear static multi-subcase (Nastran SOL 101) |
| **Subcases** | 4 orientations with different gravity vectors |
| **Output** | Surface deformation → Zernike polynomial decomposition |
### 1.3 Key Improvements in V12
| Feature | V11 | V12 |
|---------|-----|-----|
| Hyperparameter Tuning | Fixed architecture | Optuna auto-tuning |
| Model Architecture | Single network | Ensemble of 3 models |
| Validation | Train/test split | K-fold cross-validation |
| Mass Constraint | Post-hoc check | Integrated penalty |
| Convergence | Fixed iterations | Early stopping with patience |
---
## 2. Mathematical Formulation
### 2.1 Objectives
| Objective | Goal | Weight | Formula | Units | Target |
|-----------|------|--------|---------|-------|--------|
| `rel_filtered_rms_40_vs_20` | minimize | 5.0 | $\sigma_{40/20} = \sqrt{\sum_{j=5}^{50} (Z_j^{40} - Z_j^{20})^2}$ | nm | 4 nm |
| `rel_filtered_rms_60_vs_20` | minimize | 5.0 | $\sigma_{60/20} = \sqrt{\sum_{j=5}^{50} (Z_j^{60} - Z_j^{20})^2}$ | nm | 10 nm |
| `mfg_90_optician_workload` | minimize | 1.0 | $\sigma_{90}^{J4+} = \sqrt{\sum_{j=4}^{50} (Z_j^{90} - Z_j^{20})^2}$ | nm | 20 nm |
**Weighted Sum Objective**:
$$J(\mathbf{x}) = \sum_{i=1}^{3} w_i \cdot \frac{f_i(\mathbf{x})}{t_i} + P_{mass}(\mathbf{x})$$
Where:
- $w_i$ = weight for objective $i$
- $f_i(\mathbf{x})$ = objective value
- $t_i$ = target value (normalization)
- $P_{mass}$ = mass constraint penalty
### 2.2 Zernike Decomposition
The wavefront error $W(r,\theta)$ is decomposed into Noll-indexed Zernike polynomials:
$$W(r,\theta) = \sum_{j=1}^{50} Z_j \cdot P_j(r,\theta)$$
**WFE from Displacement** (reflection factor of 2):
$$W_{nm} = 2 \cdot \delta_z \cdot 10^6$$
Where $\delta_z$ is the Z-displacement in mm.
**Filtered RMS** (excluding alignable terms J1-J4):
$$\sigma_{filtered} = \sqrt{\sum_{j=5}^{50} Z_j^2}$$
**Manufacturing RMS** (excluding J1-J3, keeping defocus J4):
$$\sigma_{mfg} = \sqrt{\sum_{j=4}^{50} Z_j^2}$$
### 2.3 Design Variables (11 Parameters)
| Parameter | Symbol | Bounds | Baseline | Units | Description |
|-----------|--------|--------|----------|-------|-------------|
| `lateral_inner_angle` | $\alpha_{in}$ | [25, 28.5] | 26.79 | deg | Inner lateral support angle |
| `lateral_outer_angle` | $\alpha_{out}$ | [13, 17] | 14.64 | deg | Outer lateral support angle |
| `lateral_outer_pivot` | $p_{out}$ | [9, 12] | 10.40 | mm | Outer pivot offset |
| `lateral_inner_pivot` | $p_{in}$ | [9, 12] | 10.07 | mm | Inner pivot offset |
| `lateral_middle_pivot` | $p_{mid}$ | [18, 23] | 20.73 | mm | Middle pivot offset |
| `lateral_closeness` | $c_{lat}$ | [9.5, 12.5] | 11.02 | mm | Lateral support spacing |
| `whiffle_min` | $w_{min}$ | [35, 55] | 40.55 | mm | Whiffle tree minimum |
| `whiffle_outer_to_vertical` | $\theta_w$ | [68, 80] | 75.67 | deg | Outer whiffle angle |
| `whiffle_triangle_closeness` | $c_w$ | [50, 65] | 60.00 | mm | Whiffle triangle spacing |
| `blank_backface_angle` | $\beta$ | [4.0, 5.0] | 4.23 | deg | Mirror backface angle (mass driver) |
| `inner_circular_rib_dia` | $D_{rib}$ | [480, 620] | 534.00 | mm | Inner rib diameter |
**Design Space**:
$$\mathbf{x} = [\alpha_{in}, \alpha_{out}, p_{out}, p_{in}, p_{mid}, c_{lat}, w_{min}, \theta_w, c_w, \beta, D_{rib}]^T \in \mathbb{R}^{11}$$
### 2.4 Mass Constraint
| Constraint | Type | Formula | Threshold | Handling |
|------------|------|---------|-----------|----------|
| `mass_limit` | upper_bound | $m(\mathbf{x}) \leq m_{max}$ | 99 kg | Penalty in objective |
**Penalty Function**:
$$P_{mass}(\mathbf{x}) = \begin{cases}
0 & \text{if } m \leq 99 \\
100 \cdot (m - 99) & \text{if } m > 99
\end{cases}$$
**Mass Estimation** (from `blank_backface_angle`):
$$\hat{m}(\beta) = 105 - 15 \cdot (\beta - 4.0) \text{ kg}$$
---
## 3. Optimization Algorithm
### 3.1 Adaptive Hybrid Strategy
| Parameter | Value | Description |
|-----------|-------|-------------|
| Algorithm | Adaptive Hybrid | FEA + Neural Surrogate |
| Surrogate | Tuned Ensemble (3 models) | Auto-tuned architecture |
| Sampler | TPE | Tree-structured Parzen Estimator |
| Max Iterations | 100 | Adaptive loop iterations |
| FEA Batch Size | 5 | Real FEA evaluations per iteration |
| NN Trials | 1000 | Surrogate evaluations per iteration |
| Patience | 5 | Early stopping threshold |
| Convergence | 0.3 nm | Objective improvement threshold |
### 3.2 Hyperparameter Tuning
| Setting | Value |
|---------|-------|
| Tuning Trials | 30 |
| Cross-Validation | 5-fold |
| Search Space | Hidden dims, dropout, learning rate |
| Ensemble Size | 3 models |
| MC Dropout Samples | 30 |
**Tuned Architecture**:
```
Input(11) → Linear(128) → ReLU → Dropout →
Linear(256) → ReLU → Dropout →
Linear(256) → ReLU → Dropout →
Linear(128) → ReLU → Dropout →
Linear(64) → ReLU → Linear(4)
```
### 3.3 Adaptive Loop Flow
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ADAPTIVE ITERATION k │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. SURROGATE EXPLORATION (1000 trials) │
│ ├── Sample 11 design variables via TPE │
│ ├── Predict objectives with ensemble (mean + uncertainty) │
│ └── Select top candidates (exploitation) + diverse (exploration) │
│ │
│ 2. FEA VALIDATION (5 trials) │
│ ├── Run NX Nastran SOL 101 (4 subcases) │
│ ├── Extract Zernike coefficients from OP2 │
│ ├── Compute relative filtered RMS │
│ └── Store in Optuna database │
│ │
│ 3. SURROGATE RETRAINING │
│ ├── Load all FEA data from database │
│ ├── Retrain ensemble with new data │
│ └── Update uncertainty estimates │
│ │
│ 4. CONVERGENCE CHECK │
│ ├── Δbest < 0.3 nm for patience=5 iterations? │
│ └── If converged → STOP, else → next iteration │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 4. Simulation Pipeline
### 4.1 FEA Trial Execution Flow
```
┌─────────────────────────────────────────────────────────────────────────┐
│ FEA TRIAL n EXECUTION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CANDIDATE SELECTION │
│ Hybrid strategy: 70% exploitation (best NN predictions) │
│ 30% exploration (uncertain regions) │
│ │
│ 2. NX PARAMETER UPDATE │
│ Module: optimization_engine/nx_solver.py │
│ Target Part: M1_Blank.prt (and related components) │
│ Action: Update 11 expressions with new design values │
│ │
│ 3. NX SIMULATION (Nastran SOL 101 - 4 Subcases) │
│ Module: optimization_engine/solve_simulation.py │
│ Input: ASSY_M1_assyfem1_sim1.sim │
│ Subcases: │
│ 1 = 90° zenith (polishing/manufacturing) │
│ 2 = 20° zenith (reference) │
│ 3 = 40° zenith (operational target 1) │
│ 4 = 60° zenith (operational target 2) │
│ Output: .dat, .op2, .f06 │
│ │
│ 4. ZERNIKE EXTRACTION │
│ Module: optimization_engine/extractors/extract_zernike.py │
│ a. Read node coordinates from BDF/DAT │
│ b. Read Z-displacements from OP2 for each subcase │
│ c. Compute RELATIVE displacement (target - reference) │
│ d. Convert to WFE: W = 2 × Δδz × 10⁶ nm │
│ e. Fit 50 Zernike coefficients via least-squares │
│ f. Compute filtered RMS (exclude J1-J4) │
│ │
│ 5. MASS EXTRACTION │
│ Module: optimization_engine/extractors/extract_mass_from_expression │
│ Expression: p173 (CAD mass property) │
│ │
│ 6. OBJECTIVE COMPUTATION │
│ rel_filtered_rms_40_vs_20 ← Zernike RMS (subcase 3 - 2) │
│ rel_filtered_rms_60_vs_20 ← Zernike RMS (subcase 4 - 2) │
│ mfg_90_optician_workload ← Zernike RMS J4+ (subcase 1 - 2) │
│ │
│ 7. WEIGHTED OBJECTIVE + MASS PENALTY │
│ J = Σ (weight × objective / target) + mass_penalty │
│ │
│ 8. STORE IN DATABASE │
│ Optuna SQLite: 3_results/study.db │
│ User attrs: trial_source='fea', mass_kg, all Zernike coefficients │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 4.2 Subcase Configuration
| Subcase | Zenith Angle | Gravity Direction | Role |
|---------|--------------|-------------------|------|
| 1 | 90° | Horizontal | Manufacturing/polishing reference |
| 2 | 20° | Near-vertical | Operational reference (baseline) |
| 3 | 40° | Mid-elevation | Operational target 1 |
| 4 | 60° | Low-elevation | Operational target 2 |
---
## 5. Result Extraction Methods
### 5.1 Zernike WFE Extraction
| Attribute | Value |
|-----------|-------|
| **Extractor** | `ZernikeExtractor` |
| **Module** | `optimization_engine.extractors.extract_zernike` |
| **Method** | `extract_relative()` |
| **Geometry Source** | `.dat` (BDF format, auto-detected) |
| **Displacement Source** | `.op2` (OP2 binary) |
| **Output** | 50 Zernike coefficients + RMS metrics per subcase pair |
**Algorithm**:
1. Load node coordinates $(X_i, Y_i)$ from BDF
2. Load Z-displacements $\delta_{z,i}$ from OP2 for each subcase
3. Compute relative displacement (node-by-node):
$$\Delta\delta_{z,i} = \delta_{z,i}^{target} - \delta_{z,i}^{reference}$$
4. Convert to WFE:
$$W_i = 2 \cdot \Delta\delta_{z,i} \cdot 10^6 \text{ nm}$$
5. Fit Zernike coefficients via least-squares:
$$\min_{\mathbf{Z}} \| \mathbf{W} - \mathbf{P} \mathbf{Z} \|^2$$
6. Compute filtered RMS:
$$\sigma_{filtered} = \sqrt{\sum_{j=5}^{50} Z_j^2}$$
**Code**:
```python
from optimization_engine.extractors import ZernikeExtractor
extractor = ZernikeExtractor(op2_file, bdf_file)
result = extractor.extract_relative(
target_subcase="3", # 40 deg
reference_subcase="2", # 20 deg
displacement_unit="mm"
)
filtered_rms = result['relative_filtered_rms_nm'] # nm
```
### 5.2 Mass Extraction
| Attribute | Value |
|-----------|-------|
| **Extractor** | `extract_mass_from_expression` |
| **Module** | `optimization_engine.extractors` |
| **Expression** | `p173` (CAD mass property) |
| **Output** | kg |
**Code**:
```python
from optimization_engine.extractors import extract_mass_from_expression
mass_kg = extract_mass_from_expression(model_file, expression_name="p173")
```
---
## 6. Neural Acceleration (Tuned Ensemble Surrogate)
### 6.1 Configuration
| Setting | Value | Description |
|---------|-------|-------------|
| `enabled` | `true` | Neural surrogate active |
| `model_type` | `TunedEnsembleSurrogate` | Ensemble of tuned networks |
| `ensemble_size` | 3 | Number of models in ensemble |
| `hidden_dims` | `[128, 256, 256, 128, 64]` | Auto-tuned architecture |
| `dropout` | 0.1 | Regularization |
| `learning_rate` | 0.001 | Adam optimizer |
| `batch_size` | 16 | Mini-batch size |
| `mc_dropout_samples` | 30 | Monte Carlo uncertainty |
### 6.2 Hyperparameter Tuning
| Parameter | Search Space |
|-----------|--------------|
| `n_layers` | [3, 4, 5, 6] |
| `hidden_dim` | [64, 128, 256, 512] |
| `dropout` | [0.0, 0.1, 0.2, 0.3] |
| `learning_rate` | [1e-4, 1e-3, 1e-2] |
| `batch_size` | [8, 16, 32, 64] |
**Tuning Objective**:
$$\mathcal{L}_{tune} = \frac{1}{K} \sum_{k=1}^{K} MSE_{val}^{(k)}$$
Using 5-fold cross-validation.
### 6.3 Surrogate Model
**Input**: $\mathbf{x} = [11 \text{ design variables}]^T \in \mathbb{R}^{11}$
**Output**: $\hat{\mathbf{y}} = [4 \text{ objectives/constraints}]^T \in \mathbb{R}^{4}$
- `rel_filtered_rms_40_vs_20` (nm)
- `rel_filtered_rms_60_vs_20` (nm)
- `mfg_90_optician_workload` (nm)
- `mass_kg` (kg)
**Ensemble Prediction**:
$$\hat{y} = \frac{1}{M} \sum_{m=1}^{M} f_m(\mathbf{x})$$
**Uncertainty Quantification** (MC Dropout):
$$\sigma_y^2 = \frac{1}{T} \sum_{t=1}^{T} f_{dropout}^{(t)}(\mathbf{x})^2 - \hat{y}^2$$
### 6.4 Training Data Location
```
m1_mirror_adaptive_V12/
├── 2_iterations/
│ ├── iter_001/ # Iteration 1 working files
│ ├── iter_002/
│ └── ...
├── 3_results/
│ ├── study.db # Optuna database (all trials)
│ ├── optimization.log # Detailed log
│ ├── surrogate_best.pt # Best tuned model weights
│ └── tuning_results.json # Hyperparameter tuning history
```
### 6.5 Expected Performance
| Metric | Value |
|--------|-------|
| Source Data | V11: 107 FEA samples |
| FEA time per trial | 10-15 min |
| Neural time per trial | ~5 ms |
| Speedup | ~120,000x |
| Expected R² | > 0.90 (after tuning) |
| Uncertainty Coverage | ~95% (ensemble + MC dropout) |
---
## 7. Study File Structure
```
m1_mirror_adaptive_V12/
├── 1_setup/ # INPUT CONFIGURATION
│ ├── model/ → symlink to V11 # NX Model Files
│ │ ├── ASSY_M1.prt # Top-level assembly
│ │ ├── M1_Blank.prt # Mirror blank (expressions)
│ │ ├── ASSY_M1_assyfem1.afm # Assembly FEM
│ │ ├── ASSY_M1_assyfem1_sim1.sim # Simulation file
│ │ └── *-solution_1.op2 # Results (generated)
│ │
│ └── optimization_config.json # Study configuration
├── 2_iterations/ # WORKING DIRECTORY
│ ├── iter_001/ # Iteration 1 model copy
│ ├── iter_002/
│ └── ...
├── 3_results/ # OUTPUT (auto-generated)
│ ├── study.db # Optuna SQLite database
│ ├── optimization.log # Structured log
│ ├── surrogate_best.pt # Trained ensemble weights
│ ├── tuning_results.json # Hyperparameter tuning
│ └── convergence.json # Iteration history
├── run_optimization.py # Main entry point
├── final_validation.py # FEA validation of best NN trials
├── README.md # This blueprint
└── STUDY_REPORT.md # Results report (updated during run)
```
---
## 8. Results Location
After optimization, results are stored in `3_results/`:
| File | Description | Format |
|------|-------------|--------|
| `study.db` | Optuna database with all trials (FEA + NN) | SQLite |
| `optimization.log` | Detailed execution log | Text |
| `surrogate_best.pt` | Tuned ensemble model weights | PyTorch |
| `tuning_results.json` | Hyperparameter search history | JSON |
| `convergence.json` | Best value per iteration | JSON |
### 8.1 Trial Identification
Trials are tagged with source:
- **FEA trials**: `trial.user_attrs['trial_source'] = 'fea'`
- **NN trials**: `trial.user_attrs['trial_source'] = 'nn'`
**Dashboard Visualization**:
- FEA trials: Blue circles
- NN trials: Orange crosses
### 8.2 Results Report
See [STUDY_REPORT.md](STUDY_REPORT.md) for:
- Optimization progress and convergence
- Best designs found (FEA-validated)
- Surrogate model accuracy (R², MAE)
- Pareto trade-off analysis
- Engineering recommendations
---
## 9. Quick Start
### Launch Optimization
```bash
cd studies/m1_mirror_adaptive_V12
# Start with default settings (uses V11 FEA data)
python run_optimization.py --start
# Custom tuning parameters
python run_optimization.py --start --tune-trials 30 --ensemble-size 3 --fea-batch 5 --patience 5
# Tune hyperparameters only (no FEA)
python run_optimization.py --tune-only
```
### Command Line Options
| Option | Default | Description |
|--------|---------|-------------|
| `--start` | - | Start adaptive optimization |
| `--tune-only` | - | Only tune hyperparameters, no optimization |
| `--tune-trials` | 30 | Number of hyperparameter tuning trials |
| `--ensemble-size` | 3 | Number of models in ensemble |
| `--fea-batch` | 5 | FEA evaluations per iteration |
| `--patience` | 5 | Early stopping patience |
### Monitor Progress
```bash
# View log
tail -f 3_results/optimization.log
# Check database
sqlite3 3_results/study.db "SELECT COUNT(*) FROM trials WHERE state='COMPLETE'"
# Launch Optuna dashboard
optuna-dashboard sqlite:///3_results/study.db --port 8081
```
### Dashboard Access
| Dashboard | URL | Purpose |
|-----------|-----|---------|
| **Atomizer Dashboard** | http://localhost:3000 | Real-time monitoring |
| **Optuna Dashboard** | http://localhost:8081 | Trial history |
---
## 10. Configuration Reference
**File**: `1_setup/optimization_config.json`
| Section | Key | Description |
|---------|-----|-------------|
| `design_variables[]` | 11 parameters | All lateral/whiffle/blank params |
| `objectives[]` | 3 WFE metrics | Relative filtered RMS |
| `constraints[]` | mass_limit | Upper bound 99 kg |
| `zernike_settings.n_modes` | 50 | Zernike polynomial count |
| `zernike_settings.filter_low_orders` | 4 | Exclude J1-J4 |
| `zernike_settings.subcases` | [1,2,3,4] | Zenith angles |
| `adaptive_settings.max_iterations` | 100 | Loop limit |
| `adaptive_settings.surrogate_trials_per_iter` | 1000 | NN trials |
| `adaptive_settings.fea_batch_size` | 5 | FEA per iteration |
| `adaptive_settings.patience` | 5 | Early stopping |
| `surrogate_settings.ensemble_size` | 3 | Model ensemble |
| `surrogate_settings.mc_dropout_samples` | 30 | Uncertainty samples |
---
## 11. References
- **Deb, K. et al.** (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. *IEEE TEC*.
- **Noll, R.J.** (1976). Zernike polynomials and atmospheric turbulence. *JOSA*.
- **Wilson, R.N.** (2004). *Reflecting Telescope Optics I*. Springer.
- **Snoek, J. et al.** (2012). Practical Bayesian optimization of machine learning algorithms. *NeurIPS*.
- **Gal, Y. & Ghahramani, Z.** (2016). Dropout as a Bayesian approximation. *ICML*.
- **pyNastran Documentation**: BDF/OP2 parsing for FEA post-processing.
- **Optuna Documentation**: Hyperparameter optimization framework.
---
*Atomizer V12: Where adaptive AI meets precision optics.*

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
Compute Calibration Factors from Full FEA Dataset
==================================================
Uses ALL 153 FEA training samples to compute robust calibration factors.
This is much better than calibrating only on the GNN's "best" designs,
which are clustered in a narrow region of the design space.
"""
import sys
import json
import numpy as np
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import torch
from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer
# Paths
STUDY_DIR = Path(__file__).parent
CONFIG_PATH = STUDY_DIR / "1_setup" / "optimization_config.json"
CHECKPOINT_PATH = Path("C:/Users/Antoine/Atomizer/zernike_gnn_checkpoint.pt")
# Objective names
OBJECTIVES = [
'rel_filtered_rms_40_vs_20',
'rel_filtered_rms_60_vs_20',
'mfg_90_optician_workload'
]
def main():
print("="*60)
print("FULL DATASET CALIBRATION")
print("="*60)
# Load GNN optimizer (includes trained model and config)
print("\nLoading GNN model...")
optimizer = ZernikeGNNOptimizer.from_checkpoint(CHECKPOINT_PATH, CONFIG_PATH)
print(f" Design variables: {len(optimizer.design_names)}")
# Load training data from gnn_data folder
print("\nLoading training data from gnn_data folder...")
gnn_data_dir = STUDY_DIR / "gnn_data"
training_data = []
if gnn_data_dir.exists():
import h5py
for trial_dir in sorted(gnn_data_dir.iterdir()):
if trial_dir.is_dir() and trial_dir.name.startswith('trial_'):
metadata_path = trial_dir / "metadata.json"
field_path = trial_dir / "displacement_field.h5"
if metadata_path.exists():
with open(metadata_path) as f:
metadata = json.load(f)
if 'objectives' in metadata and metadata.get('objectives'):
training_data.append({
'design_vars': metadata['params'],
'objectives': metadata['objectives'],
})
if not training_data:
# Fallback: load from V11 database
print(" No gnn_data with objectives found, loading from V11 database...")
import sqlite3
v11_db = STUDY_DIR.parent / "m1_mirror_adaptive_V11" / "3_results" / "study.db"
if v11_db.exists():
conn = sqlite3.connect(str(v11_db))
cursor = conn.cursor()
# Get completed trials - filter for FEA trials only (source='fea' or no source means early trials)
cursor.execute("""
SELECT t.trial_id, t.number
FROM trials t
WHERE t.state = 'COMPLETE'
""")
trial_ids = cursor.fetchall()
for trial_id, trial_num in trial_ids:
# Get user attributes
cursor.execute("""
SELECT key, value_json FROM trial_user_attributes
WHERE trial_id = ?
""", (trial_id,))
attrs = {row[0]: json.loads(row[1]) for row in cursor.fetchall()}
# Check if this is an FEA trial (source contains 'FEA' - matches "FEA" and "V10_FEA")
source = attrs.get('source', 'FEA') # Default to 'FEA' for old trials without source tag
if 'FEA' not in source:
continue # Skip NN trials
# Get params
cursor.execute("""
SELECT param_name, param_value FROM trial_params
WHERE trial_id = ?
""", (trial_id,))
params = {row[0]: float(row[1]) for row in cursor.fetchall()}
# Check if objectives exist (stored as individual attributes)
if all(obj in attrs for obj in OBJECTIVES):
training_data.append({
'design_vars': params,
'objectives': {obj: attrs[obj] for obj in OBJECTIVES},
})
conn.close()
print(f" Found {len(training_data)} FEA trials in V11 database")
print(f" Loaded {len(training_data)} training samples")
if not training_data:
print("\n ERROR: No training data found!")
return 1
# Compute GNN predictions for all training samples
print("\nComputing GNN predictions for all training samples...")
gnn_predictions = []
fea_ground_truth = []
for i, sample in enumerate(training_data):
# Get design variables
design_vars = sample['design_vars']
# Get FEA ground truth objectives
fea_obj = sample['objectives']
# Predict with GNN
gnn_pred = optimizer.predict(design_vars)
gnn_obj = gnn_pred.objectives
gnn_predictions.append(gnn_obj)
fea_ground_truth.append(fea_obj)
if (i + 1) % 25 == 0:
print(f" Processed {i+1}/{len(training_data)} samples")
print(f"\n Total: {len(gnn_predictions)} samples")
# Compute calibration factors for each objective
print("\n" + "="*60)
print("CALIBRATION RESULTS")
print("="*60)
calibration = {}
for obj_name in OBJECTIVES:
gnn_vals = np.array([p[obj_name] for p in gnn_predictions])
fea_vals = np.array([f[obj_name] for f in fea_ground_truth])
# Calibration factor = mean(FEA / GNN)
# This gives the multiplicative correction
ratios = fea_vals / gnn_vals
factor = np.mean(ratios)
factor_std = np.std(ratios)
factor_cv = 100 * factor_std / factor # Coefficient of variation
# Also compute after-calibration errors
calibrated_gnn = gnn_vals * factor
abs_errors = np.abs(calibrated_gnn - fea_vals)
pct_errors = 100 * abs_errors / fea_vals
calibration[obj_name] = {
'factor': float(factor),
'std': float(factor_std),
'cv_pct': float(factor_cv),
'calibrated_mean_error_pct': float(np.mean(pct_errors)),
'calibrated_max_error_pct': float(np.max(pct_errors)),
'raw_mean_error_pct': float(np.mean(100 * np.abs(gnn_vals - fea_vals) / fea_vals)),
}
print(f"\n{obj_name}:")
print(f" Calibration factor: {factor:.4f} ± {factor_std:.4f} (CV: {factor_cv:.1f}%)")
print(f" Raw GNN error: {calibration[obj_name]['raw_mean_error_pct']:.1f}%")
print(f" Calibrated error: {np.mean(pct_errors):.1f}% (max: {np.max(pct_errors):.1f}%)")
# Summary
print("\n" + "="*60)
print("SUMMARY")
print("="*60)
print(f"\nCalibration factors (multiply GNN predictions by these):")
for obj_name in OBJECTIVES:
print(f" {obj_name}: {calibration[obj_name]['factor']:.4f}")
print(f"\nExpected error reduction:")
for obj_name in OBJECTIVES:
raw = calibration[obj_name]['raw_mean_error_pct']
cal = calibration[obj_name]['calibrated_mean_error_pct']
print(f" {obj_name}: {raw:.1f}% → {cal:.1f}%")
# Save calibration
output_path = STUDY_DIR / "full_calibration.json"
result = {
'timestamp': str(np.datetime64('now')),
'n_samples': len(training_data),
'calibration': calibration,
'objectives': OBJECTIVES,
}
with open(output_path, 'w') as f:
json.dump(result, f, indent=2)
print(f"\nCalibration saved to: {output_path}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""
M1 Mirror - GNN Turbo Optimization with FEA Validation
=======================================================
Runs fast GNN-based turbo optimization (5000 trials in ~2 min) then
validates top candidates with actual FEA (~5 min each).
Usage:
python run_gnn_turbo.py # Full workflow: 5000 GNN + 5 FEA validations
python run_gnn_turbo.py --gnn-only # Just GNN turbo, no FEA
python run_gnn_turbo.py --validate-top 10 # Validate top 10 instead of 5
python run_gnn_turbo.py --trials 10000 # More GNN trials
Estimated time: ~2 min GNN + ~25 min FEA validation = ~27 min total
"""
import sys
import os
import json
import time
import argparse
import logging
import re
import numpy as np
from pathlib import Path
from datetime import datetime
# Add parent directories to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer
from optimization_engine.nx_solver import NXSolver
from optimization_engine.utils import ensure_nx_running
from optimization_engine.gnn.extract_displacement_field import (
extract_displacement_field, save_field_to_hdf5
)
from optimization_engine.extractors.extract_zernike_surface import extract_surface_zernike
# ============================================================================
# Paths
# ============================================================================
STUDY_DIR = Path(__file__).parent
SETUP_DIR = STUDY_DIR / "1_setup"
MODEL_DIR = SETUP_DIR / "model"
CONFIG_PATH = SETUP_DIR / "optimization_config.json"
CHECKPOINT_PATH = Path("C:/Users/Antoine/Atomizer/zernike_gnn_checkpoint.pt")
RESULTS_DIR = STUDY_DIR / "gnn_turbo_results"
LOG_FILE = STUDY_DIR / "gnn_turbo.log"
# Ensure directories exist
RESULTS_DIR.mkdir(exist_ok=True)
# ============================================================================
# Logging
# ============================================================================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(levelname)-8s | %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(LOG_FILE, mode='a')
]
)
logger = logging.getLogger(__name__)
# ============================================================================
# GNN Turbo Runner
# ============================================================================
class GNNTurboRunner:
"""
Run GNN turbo optimization with optional FEA validation.
Workflow:
1. Load trained GNN model
2. Run fast turbo optimization (5000 trials in ~2 min)
3. Extract Pareto front and top candidates per objective
4. Validate selected candidates with actual FEA
5. Report GNN vs FEA accuracy
"""
def __init__(self, config_path: Path, checkpoint_path: Path):
logger.info("=" * 60)
logger.info("GNN TURBO OPTIMIZER")
logger.info("=" * 60)
# Load config
with open(config_path) as f:
self.config = json.load(f)
# Load GNN optimizer
logger.info(f"Loading GNN from {checkpoint_path}")
self.gnn = ZernikeGNNOptimizer.from_checkpoint(checkpoint_path, config_path)
logger.info(f"GNN loaded. Design variables: {self.gnn.design_names}")
logger.info(f"disp_scale: {self.gnn.disp_scale}")
# Design variable info
self.design_vars = [v for v in self.config['design_variables'] if v.get('enabled', True)]
self.objectives = self.config['objectives']
self.objective_names = [obj['name'] for obj in self.objectives]
# NX Solver for FEA validation
self.nx_solver = None # Lazy init
def _init_nx_solver(self):
"""Initialize NX solver only when needed (for FEA validation)."""
if self.nx_solver is not None:
return
nx_settings = self.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"
self.nx_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="m1_mirror_adaptive_V12_gnn_validation"
)
# Ensure NX is running
ensure_nx_running(nx_install_dir)
def run_turbo(self, n_trials: int = 5000) -> dict:
"""
Run GNN turbo optimization.
Returns dict with:
- all_predictions: List of all predictions
- pareto_front: Pareto-optimal designs
- best_per_objective: Best design for each objective
"""
logger.info(f"\nRunning turbo optimization ({n_trials} trials)...")
start_time = time.time()
results = self.gnn.turbo_optimize(n_trials=n_trials, verbose=True)
elapsed = time.time() - start_time
logger.info(f"Turbo completed in {elapsed:.1f}s ({n_trials/elapsed:.0f} trials/sec)")
# Get Pareto front
pareto = results.get_pareto_front()
logger.info(f"Found {len(pareto)} Pareto-optimal designs")
# Get best per objective
best_per_obj = {}
for obj_name in self.objective_names:
best = results.get_best(n=1, objective=obj_name)[0]
best_per_obj[obj_name] = best
logger.info(f"Best {obj_name}: {best.objectives[obj_name]:.2f} nm")
return {
'results': results,
'pareto': pareto,
'best_per_objective': best_per_obj,
'elapsed_time': elapsed
}
def select_validation_candidates(self, turbo_results: dict, n_validate: int = 5) -> list:
"""
Select diverse candidates for FEA validation.
Strategy: Select from Pareto front with diversity preference.
If Pareto front has fewer than n_validate, add best per objective.
"""
candidates = []
seen_designs = set()
pareto = turbo_results['pareto']
best_per_obj = turbo_results['best_per_objective']
# First, add best per objective (most important to validate)
for obj_name, pred in best_per_obj.items():
design_key = tuple(round(v, 4) for v in pred.design.values())
if design_key not in seen_designs:
candidates.append({
'design': pred.design,
'gnn_objectives': pred.objectives,
'source': f'best_{obj_name}'
})
seen_designs.add(design_key)
if len(candidates) >= n_validate:
break
# Fill remaining slots from Pareto front
if len(candidates) < n_validate and len(pareto) > 0:
# Sort Pareto by sum of objectives (balanced designs)
pareto_sorted = sorted(pareto,
key=lambda p: sum(p.objectives.values()))
for pred in pareto_sorted:
design_key = tuple(round(v, 4) for v in pred.design.values())
if design_key not in seen_designs:
candidates.append({
'design': pred.design,
'gnn_objectives': pred.objectives,
'source': 'pareto_front'
})
seen_designs.add(design_key)
if len(candidates) >= n_validate:
break
logger.info(f"Selected {len(candidates)} candidates for FEA validation:")
for i, c in enumerate(candidates):
logger.info(f" {i+1}. {c['source']}: 40vs20={c['gnn_objectives']['rel_filtered_rms_40_vs_20']:.2f} nm")
return candidates
def run_fea_validation(self, design: dict, trial_num: int) -> dict:
"""
Run FEA for a single design and extract Zernike objectives.
Returns dict with success status and FEA objectives.
"""
self._init_nx_solver()
trial_dir = RESULTS_DIR / f"validation_{trial_num:04d}"
trial_dir.mkdir(exist_ok=True)
logger.info(f"Validation {trial_num}: Running FEA...")
start_time = time.time()
try:
# Build expression updates
expressions = {var['expression_name']: design[var['name']]
for var in self.design_vars}
# Create iteration folder
iter_folder = self.nx_solver.create_iteration_folder(
iterations_base_dir=RESULTS_DIR / "iterations",
iteration_number=trial_num,
expression_updates=expressions
)
# Run solve
op2_path = self.nx_solver.run_solve(
sim_file=iter_folder / self.config['nx_settings']['sim_file'],
solution_name=self.config['nx_settings']['solution_name']
)
if op2_path is None or not Path(op2_path).exists():
logger.error(f"Validation {trial_num}: FEA solve failed - no OP2")
return {'success': False, 'error': 'No OP2 file'}
# Extract Zernike objectives using the same extractor as training
bdf_path = iter_folder / "model.bdf"
if not bdf_path.exists():
bdf_files = list(iter_folder.glob("*.bdf"))
bdf_path = bdf_files[0] if bdf_files else None
# Use extract_surface_zernike to get objectives
zernike_result = extract_surface_zernike(
op2_path=str(op2_path),
bdf_path=str(bdf_path),
n_modes=50,
r_inner=100.0,
r_outer=650.0,
n_radial=50,
n_angular=60
)
if not zernike_result.get('success', False):
logger.error(f"Validation {trial_num}: Zernike extraction failed")
return {'success': False, 'error': zernike_result.get('error', 'Unknown')}
# Compute relative objectives (same as GNN training data)
objectives = self._compute_relative_objectives(zernike_result)
elapsed = time.time() - start_time
logger.info(f"Validation {trial_num}: Completed in {elapsed:.1f}s")
logger.info(f" 40vs20: {objectives['rel_filtered_rms_40_vs_20']:.2f} nm")
logger.info(f" 60vs20: {objectives['rel_filtered_rms_60_vs_20']:.2f} nm")
logger.info(f" mfg90: {objectives['mfg_90_optician_workload']:.2f} nm")
# Save results
results = {
'success': True,
'design': design,
'objectives': objectives,
'op2_path': str(op2_path),
'elapsed_time': elapsed
}
with open(trial_dir / 'fea_result.json', 'w') as f:
json.dump(results, f, indent=2)
return results
except Exception as e:
logger.error(f"Validation {trial_num}: Error - {e}")
import traceback
traceback.print_exc()
return {'success': False, 'error': str(e)}
def _compute_relative_objectives(self, zernike_result: dict) -> dict:
"""
Compute relative Zernike objectives from extraction result.
Matches the exact computation used in GNN training data preparation.
"""
coeffs = zernike_result['data']['coefficients'] # Dict by subcase
# Subcase mapping: 1=90deg, 2=20deg(ref), 3=40deg, 4=60deg
subcases = ['1', '2', '3', '4']
# Convert coefficients to arrays
coeff_arrays = {}
for sc in subcases:
if sc in coeffs:
coeff_arrays[sc] = np.array(coeffs[sc])
# Objective 1: rel_filtered_rms_40_vs_20
# Relative = subcase 3 (40deg) - subcase 2 (20deg ref)
# Filter: remove J1-J4 (first 4 modes)
rel_40_vs_20 = coeff_arrays['3'] - coeff_arrays['2']
rel_40_vs_20_filtered = rel_40_vs_20[4:] # Skip J1-J4
rms_40_vs_20 = np.sqrt(np.sum(rel_40_vs_20_filtered ** 2))
# Objective 2: rel_filtered_rms_60_vs_20
rel_60_vs_20 = coeff_arrays['4'] - coeff_arrays['2']
rel_60_vs_20_filtered = rel_60_vs_20[4:] # Skip J1-J4
rms_60_vs_20 = np.sqrt(np.sum(rel_60_vs_20_filtered ** 2))
# Objective 3: mfg_90_optician_workload (J1-J3 filtered, keep J4 defocus)
rel_90_vs_20 = coeff_arrays['1'] - coeff_arrays['2']
rel_90_vs_20_filtered = rel_90_vs_20[3:] # Skip only J1-J3 (keep J4 defocus)
rms_mfg_90 = np.sqrt(np.sum(rel_90_vs_20_filtered ** 2))
return {
'rel_filtered_rms_40_vs_20': float(rms_40_vs_20),
'rel_filtered_rms_60_vs_20': float(rms_60_vs_20),
'mfg_90_optician_workload': float(rms_mfg_90)
}
def compare_results(self, candidates: list) -> dict:
"""
Compare GNN predictions vs FEA results.
Returns accuracy statistics.
"""
logger.info("\n" + "=" * 60)
logger.info("GNN vs FEA COMPARISON")
logger.info("=" * 60)
errors = {obj: [] for obj in self.objective_names}
for c in candidates:
if 'fea_objectives' not in c or not c.get('fea_success', False):
continue
gnn = c['gnn_objectives']
fea = c['fea_objectives']
logger.info(f"\n{c['source']}:")
logger.info(f" {'Objective':<30} {'GNN':<10} {'FEA':<10} {'Error':<10}")
logger.info(f" {'-'*60}")
for obj in self.objective_names:
gnn_val = gnn[obj]
fea_val = fea[obj]
error_pct = abs(gnn_val - fea_val) / fea_val * 100 if fea_val > 0 else 0
logger.info(f" {obj:<30} {gnn_val:<10.2f} {fea_val:<10.2f} {error_pct:<10.1f}%")
errors[obj].append(error_pct)
# Summary statistics
logger.info("\n" + "-" * 60)
logger.info("SUMMARY STATISTICS")
logger.info("-" * 60)
summary = {}
for obj in self.objective_names:
if errors[obj]:
mean_err = np.mean(errors[obj])
max_err = np.max(errors[obj])
summary[obj] = {'mean_error_pct': mean_err, 'max_error_pct': max_err}
logger.info(f"{obj}: Mean error = {mean_err:.1f}%, Max error = {max_err:.1f}%")
return summary
def run_full_workflow(self, n_trials: int = 5000, n_validate: int = 5, gnn_only: bool = False):
"""
Run complete workflow: GNN turbo → select candidates → FEA validation → comparison.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Phase 1: GNN Turbo
logger.info("\n" + "=" * 60)
logger.info("PHASE 1: GNN TURBO OPTIMIZATION")
logger.info("=" * 60)
turbo_results = self.run_turbo(n_trials=n_trials)
# Save turbo results
turbo_summary = {
'timestamp': timestamp,
'n_trials': n_trials,
'n_pareto': len(turbo_results['pareto']),
'elapsed_time': turbo_results['elapsed_time'],
'best_per_objective': {
obj: {
'design': pred.design,
'objectives': pred.objectives
}
for obj, pred in turbo_results['best_per_objective'].items()
},
'pareto_front': [
{'design': p.design, 'objectives': p.objectives}
for p in turbo_results['pareto'][:20] # Top 20 from Pareto
]
}
turbo_file = RESULTS_DIR / f'turbo_results_{timestamp}.json'
with open(turbo_file, 'w') as f:
json.dump(turbo_summary, f, indent=2)
logger.info(f"Turbo results saved to {turbo_file}")
if gnn_only:
logger.info("\n--gnn-only flag set, skipping FEA validation")
return {'turbo': turbo_summary}
# Phase 2: FEA Validation
logger.info("\n" + "=" * 60)
logger.info("PHASE 2: FEA VALIDATION")
logger.info("=" * 60)
candidates = self.select_validation_candidates(turbo_results, n_validate=n_validate)
for i, candidate in enumerate(candidates):
logger.info(f"\n--- Validating candidate {i+1}/{len(candidates)} ---")
fea_result = self.run_fea_validation(candidate['design'], trial_num=i+1)
candidate['fea_success'] = fea_result.get('success', False)
if fea_result.get('success'):
candidate['fea_objectives'] = fea_result['objectives']
candidate['fea_time'] = fea_result.get('elapsed_time', 0)
# Phase 3: Comparison
logger.info("\n" + "=" * 60)
logger.info("PHASE 3: RESULTS COMPARISON")
logger.info("=" * 60)
comparison = self.compare_results(candidates)
# Save final report
final_report = {
'timestamp': timestamp,
'turbo_summary': turbo_summary,
'validation_candidates': [
{
'source': c['source'],
'design': c['design'],
'gnn_objectives': c['gnn_objectives'],
'fea_objectives': c.get('fea_objectives'),
'fea_success': c.get('fea_success', False),
'fea_time': c.get('fea_time')
}
for c in candidates
],
'accuracy_summary': comparison
}
report_file = RESULTS_DIR / f'gnn_turbo_report_{timestamp}.json'
with open(report_file, 'w') as f:
json.dump(final_report, f, indent=2)
logger.info(f"\nFinal report saved to {report_file}")
# Print final summary
logger.info("\n" + "=" * 60)
logger.info("WORKFLOW COMPLETE")
logger.info("=" * 60)
logger.info(f"GNN Turbo: {n_trials} trials in {turbo_results['elapsed_time']:.1f}s")
logger.info(f"Pareto front: {len(turbo_results['pareto'])} designs")
successful_validations = sum(1 for c in candidates if c.get('fea_success', False))
logger.info(f"FEA Validations: {successful_validations}/{len(candidates)} successful")
if comparison:
avg_errors = [np.mean([comparison[obj]['mean_error_pct'] for obj in comparison])]
logger.info(f"Overall GNN accuracy: {100 - np.mean(avg_errors):.1f}%")
return final_report
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description="GNN Turbo Optimization with FEA Validation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument('--trials', type=int, default=5000,
help='Number of GNN turbo trials (default: 5000)')
parser.add_argument('--validate-top', type=int, default=5,
help='Number of top candidates to validate with FEA (default: 5)')
parser.add_argument('--gnn-only', action='store_true',
help='Run only GNN turbo, skip FEA validation')
parser.add_argument('--checkpoint', type=str, default=str(CHECKPOINT_PATH),
help='Path to GNN checkpoint')
args = parser.parse_args()
logger.info(f"Starting GNN Turbo Optimization")
logger.info(f" Checkpoint: {args.checkpoint}")
logger.info(f" GNN trials: {args.trials}")
logger.info(f" FEA validations: {args.validate_top if not args.gnn_only else 'SKIP'}")
runner = GNNTurboRunner(
config_path=CONFIG_PATH,
checkpoint_path=Path(args.checkpoint)
)
report = runner.run_full_workflow(
n_trials=args.trials,
n_validate=args.validate_top,
gnn_only=args.gnn_only
)
logger.info("\nDone!")
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Validate GNN Best Designs with FEA
===================================
Reads best designs from gnn_turbo_results.json and validates with actual FEA.
Usage:
python validate_gnn_best.py # Full validation (solve + extract)
python validate_gnn_best.py --resume # Resume: skip existing OP2, just extract Zernike
"""
import sys
import json
import argparse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from optimization_engine.gnn.gnn_optimizer import ZernikeGNNOptimizer, GNNPrediction
from optimization_engine.extractors import ZernikeExtractor
# Paths
STUDY_DIR = Path(__file__).parent
RESULTS_FILE = STUDY_DIR / "gnn_turbo_results.json"
CONFIG_PATH = STUDY_DIR / "1_setup" / "optimization_config.json"
CHECKPOINT_PATH = Path("C:/Users/Antoine/Atomizer/zernike_gnn_checkpoint.pt")
def extract_from_existing_op2(study_dir: Path, turbo_results: dict, config: dict) -> list:
"""Extract Zernike from existing OP2 files in iter9000-9002."""
import numpy as np
iterations_dir = study_dir / "2_iterations"
zernike_settings = config.get('zernike_settings', {})
results = []
design_keys = ['best_40_vs_20', 'best_60_vs_20', 'best_mfg_90']
for i, key in enumerate(design_keys):
trial_num = 9000 + i
iter_dir = iterations_dir / f"iter{trial_num}"
print(f"\n[{i+1}/3] Processing {iter_dir.name} ({key})")
# Find OP2 file
op2_files = list(iter_dir.glob("*-solution_1.op2"))
if not op2_files:
print(f" ERROR: No OP2 file found")
results.append({
'design': turbo_results[key]['design_vars'],
'gnn_objectives': turbo_results[key]['objectives'],
'fea_objectives': None,
'status': 'no_op2',
'trial_num': trial_num
})
continue
op2_path = op2_files[0]
size_mb = op2_path.stat().st_size / 1e6
print(f" OP2: {op2_path.name} ({size_mb:.1f} MB)")
if size_mb < 50:
print(f" ERROR: OP2 too small, likely incomplete")
results.append({
'design': turbo_results[key]['design_vars'],
'gnn_objectives': turbo_results[key]['objectives'],
'fea_objectives': None,
'status': 'incomplete_op2',
'trial_num': trial_num
})
continue
# Extract Zernike
try:
extractor = ZernikeExtractor(
str(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'],
}
# Compute errors
gnn_obj = turbo_results[key]['objectives']
errors = {}
for obj_name in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']:
gnn_val = gnn_obj[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)
print(f" FEA: 40vs20={fea_objectives['rel_filtered_rms_40_vs_20']:.2f} nm "
f"(GNN: {gnn_obj['rel_filtered_rms_40_vs_20']:.2f}, 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: {gnn_obj['rel_filtered_rms_60_vs_20']:.2f}, err: {errors['rel_filtered_rms_60_vs_20_pct_error']:.1f}%)")
print(f" mfg90={fea_objectives['mfg_90_optician_workload']:.2f} nm "
f"(GNN: {gnn_obj['mfg_90_optician_workload']:.2f}, err: {errors['mfg_90_optician_workload_pct_error']:.1f}%)")
results.append({
'design': turbo_results[key]['design_vars'],
'gnn_objectives': gnn_obj,
'fea_objectives': fea_objectives,
'errors': errors,
'trial_num': trial_num,
'status': 'success'
})
except Exception as e:
print(f" ERROR extracting Zernike: {e}")
results.append({
'design': turbo_results[key]['design_vars'],
'gnn_objectives': turbo_results[key]['objectives'],
'fea_objectives': None,
'status': 'extraction_error',
'error': str(e),
'trial_num': trial_num
})
return results
def main():
parser = argparse.ArgumentParser(description='Validate GNN predictions with FEA')
parser.add_argument('--resume', action='store_true',
help='Resume: extract Zernike from existing OP2 files instead of re-solving')
args = parser.parse_args()
# Load GNN turbo results
print("Loading GNN turbo results...")
with open(RESULTS_FILE) as f:
turbo_results = json.load(f)
# Load config
with open(CONFIG_PATH) as f:
config = json.load(f)
# Show candidates
candidates = []
for key in ['best_40_vs_20', 'best_60_vs_20', 'best_mfg_90']:
data = turbo_results[key]
pred = GNNPrediction(
design_vars=data['design_vars'],
objectives={k: float(v) for k, v in data['objectives'].items()}
)
candidates.append(pred)
print(f"\n{key}:")
print(f" 40vs20: {pred.objectives['rel_filtered_rms_40_vs_20']:.2f} nm")
print(f" 60vs20: {pred.objectives['rel_filtered_rms_60_vs_20']:.2f} nm")
print(f" mfg90: {pred.objectives['mfg_90_optician_workload']:.2f} nm")
if args.resume:
# Resume mode: extract from existing OP2 files
print("\n" + "="*60)
print("RESUME MODE: Extracting Zernike from existing OP2 files")
print("="*60)
validation_results = extract_from_existing_op2(STUDY_DIR, turbo_results, config)
else:
# Full mode: run FEA + extract
print("\n" + "="*60)
print("LOADING GNN OPTIMIZER FOR FEA VALIDATION")
print("="*60)
optimizer = ZernikeGNNOptimizer.from_checkpoint(CHECKPOINT_PATH, CONFIG_PATH)
print(f"Design variables: {len(optimizer.design_names)}")
print("\n" + "="*60)
print("RUNNING FEA VALIDATION")
print("="*60)
validation_results = optimizer.validate_with_fea(
candidates=candidates,
study_dir=STUDY_DIR,
verbose=True,
start_trial_num=9000
)
# Summary
import numpy as np
successful = [r for r in validation_results if r['status'] == 'success']
print(f"\n{'='*60}")
print(f"VALIDATION SUMMARY")
print(f"{'='*60}")
print(f"Successful: {len(successful)}/{len(validation_results)}")
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 GNN 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}%")
# Save validation report
from datetime import datetime
output_path = STUDY_DIR / "gnn_validation_report.json"
report = {
'timestamp': datetime.now().isoformat(),
'mode': 'resume' if args.resume else 'full',
'n_candidates': len(validation_results),
'n_successful': len(successful),
'results': validation_results,
}
if successful:
report['error_summary'] = {
obj: {
'mean_pct': float(np.mean([r['errors'][f'{obj}_pct_error'] for r in successful])),
'std_pct': float(np.std([r['errors'][f'{obj}_pct_error'] for r in successful])),
'max_pct': float(np.max([r['errors'][f'{obj}_pct_error'] for r in successful])),
}
for obj in ['rel_filtered_rms_40_vs_20', 'rel_filtered_rms_60_vs_20', 'mfg_90_optician_workload']
}
with open(output_path, 'w') as f:
json.dump(report, f, indent=2)
print(f"\nValidation report saved to: {output_path}")
print("\nDone!")
return 0
if __name__ == "__main__":
sys.exit(main())