feat: Add M1 mirror Zernike optimization with correct RMS calculation
Major improvements to telescope mirror optimization workflow: Assembly FEM Workflow (solve_simulation.py): - Fixed multi-part assembly FEM update sequence - Use ImportFromFile() for reliable expression updates - Add DuplicateNodesCheckBuilder with MergeOccurrenceNodes=True - Switch to Foreground solve mode for multi-subcase solutions - Add detailed logging and diagnostics for node merge operations Zernike RMS Calculation: - CRITICAL FIX: Use correct surface-based RMS formula - Global RMS = sqrt(mean(W^2)) from actual WFE values - Filtered RMS = sqrt(mean(W_residual^2)) after removing low-order fit - This matches zernike_Post_Script_NX.py (optical standard) - Previous WRONG formula was: sqrt(sum(coeffs^2)) - Add compute_rms_filter_j1to3() for optician workload metric Subcase Mapping: - Fix subcase mapping to match NX model: - Subcase 1 = 90 deg (polishing orientation) - Subcase 2 = 20 deg (reference) - Subcase 3 = 40 deg - Subcase 4 = 60 deg New Study: M1 Mirror Zernike Optimization - Full optimization config with 11 design variables - 3 objectives: rel_filtered_rms_40_vs_20, rel_filtered_rms_60_vs_20, mfg_90_optician_workload - Neural surrogate support for accelerated optimization Documentation: - Update ZERNIKE_INTEGRATION.md with correct RMS formula - Update ASSEMBLY_FEM_WORKFLOW.md with expression import and node merge details - Add reference scripts from original zernike_Post_Script_NX.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
188
docs/06_PROTOCOLS_DETAILED/ASSEMBLY_FEM_WORKFLOW.md
Normal file
188
docs/06_PROTOCOLS_DETAILED/ASSEMBLY_FEM_WORKFLOW.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Assembly FEM Optimization Workflow
|
||||
|
||||
This document describes the multi-part assembly FEM workflow used when optimizing complex assemblies with `.afm` (Assembly FEM) files.
|
||||
|
||||
## Overview
|
||||
|
||||
Assembly FEMs have a more complex dependency chain than single-part simulations:
|
||||
|
||||
```
|
||||
.prt (geometry) → _fem1.fem (component mesh) → .afm (assembly mesh) → .sim (solution)
|
||||
```
|
||||
|
||||
Each level must be updated in sequence when design parameters change.
|
||||
|
||||
## When This Workflow Applies
|
||||
|
||||
This workflow is automatically triggered when:
|
||||
- The working directory contains `.afm` files
|
||||
- Multiple `.fem` files exist (component meshes)
|
||||
- Multiple `.prt` files exist (component geometry)
|
||||
|
||||
Examples:
|
||||
- M1 Mirror assembly (M1_Blank + M1_Vertical_Support_Skeleton)
|
||||
- Multi-component mechanical assemblies
|
||||
- Any NX assembly where components have separate FEM files
|
||||
|
||||
## The 4-Step Workflow
|
||||
|
||||
### Step 1: Update Expressions in Geometry Part (.prt)
|
||||
|
||||
```
|
||||
Open M1_Blank.prt
|
||||
├── Find and update design expressions
|
||||
│ ├── whiffle_min = 42.5
|
||||
│ ├── whiffle_outer_to_vertical = 75.0
|
||||
│ └── inner_circular_rib_dia = 550.0
|
||||
├── Rebuild geometry (DoUpdate)
|
||||
└── Save part
|
||||
```
|
||||
|
||||
The `.prt` file contains the parametric CAD model with expressions that drive dimensions. These expressions are updated with new design parameter values, then the geometry is rebuilt.
|
||||
|
||||
### Step 2: Update Component FEM Files (.fem)
|
||||
|
||||
```
|
||||
For each component FEM:
|
||||
├── Open M1_Blank_fem1.fem
|
||||
│ ├── UpdateFemodel() - regenerates mesh from updated geometry
|
||||
│ └── Save FEM
|
||||
├── Open M1_Vertical_Support_Skeleton_fem1.fem
|
||||
│ ├── UpdateFemodel()
|
||||
│ └── Save FEM
|
||||
└── ... (repeat for all component FEMs)
|
||||
```
|
||||
|
||||
Each component FEM is linked to its source geometry. `UpdateFemodel()` regenerates the mesh based on the updated geometry.
|
||||
|
||||
### Step 3: Update Assembly FEM (.afm)
|
||||
|
||||
```
|
||||
Open ASSY_M1_assyfem1.afm
|
||||
├── UpdateFemodel() - updates assembly mesh
|
||||
├── Merge coincident nodes (at component interfaces)
|
||||
├── Resolve labeling conflicts (duplicate node/element IDs)
|
||||
└── Save AFM
|
||||
```
|
||||
|
||||
The assembly FEM combines component meshes. This step:
|
||||
- Reconnects meshes at shared interfaces
|
||||
- Resolves numbering conflicts between component meshes
|
||||
- Ensures mesh continuity for accurate analysis
|
||||
|
||||
### Step 4: Solve Simulation (.sim)
|
||||
|
||||
```
|
||||
Open ASSY_M1_assyfem1_sim1.sim
|
||||
├── Execute solve
|
||||
│ ├── Foreground mode for all solutions
|
||||
│ └── or Background mode for specific solution
|
||||
└── Save simulation
|
||||
```
|
||||
|
||||
The simulation file references the assembly FEM and contains solution setup (loads, constraints, subcases).
|
||||
|
||||
## File Dependencies
|
||||
|
||||
```
|
||||
M1 Mirror Example:
|
||||
|
||||
M1_Blank.prt ─────────────────────> M1_Blank_fem1.fem ─────────┐
|
||||
│ │ │
|
||||
│ (expressions) │ (component mesh) │
|
||||
↓ ↓ │
|
||||
M1_Vertical_Support_Skeleton.prt ──> M1_..._Skeleton_fem1.fem ─┤
|
||||
│
|
||||
↓
|
||||
ASSY_M1_assyfem1.afm ──> ASSY_M1_assyfem1_sim1.sim
|
||||
(assembly mesh) (solution)
|
||||
```
|
||||
|
||||
## API Functions Used
|
||||
|
||||
| Step | NX API Call | Purpose |
|
||||
|------|-------------|---------|
|
||||
| 1 | `OpenBase()` | Open .prt file |
|
||||
| 1 | `ImportFromFile()` | Import expressions from .exp file (preferred) |
|
||||
| 1 | `DoUpdate()` | Rebuild geometry |
|
||||
| 2-3 | `UpdateFemodel()` | Regenerate mesh from geometry |
|
||||
| 3 | `DuplicateNodesCheckBuilder` | Merge coincident nodes at interfaces |
|
||||
| 3 | `MergeOccurrenceNodes = True` | Critical: enables cross-component merge |
|
||||
| 4 | `SolveAllSolutions()` | Execute FEA (Foreground mode recommended)
|
||||
|
||||
### Expression Update Method
|
||||
|
||||
The recommended approach uses expression file import:
|
||||
|
||||
```python
|
||||
# Write expressions to .exp file
|
||||
with open(exp_path, 'w') as f:
|
||||
for name, value in expressions.items():
|
||||
unit = get_unit_for_expression(name)
|
||||
f.write(f"[{unit}]{name}={value}\n")
|
||||
|
||||
# Import into part
|
||||
modified, errors = workPart.Expressions.ImportFromFile(
|
||||
exp_path,
|
||||
NXOpen.ExpressionCollection.ImportMode.Replace
|
||||
)
|
||||
```
|
||||
|
||||
This is more reliable than `EditExpressionWithUnits()` for batch updates.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
### "Update undo happened"
|
||||
- Geometry update failed due to constraint violations
|
||||
- Check expression values are within valid ranges
|
||||
- May need to adjust parameter bounds
|
||||
|
||||
### "This operation can only be done on the work part"
|
||||
- Work part not properly set before operation
|
||||
- Use `SetWork()` to make target part the work part
|
||||
|
||||
### Node merge warnings
|
||||
- Manual intervention may be needed for complex interfaces
|
||||
- Check mesh connectivity in NX after solve
|
||||
|
||||
### "Billion nm" RMS values
|
||||
- Indicates node merging failed - coincident nodes not properly merged
|
||||
- Check `MergeOccurrenceNodes = True` is set
|
||||
- Verify tolerance (0.01 mm recommended)
|
||||
- Run node merge after every FEM update, not just once
|
||||
|
||||
## Configuration
|
||||
|
||||
The workflow auto-detects assembly FEMs, but you can configure behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"nx_settings": {
|
||||
"expression_part": "M1_Blank", // Override auto-detection
|
||||
"component_fems": [ // Explicit list of FEMs to update
|
||||
"M1_Blank_fem1.fem",
|
||||
"M1_Vertical_Support_Skeleton_fem1.fem"
|
||||
],
|
||||
"afm_file": "ASSY_M1_assyfem1.afm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Reference
|
||||
|
||||
See `optimization_engine/solve_simulation.py` for the full implementation:
|
||||
|
||||
- `detect_assembly_fem()` - Detects if assembly workflow needed
|
||||
- `update_expressions_in_part()` - Step 1 implementation
|
||||
- `update_fem_part()` - Step 2 implementation
|
||||
- `update_assembly_fem()` - Step 3 implementation
|
||||
- `solve_simulation_file()` - Step 4 implementation
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Start with baseline solve**: Before optimization, manually verify the full workflow completes in NX
|
||||
2. **Check mesh quality**: Poor mesh quality after updates can cause solve failures
|
||||
3. **Monitor memory**: Assembly FEMs with many components use significant memory
|
||||
4. **Use Foreground mode**: For multi-subcase solutions, Foreground mode ensures all subcases complete
|
||||
313
docs/ZERNIKE_INTEGRATION.md
Normal file
313
docs/ZERNIKE_INTEGRATION.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Zernike Wavefront Analysis Integration
|
||||
|
||||
This document describes how to use Atomizer's Zernike analysis capabilities for telescope mirror optimization.
|
||||
|
||||
## Overview
|
||||
|
||||
Atomizer includes a full Zernike polynomial decomposition system for analyzing wavefront errors (WFE) in telescope mirror FEA simulations. The system:
|
||||
|
||||
- Extracts nodal displacements from NX Nastran OP2 files
|
||||
- Fits Zernike polynomials using Noll indexing (optical standard)
|
||||
- Computes RMS metrics (global and filtered)
|
||||
- Analyzes individual aberrations (astigmatism, coma, trefoil, etc.)
|
||||
- Supports multi-subcase analysis (different gravity orientations)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Simple Extraction
|
||||
|
||||
```python
|
||||
from optimization_engine.extractors import extract_zernike_from_op2
|
||||
|
||||
# Extract Zernike metrics for a single subcase
|
||||
result = extract_zernike_from_op2(
|
||||
op2_file="model-solution_1.op2",
|
||||
subcase="20" # 20 degree elevation
|
||||
)
|
||||
|
||||
print(f"Global RMS: {result['global_rms_nm']:.2f} nm")
|
||||
print(f"Filtered RMS: {result['filtered_rms_nm']:.2f} nm")
|
||||
print(f"Astigmatism: {result['astigmatism_rms_nm']:.2f} nm")
|
||||
```
|
||||
|
||||
### In Optimization Objective
|
||||
|
||||
```python
|
||||
from optimization_engine.extractors.zernike_helpers import create_zernike_objective
|
||||
|
||||
# Create objective function
|
||||
zernike_obj = create_zernike_objective(
|
||||
op2_finder=lambda: sim_dir / "model-solution_1.op2",
|
||||
subcase="20",
|
||||
metric="filtered_rms_nm"
|
||||
)
|
||||
|
||||
# Use in Optuna trial
|
||||
def objective(trial):
|
||||
# ... suggest parameters ...
|
||||
# ... run simulation ...
|
||||
|
||||
rms = zernike_obj()
|
||||
return rms
|
||||
```
|
||||
|
||||
## RMS Calculation Method
|
||||
|
||||
**IMPORTANT**: Atomizer uses the correct surface-based RMS calculation matching optical standards:
|
||||
|
||||
```python
|
||||
# Global RMS = sqrt(mean(W^2)) - RMS of actual WFE surface values
|
||||
global_rms = sqrt(mean(W_nm ** 2))
|
||||
|
||||
# Filtered RMS = sqrt(mean(W_residual^2))
|
||||
# where W_residual = W_nm - Z[:, :4] @ coeffs[:4] (low-order fit subtracted)
|
||||
filtered_rms = sqrt(mean(W_residual ** 2))
|
||||
```
|
||||
|
||||
This is **different** from summing Zernike coefficients! The RMS is computed from the actual WFE surface values, not from `sqrt(sum(coeffs^2))`.
|
||||
|
||||
## Available Metrics
|
||||
|
||||
### RMS Metrics
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| `global_rms_nm` | RMS of entire WFE surface: `sqrt(mean(W^2))` |
|
||||
| `filtered_rms_nm` | RMS after removing modes 1-4 (piston, tip, tilt, defocus) |
|
||||
| `rms_filter_j1to3_nm` | RMS after removing only modes 1-3 (keeps defocus) - "optician workload" |
|
||||
|
||||
### Aberration Magnitudes
|
||||
| Metric | Zernike Modes | Description |
|
||||
|--------|--------------|-------------|
|
||||
| `defocus_nm` | J4 | Focus error |
|
||||
| `astigmatism_rms_nm` | J5 + J6 | Combined astigmatism |
|
||||
| `coma_rms_nm` | J7 + J8 | Combined coma |
|
||||
| `trefoil_rms_nm` | J9 + J10 | Combined trefoil |
|
||||
| `spherical_nm` | J11 | Primary spherical |
|
||||
|
||||
## Multi-Subcase Analysis
|
||||
|
||||
For telescope mirrors, gravity orientation affects surface shape. Standard subcases:
|
||||
|
||||
| Subcase | Description |
|
||||
|---------|-------------|
|
||||
| 20 | Low elevation (operational) |
|
||||
| 40 | Mid-low elevation |
|
||||
| 60 | Mid-high elevation |
|
||||
| 90 | Horizontal (polishing orientation) |
|
||||
|
||||
### Extract All Subcases
|
||||
|
||||
```python
|
||||
from optimization_engine.extractors import ZernikeExtractor
|
||||
|
||||
extractor = ZernikeExtractor("model.op2")
|
||||
results = extractor.extract_all_subcases(reference_subcase="20")
|
||||
|
||||
for label, metrics in results.items():
|
||||
print(f"Subcase {label}: {metrics['filtered_rms_nm']:.1f} nm")
|
||||
```
|
||||
|
||||
### Relative Analysis
|
||||
|
||||
Compare deformation between orientations:
|
||||
|
||||
```python
|
||||
from optimization_engine.extractors.zernike_helpers import create_relative_zernike_objective
|
||||
|
||||
# Minimize deformation at 20 deg relative to polishing position (90 deg)
|
||||
relative_obj = create_relative_zernike_objective(
|
||||
op2_finder=lambda: sim_dir / "model.op2",
|
||||
target_subcase="20",
|
||||
reference_subcase="90"
|
||||
)
|
||||
|
||||
relative_rms = relative_obj()
|
||||
```
|
||||
|
||||
## Optimization Configuration
|
||||
|
||||
### Example: Single Objective (Filtered RMS)
|
||||
|
||||
```json
|
||||
{
|
||||
"objectives": [
|
||||
{
|
||||
"name": "filtered_rms",
|
||||
"direction": "minimize",
|
||||
"extractor": "zernike",
|
||||
"extractor_config": {
|
||||
"subcase": "20",
|
||||
"metric": "filtered_rms_nm"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Multi-Objective (RMS + Mass)
|
||||
|
||||
```json
|
||||
{
|
||||
"objectives": [
|
||||
{
|
||||
"name": "filtered_rms_20deg",
|
||||
"direction": "minimize",
|
||||
"extractor": "zernike",
|
||||
"extractor_config": {
|
||||
"subcase": "20",
|
||||
"metric": "filtered_rms_nm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mass",
|
||||
"direction": "minimize",
|
||||
"extractor": "mass_from_expression"
|
||||
}
|
||||
],
|
||||
"optimization_settings": {
|
||||
"sampler": "NSGA-II",
|
||||
"protocol": 11
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Constrained (Stress + Aberration Limits)
|
||||
|
||||
```json
|
||||
{
|
||||
"constraints": [
|
||||
{
|
||||
"name": "astigmatism_limit",
|
||||
"type": "upper_bound",
|
||||
"threshold": 50.0,
|
||||
"extractor": "zernike",
|
||||
"extractor_config": {
|
||||
"subcase": "90",
|
||||
"metric": "astigmatism_rms_nm"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced: ZernikeObjectiveBuilder
|
||||
|
||||
For complex multi-subcase objectives:
|
||||
|
||||
```python
|
||||
from optimization_engine.extractors.zernike_helpers import ZernikeObjectiveBuilder
|
||||
|
||||
builder = ZernikeObjectiveBuilder(
|
||||
op2_finder=lambda: sim_dir / "model.op2"
|
||||
)
|
||||
|
||||
# Weight operational positions more heavily
|
||||
builder.add_subcase_objective("20", "filtered_rms_nm", weight=1.0)
|
||||
builder.add_subcase_objective("40", "filtered_rms_nm", weight=0.5)
|
||||
builder.add_subcase_objective("60", "filtered_rms_nm", weight=0.5)
|
||||
|
||||
# Create combined objective (weighted sum)
|
||||
objective = builder.build_weighted_sum()
|
||||
|
||||
# Or: worst-case across subcases
|
||||
worst_case_obj = builder.build_max()
|
||||
```
|
||||
|
||||
## Zernike Settings
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `n_modes` | 50 | Number of Zernike modes to fit |
|
||||
| `filter_orders` | 4 | Low-order modes to filter (1-4 = piston through defocus) |
|
||||
| `displacement_unit` | "mm" | Unit of displacement in OP2 ("mm", "m", "um", "nm") |
|
||||
|
||||
### Unit Conversions
|
||||
|
||||
Wavefront error (WFE) is computed as:
|
||||
|
||||
```
|
||||
WFE_nm = 2 * displacement * unit_conversion
|
||||
```
|
||||
|
||||
Where `unit_conversion` converts to nanometers:
|
||||
- mm: 1e6
|
||||
- m: 1e9
|
||||
- um: 1e3
|
||||
|
||||
The factor of 2 accounts for the optical convention (surface error doubles as wavefront error for reflection).
|
||||
|
||||
## NX Nastran Setup
|
||||
|
||||
### Required Subcases
|
||||
|
||||
Your NX Nastran model should have subcases for each gravity orientation:
|
||||
|
||||
```
|
||||
SUBCASE 20
|
||||
SUBTITLE=20 deg elevation
|
||||
LOAD = ...
|
||||
|
||||
SUBCASE 40
|
||||
SUBTITLE=40 deg elevation
|
||||
LOAD = ...
|
||||
```
|
||||
|
||||
The extractor identifies subcases by:
|
||||
1. Numeric value in SUBTITLE (preferred)
|
||||
2. SUBCASE ID number
|
||||
|
||||
### Output Requests
|
||||
|
||||
Ensure displacement output is requested:
|
||||
|
||||
```
|
||||
SET 999 = ALL
|
||||
DISPLACEMENT(SORT1,REAL) = 999
|
||||
```
|
||||
|
||||
## Migration from Legacy Scripts
|
||||
|
||||
If you were using `zernike_Post_Script_NX.py`:
|
||||
|
||||
| Old Approach | Atomizer Equivalent |
|
||||
|--------------|---------------------|
|
||||
| Manual OP2 parsing | `ZernikeExtractor` |
|
||||
| `compute_zernike_coeffs_chunked()` | `compute_zernike_coefficients()` |
|
||||
| `write_exp_file()` | Configure as objective/constraint |
|
||||
| HTML reports | Dashboard visualization (TBD) |
|
||||
| RMS log CSV | Optuna database + export |
|
||||
|
||||
### Key Differences
|
||||
|
||||
1. **Integration**: Zernike is now an extractor like displacement/stress
|
||||
2. **Optimization**: Direct use as objectives/constraints in Optuna
|
||||
3. **Multi-objective**: Native NSGA-II support for RMS + mass Pareto optimization
|
||||
4. **Neural Acceleration**: Can train surrogate on Zernike metrics (Protocol 12)
|
||||
|
||||
## Example Study Structure
|
||||
|
||||
```
|
||||
studies/
|
||||
mirror_optimization/
|
||||
1_setup/
|
||||
optimization_config.json
|
||||
model/
|
||||
ASSY_M1.prt
|
||||
ASSY_M1_assyfem1.afm
|
||||
ASSY_M1_assyfem1_sim1.sim
|
||||
2_results/
|
||||
study.db
|
||||
zernike_analysis/
|
||||
trial_001_zernike.json
|
||||
trial_002_zernike.json
|
||||
...
|
||||
run_optimization.py
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [examples/optimization_config_zernike_mirror.json](../examples/optimization_config_zernike_mirror.json) - Full example configuration
|
||||
- [optimization_engine/extractors/extract_zernike.py](../optimization_engine/extractors/extract_zernike.py) - Core implementation
|
||||
- [optimization_engine/extractors/zernike_helpers.py](../optimization_engine/extractors/zernike_helpers.py) - Helper functions
|
||||
Reference in New Issue
Block a user