fix(canvas): Multiple fixes for drag-drop, undo/redo, and code generation

Drag-drop fixes:
- Fix Objective default data: use nested 'source' object with extractor_id/output_name
- Fix Constraint default data: use 'type' field (not constraint_type), 'threshold' (not limit)

Undo/Redo fixes:
- Remove dependency on isDirty flag (which is always false due to auto-save)
- Record snapshots based on actual spec changes via deep comparison

Code generation improvements:
- Update system prompt to support multiple extractor types:
  * OP2-based extractors for FEA results (stress, displacement, frequency)
  * Expression-based extractors for NX model values (dimensions, volumes)
  * Computed extractors for derived values (no FEA needed)
- Claude will now choose appropriate signature based on user's description
This commit is contained in:
2026-01-20 15:08:49 -05:00
parent 89694088a2
commit 5c419e2358
30 changed files with 1781 additions and 85 deletions

View File

@@ -0,0 +1,195 @@
{
"part_file": "ASSY_M1.prt",
"part_path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
"success": true,
"error": null,
"expressions": {
"user": [
{
"name": "p7_CircularPattern_pattern_Circular_Dir_count",
"value": 3.0,
"rhs": "3",
"units": null,
"type": "Number"
},
{
"name": "p8_CircularPattern_pattern_Circular_Dir_offset_angle",
"value": 120.0,
"rhs": "120",
"units": "Degrees",
"type": "Number"
},
{
"name": "p66_x",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p68_z",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p67_y",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
}
],
"internal": [
{
"name": "p14",
"value": 0.0,
"rhs": "0",
"units": "Degrees",
"type": "Number"
},
{
"name": "p64",
"value": 120.0,
"rhs": "120",
"units": "Degrees",
"type": "Number"
},
{
"name": "p9",
"value": 10.0,
"rhs": "10",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p10",
"value": 240.0,
"rhs": "240",
"units": "Degrees",
"type": "Number"
},
{
"name": "p11",
"value": 1.0,
"rhs": "1",
"units": null,
"type": "Number"
},
{
"name": "p12",
"value": 10.0,
"rhs": "10",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p13",
"value": 0.0,
"rhs": "0",
"units": "MilliMeter",
"type": "Number"
}
],
"total_count": 12,
"user_count": 5
},
"mass_properties": {
"mass_kg": 0.0,
"mass_g": 0.0,
"volume_mm3": 0.0,
"surface_area_mm2": 0.0,
"center_of_gravity_mm": [
0.0,
0.0,
0.0
],
"num_bodies": 0,
"success": false
},
"materials": {
"assigned": [],
"available": [],
"library": [],
"pmm_error": "'NXOpen.Part' object has no attribute 'PhysicalMaterialManager'"
},
"bodies": {
"solid_bodies": [],
"sheet_bodies": [],
"counts": {
"solid": 0,
"sheet": 0,
"total": 0
}
},
"attributes": [
{
"title": "NX_Arrangement",
"type": "5",
"value": "Arrangement 1"
},
{
"title": "NX_ComponentGroup",
"type": "5",
"value": "AllComponents"
},
{
"title": "NX_ReferenceSet",
"type": "5",
"value": "Empty"
},
{
"title": "NX_MaterialMissingAssignments",
"type": "5",
"value": "TRUE"
},
{
"title": "NX_MaterialMultipleAssigned",
"type": "5",
"value": "FALSE"
}
],
"groups": [],
"features": {
"total_count": 0,
"by_type": {},
"first_10": []
},
"datums": {
"planes": [],
"csys": [],
"axes": []
},
"units": {
"base_units": {
"Length": "MilliMeter",
"Mass": "Kilogram",
"Time": "Second",
"Temperature": "Kelvin",
"Angle": "Radian",
"Area": "SquareMilliMeter",
"Volume": "CubicMilliMeter",
"Force": "MilliNewton",
"Pressure": "PressureMilliNewtonPerSquareMilliMeter"
},
"system": "Metric (mm)"
},
"linked_parts": {
"loaded_parts": [
{
"name": "M1_Blank",
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\M1_Blank.prt",
"leaf_name": "M1_Blank"
},
{
"name": "ASSY_M1",
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
"leaf_name": "ASSY_M1"
}
],
"fem_parts": [],
"sim_parts": [],
"idealized_parts": []
}
}

View File

@@ -0,0 +1,195 @@
{
"part_file": "ASSY_M1.prt",
"part_path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
"success": true,
"error": null,
"expressions": {
"user": [
{
"name": "p7_CircularPattern_pattern_Circular_Dir_count",
"value": 3.0,
"rhs": "3",
"units": null,
"type": "Number"
},
{
"name": "p8_CircularPattern_pattern_Circular_Dir_offset_angle",
"value": 120.0,
"rhs": "120",
"units": "Degrees",
"type": "Number"
},
{
"name": "p66_x",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p68_z",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p67_y",
"value": 0.0,
"rhs": "0.00000000000",
"units": "MilliMeter",
"type": "Number"
}
],
"internal": [
{
"name": "p14",
"value": 0.0,
"rhs": "0",
"units": "Degrees",
"type": "Number"
},
{
"name": "p64",
"value": 120.0,
"rhs": "120",
"units": "Degrees",
"type": "Number"
},
{
"name": "p9",
"value": 10.0,
"rhs": "10",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p10",
"value": 240.0,
"rhs": "240",
"units": "Degrees",
"type": "Number"
},
{
"name": "p11",
"value": 1.0,
"rhs": "1",
"units": null,
"type": "Number"
},
{
"name": "p12",
"value": 10.0,
"rhs": "10",
"units": "MilliMeter",
"type": "Number"
},
{
"name": "p13",
"value": 0.0,
"rhs": "0",
"units": "MilliMeter",
"type": "Number"
}
],
"total_count": 12,
"user_count": 5
},
"mass_properties": {
"mass_kg": 0.0,
"mass_g": 0.0,
"volume_mm3": 0.0,
"surface_area_mm2": 0.0,
"center_of_gravity_mm": [
0.0,
0.0,
0.0
],
"num_bodies": 0,
"success": false
},
"materials": {
"assigned": [],
"available": [],
"library": [],
"pmm_error": "'NXOpen.Part' object has no attribute 'PhysicalMaterialManager'"
},
"bodies": {
"solid_bodies": [],
"sheet_bodies": [],
"counts": {
"solid": 0,
"sheet": 0,
"total": 0
}
},
"attributes": [
{
"title": "NX_Arrangement",
"type": "5",
"value": "Arrangement 1"
},
{
"title": "NX_ComponentGroup",
"type": "5",
"value": "AllComponents"
},
{
"title": "NX_ReferenceSet",
"type": "5",
"value": "Empty"
},
{
"title": "NX_MaterialMissingAssignments",
"type": "5",
"value": "TRUE"
},
{
"title": "NX_MaterialMultipleAssigned",
"type": "5",
"value": "FALSE"
}
],
"groups": [],
"features": {
"total_count": 0,
"by_type": {},
"first_10": []
},
"datums": {
"planes": [],
"csys": [],
"axes": []
},
"units": {
"base_units": {
"Length": "MilliMeter",
"Mass": "Kilogram",
"Time": "Second",
"Temperature": "Kelvin",
"Angle": "Radian",
"Area": "SquareMilliMeter",
"Volume": "CubicMilliMeter",
"Force": "MilliNewton",
"Pressure": "PressureMilliNewtonPerSquareMilliMeter"
},
"system": "Metric (mm)"
},
"linked_parts": {
"loaded_parts": [
{
"name": "M1_Blank",
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\M1_Blank.prt",
"leaf_name": "M1_Blank"
},
{
"name": "ASSY_M1",
"path": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\1_setup\\model\\ASSY_M1.prt",
"leaf_name": "ASSY_M1"
}
],
"fem_parts": [],
"sim_parts": [],
"idealized_parts": []
}
}

View File

@@ -0,0 +1,58 @@
{
"blank_backface_angle": 4.0,
"lateral_inner_angle": 31.93,
"whiffle_p1": 390.0,
"whiffle_p2": 135.0,
"whiffle_p3": 80.0,
"mirror_face_thickness": 15.0,
"blank_mass": 66.7514321518352,
"lateral_second_row_angle": 10.0,
"p1049_x": 0.0,
"blank_backface_max_radius": 582.0,
"p1051_z": 0.0,
"rib_thickness_lataral": 10.0,
"hole_count": 10.0,
"Pocket_Radius": 10.05,
"lateral_closeness": 7.89,
"offset_whiffle": 35.0,
"inner_circular_rib_dia": 537.86,
"whiffle_min": 56.65,
"Pattern_p5610": 60.0,
"whiffle_triangle_closeness": 69.24,
"beam_face_thickness": 20.0,
"offset_lateral_support_contact": 5.0,
"Pattern_p2656": 360.0,
"rib_thickness_lateral_truss": 12.06,
"whiffle_max": 8.0,
"outer_post_distance": 551.7504884773748,
"Pattern_p2653": 3.0,
"Pattern_p2654": 120.0,
"Pattern_p2883": 3.0,
"Pattern_p2884": 120.0,
"Pattern_p2886": 360.0,
"ribs_circular_thk": 6.81,
"support_cone_angle": 0.0,
"rib_thickness": 8.07,
"rib_lin_1": 60.0,
"rib_lin_2": 80.0,
"rib_lin_4": 80.0,
"in_between_u": 0.5,
"beam_half_core_thickness": 20.0,
"lateral_middle_pivot": 21.07,
"Pattern_p5609": 6.0,
"lateral_inner_u": 0.3,
"center_thickness": 85.0,
"rib_pocket_bottom_radius": 10.0,
"lateral_outer_pivot": 8.615999999999998,
"Pattern_p5612": 360.0,
"Pattern_p3829": 3.0,
"Pattern_p3830": 120.0,
"Pattern_p3832": 360.0,
"p1050_y": 0.0,
"holes_diameter": 400.0,
"lateral_outer_angle": 10.77,
"vertical_support_diameter_seat_offset": 10.0,
"lateral_outer_u": 0.8,
"vertical_support_seat_depth": 20.0,
"lateral_inner_pivot": 9.578999999999997
}

View File

@@ -0,0 +1,131 @@
{
"$schema": "Atomizer M1 Mirror Cost Reduction - Lateral Supports Optimization",
"study_name": "m1_mirror_cost_reduction_lateral",
"study_tag": "CMA-ES-100",
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u) for cost reduction model. Focus on WFE and MFG only - no mass objective.",
"business_context": {
"purpose": "Optimize lateral support geometry using new U-joint parameterization on cost reduction model",
"benefit": "Improved lateral support performance with cleaner parameterization",
"goal": "Minimize WFE at 40/60 deg and MFG at 90 deg"
},
"optimization": {
"algorithm": "CMA-ES",
"n_trials": 100,
"n_startup_trials": 0,
"sigma0": 0.3,
"notes": "CMA-ES is optimal for 5D continuous optimization - fast convergence, robust"
},
"extraction_method": {
"type": "zernike_opd",
"class": "ZernikeOPDExtractor",
"method": "extract_relative",
"inner_radius": 135.75,
"description": "OPD-based Zernike with ANNULAR aperture (271.5mm central hole excluded)"
},
"design_variables": [
{
"name": "lateral_inner_u",
"expression_name": "lateral_inner_u",
"min": 0.2,
"max": 0.95,
"baseline": 0.3,
"units": "unitless",
"enabled": true,
"notes": "U-joint ratio for inner lateral support (replaces lateral_inner_pivot)"
},
{
"name": "lateral_outer_u",
"expression_name": "lateral_outer_u",
"min": 0.2,
"max": 0.95,
"baseline": 0.8,
"units": "unitless",
"enabled": true,
"notes": "U-joint ratio for outer lateral support (replaces lateral_outer_pivot)"
},
{
"name": "lateral_middle_pivot",
"expression_name": "lateral_middle_pivot",
"min": 15.0,
"max": 27.0,
"baseline": 21.07,
"units": "mm",
"enabled": true,
"notes": "Middle pivot position on lateral support"
},
{
"name": "lateral_inner_angle",
"expression_name": "lateral_inner_angle",
"min": 25.0,
"max": 35.0,
"baseline": 31.93,
"units": "degrees",
"enabled": true,
"notes": "Inner lateral support angle"
},
{
"name": "lateral_outer_angle",
"expression_name": "lateral_outer_angle",
"min": 8.0,
"max": 17.0,
"baseline": 10.77,
"units": "degrees",
"enabled": true,
"notes": "Outer lateral support angle"
}
],
"fixed_parameters": [],
"constraints": [
{
"name": "blank_mass_max",
"type": "hard",
"expression": "mass_kg <= 120.0",
"description": "Maximum blank mass constraint (still enforced even though mass not optimized)",
"penalty_weight": 1000.0
}
],
"objectives": [
{
"name": "wfe_40_20",
"description": "Filtered RMS WFE at 40 deg relative to 20 deg (annular)",
"direction": "minimize",
"weight": 6.0,
"target": 4.0,
"units": "nm"
},
{
"name": "wfe_60_20",
"description": "Filtered RMS WFE at 60 deg relative to 20 deg (annular)",
"direction": "minimize",
"weight": 5.0,
"target": 10.0,
"units": "nm"
},
{
"name": "mfg_90",
"description": "Manufacturing deformation at 90 deg polishing (J1-J3 filtered, annular)",
"direction": "minimize",
"weight": 3.0,
"target": 20.0,
"units": "nm"
}
],
"weighted_sum_formula": "6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90",
"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",
"method": "opd",
"inner_radius": 135.75
},
"nx_settings": {
"nx_install_path": "C:\\Program Files\\Siemens\\DesigncenterNX2512",
"sim_file": "ASSY_M1_assyfem1_sim1.sim",
"solution_name": "Solution 1",
"op2_pattern": "*-solution_1.op2",
"simulation_timeout_s": 600
}
}

View File

@@ -0,0 +1,28 @@
{
"study_name": "m1_mirror_cost_reduction_lateral",
"algorithm": "CMA-ES",
"extraction_method": "ZernikeOPD_Annular",
"inner_radius_mm": 135.75,
"objectives_note": "Mass NOT in objective - WFE only",
"total_trials": 169,
"feasible_trials": 167,
"best_trial": {
"number": 163,
"weighted_sum": 181.1220151783071,
"objectives": {
"wfe_40_20": 5.901179945313834,
"wfe_60_20": 13.198682506114679,
"mfg_90": 26.573840991950224,
"mass_kg": 96.75011491846891
},
"params": {
"lateral_inner_u": 0.32248417341983515,
"lateral_outer_u": 0.9038210727913156,
"lateral_middle_pivot": 21.25398896032501,
"lateral_inner_angle": 30.182447933329243,
"lateral_outer_angle": 15.08932828662093
},
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_cost_reduction_lateral\\2_iterations\\iter164"
},
"timestamp": "2026-01-14T17:59:38.649254"
}

View File

@@ -0,0 +1,136 @@
# M1 Mirror Cost Reduction - Lateral Supports Optimization
> See [../README.md](../README.md) for project overview and optical specifications.
## Study Overview
| Field | Value |
|-------|-------|
| **Study Name** | m1_mirror_cost_reduction_lateral |
| **Algorithm** | CMA-ES |
| **Status** | Ready to run |
| **Created** | 2026-01-13 |
| **Trials** | 100 planned |
| **Focus** | Lateral support geometry only |
## Purpose
Optimize **lateral support parameters only** for the COST REDUCTION model using the new U-joint parameterization:
- `lateral_inner_u` and `lateral_outer_u` replace the old `lateral_inner_pivot` and `lateral_outer_pivot`
- All other parameters (whiffle, ribs, thickness) are **fixed at baseline values**
- **Mass is NOT an objective** - only WFE and MFG are optimized
## Design Variables (5)
| Variable | Min | Max | Baseline | Units | Notes |
|----------|-----|-----|----------|-------|-------|
| `lateral_inner_u` | 0.2 | 0.95 | TBD | unitless | U-joint ratio for inner lateral (NEW) |
| `lateral_outer_u` | 0.2 | 0.95 | TBD | unitless | U-joint ratio for outer lateral (NEW) |
| `lateral_middle_pivot` | 15.0 | 27.0 | TBD | mm | Middle pivot position |
| `lateral_inner_angle` | 25.0 | 35.0 | TBD | degrees | Inner lateral angle |
| `lateral_outer_angle` | 8.0 | 17.0 | TBD | degrees | Outer lateral angle |
**Note:** Baselines marked TBD will be updated after model introspection.
## Objectives
| Objective | Weight | Target | Description |
|-----------|--------|--------|-------------|
| `wfe_40_20` | 6.0 | 4.0 nm | Filtered RMS WFE at 40 deg relative to 20 deg |
| `wfe_60_20` | 5.0 | 10.0 nm | Filtered RMS WFE at 60 deg relative to 20 deg |
| `mfg_90` | 3.0 | 20.0 nm | Manufacturing deformation at 90 deg polishing |
**Weighted Sum Formula:** `6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90`
**Note:** Mass is **NOT** in the objective function. A hard constraint (mass <= 120 kg) is still enforced.
## Fixed Parameters (Baked in Model)
All non-lateral parameters are fixed at the model's current values. These are **not pushed** during optimization - the model already contains the correct values.
## Why CMA-ES?
| Factor | This Problem | Why CMA-ES |
|--------|--------------|------------|
| Dimensions | 5 variables | CMA-ES optimal for 5-50D |
| Variable type | All continuous | CMA-ES designed for continuous |
| Landscape | Smooth (physics-based) | CMA-ES exploits gradient structure |
| Correlation | Lateral params likely correlated | CMA-ES learns correlations automatically |
| Convergence | 100 trials budget | CMA-ES converges 2-3x faster than TPE |
### CMA-ES Settings
- `sigma0`: 0.3 (30% of range for initial exploration)
- `restart_strategy`: IPOP (restarts with larger population if stuck)
- `seed`: 42
## Model Files
Place the following files in `1_setup/model/`:
| File | Purpose |
|------|---------|
| `ASSY_M1_assyfem1_sim1.sim` | Simulation file |
| `*.fem` | FEM mesh files |
| `*.prt` | Geometry parts |
| `*_i.prt` | Idealized part (critical for mesh updates) |
## Extraction Method
- **Type:** ZernikeOPDExtractor with ANNULAR aperture
- **Inner radius:** 135.75 mm (271.5 mm central hole excluded)
- **Zernike modes:** 50
- **Filter orders:** J1-J4 removed for WFE, J1-J3 for MFG
- **Subcases:** 90 deg (1), 20 deg (2), 40 deg (3), 60 deg (4)
- **Reference:** 20 deg (subcase 2)
## Usage
```bash
# Single test trial
python run_optimization.py --test
# Full optimization (100 trials) - auto-launches dashboard
python run_optimization.py --start
# Custom trial count
python run_optimization.py --start --trials 50
# Resume interrupted run
python run_optimization.py --start --resume
# Without dashboard
python run_optimization.py --start --no-dashboard
```
## Directory Structure
```
m1_mirror_cost_reduction_lateral/
|-- 1_setup/
| |-- model/ # NX model files (user to add)
| `-- optimization_config.json # Study configuration
|-- 2_iterations/ # FEA iteration folders
|-- 3_results/ # Results database & summaries
| |-- study.db # Optuna SQLite database
| |-- optimization.log # Run log
| `-- optimization_summary.json # Final results
|-- run_optimization.py # Main optimization script
|-- README.md # This file
`-- STUDY_REPORT.md # Results template
```
## Setup Checklist
- [ ] Copy model files to `1_setup/model/`
- [ ] Run introspection to get baseline values
- [ ] Update `optimization_config.json` with correct baselines
- [ ] Run `--test` to verify setup
- [ ] Run full optimization
## Results
*Study not yet run. Results will be updated after optimization completes.*
## References
- Sister study: [m1_mirror_flatback_lateral](../m1_mirror_flatback_lateral/) (same approach, flat back model)

View File

@@ -0,0 +1,126 @@
# Study Report: m1_mirror_cost_reduction_lateral
## Executive Summary
| Metric | Value |
|--------|-------|
| **Study Name** | m1_mirror_cost_reduction_lateral |
| **Algorithm** | CMA-ES |
| **Trials Completed** | _pending_ |
| **Best Weighted Sum** | _pending_ |
| **Constraint Satisfaction** | _pending_ |
## Optimization Focus
This study optimizes **lateral support parameters only** for the COST REDUCTION model using the new U-joint parameterization:
- `lateral_inner_u`, `lateral_outer_u` (NEW - replace pivot params)
- `lateral_middle_pivot`, `lateral_inner_angle`, `lateral_outer_angle`
**Key Difference**: Mass is NOT an objective - only WFE and MFG are optimized.
## Optimization Progress
_To be filled after optimization run_
### Convergence Plot
_Insert convergence plot here_
### Parameter Evolution
_Insert parameter evolution plots here_
## Best Designs Found
### Top 5 Designs
| Rank | Trial | WS | WFE 40/20 | WFE 60/20 | MFG 90 | Mass |
|------|-------|-------|-----------|-----------|--------|------|
| 1 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
| 2 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
| 3 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
| 4 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
| 5 | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ | _pending_ |
### Best Design Parameters
| Parameter | Baseline | Best | Change |
|-----------|----------|------|--------|
| lateral_inner_u | _TBD_ | _pending_ | _pending_ |
| lateral_outer_u | _TBD_ | _pending_ | _pending_ |
| lateral_middle_pivot | _TBD_ | _pending_ | _pending_ |
| lateral_inner_angle | _TBD_ | _pending_ | _pending_ |
| lateral_outer_angle | _TBD_ | _pending_ | _pending_ |
## Parameter Sensitivity
_To be filled after analysis_
### Most Influential Parameters
1. _pending_
2. _pending_
3. _pending_
### Parameter Correlations
_Insert correlation analysis_
## Comparison to Baseline
| Metric | Baseline | Best | Improvement |
|--------|----------|------|-------------|
| Weighted Sum | _pending_ | _pending_ | _pending_ |
| WFE 40/20 | _pending_ | _pending_ | _pending_ |
| WFE 60/20 | _pending_ | _pending_ | _pending_ |
| MFG 90 | _pending_ | _pending_ | _pending_ |
## Comparison to Flat Back Lateral Study
| Metric | Flat Back | Cost Reduction | Difference |
|--------|-----------|----------------|------------|
| Best WS | _pending_ | _pending_ | _pending_ |
| Best WFE 40/20 | _pending_ | _pending_ | _pending_ |
| Best WFE 60/20 | _pending_ | _pending_ | _pending_ |
## Key Learnings
_To be filled after analysis_
1. _pending_
2. _pending_
3. _pending_
## Recommendations
_To be filled after analysis_
### For Next Study
- [ ] _pending_
### For Production
- [ ] _pending_
## Appendix
### Run Configuration
```json
Algorithm: CMA-ES
Trials: 100
sigma0: 0.3
restart_strategy: IPOP
```
### Files Generated
- `3_results/study.db` - Optuna database
- `3_results/optimization.log` - Run log
- `3_results/optimization_summary.json` - Final results
- `3_results/best_design_archive/` - Archived best designs
---
*Report generated: _pending_*

View File

@@ -2,9 +2,9 @@
"meta": {
"version": "2.0",
"created": "2026-01-17T15:35:12.024432Z",
"modified": "2026-01-20T19:01:36.016065Z",
"modified": "2026-01-20T20:05:28.197219Z",
"created_by": "migration",
"modified_by": "canvas",
"modified_by": "test",
"study_name": "m1_mirror_cost_reduction_lateral",
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u) for cost reduction model. Focus on WFE and MFG only - no mass objective.",
"tags": [
@@ -151,6 +151,38 @@
"x": 50,
"y": 580
}
},
{
"name": "variable_1768938898079",
"expression_name": "expr_1768938898079",
"type": "continuous",
"bounds": {
"min": 0,
"max": 1
},
"baseline": 0.5,
"enabled": true,
"canvas_position": {
"x": -185.06035488622524,
"y": 91.62521000204346
},
"id": "dv_008"
},
{
"name": "test_dv",
"expression_name": "test_expr",
"type": "continuous",
"bounds": {
"min": 0,
"max": 1
},
"baseline": 0.5,
"enabled": true,
"id": "dv_009",
"canvas_position": {
"x": 50,
"y": 680
}
}
],
"extractors": [
@@ -228,11 +260,11 @@
"name": "extract_volume",
"module": null,
"signature": null,
"source_code": "def extract_volume(trial_dir, config, context):\n \"\"\"\n Extract volume from mass using material density.\n Volume = Mass / Density\n \n For Zerodur glass-ceramic: density ~ 2530 kg/mó\n \"\"\"\n import json\n from pathlib import Path\n \n # Get mass from the mass extractor results\n results_file = Path(trial_dir) / 'results.json'\n if results_file.exists():\n with open(results_file) as f:\n results = json.load(f)\n mass_kg = results.get('mass_kg', 0)\n else:\n # If no results yet, try to get from context\n mass_kg = context.get('mass_kg', 0)\n \n density = config.get('density_kg_m3', 2530.0) # Zerodur default\n \n # Volume in m³\n volume_m3 = mass_kg / density if density > 0 else 0\n \n # Also calculate in liters for convenience (1 mó = 1000 L)\n volume_liters = volume_m3 * 1000000\n \n return {\n 'volume_m3': volume_m3,\n 'volume_liters': volume_liters\n }\n"
"source_code": "\"\"\"Extract modal mass matrix from Nastran OP2 file\"\"\"\n\nfrom pyNastran.op2.op2 import OP2\nimport numpy as np\n\ndef extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:\n \"\"\"\n Extract modal mass matrix from Nastran modal analysis results.\n \n The modal mass matrix (generalized mass) is typically diagonal for\n mass-normalized modes, with each diagonal entry representing the\n effective mass participation for that mode.\n \n Returns:\n dict with keys:\n - modal_mass_1, modal_mass_2, modal_mass_3: First three modal masses\n - total_modal_mass: Sum of all modal masses\n - modal_mass_matrix: Full diagonal modal mass array (as list)\n \"\"\"\n op2 = OP2()\n op2.read_op2(op2_path)\n \n # Initialize outputs\n modal_mass_1 = 0.0\n modal_mass_2 = 0.0\n modal_mass_3 = 0.0\n total_modal_mass = 0.0\n modal_mass_matrix = []\n \n try:\n # Method 1: Check for modal participation factors / effective mass\n # This is stored in op2.modal_contribution if available\n if hasattr(op2, 'modal_contribution') and op2.modal_contribution:\n mc = op2.modal_contribution\n if subcase_id in mc:\n modal_data = mc[subcase_id]\n if hasattr(modal_data, 'effective_mass'):\n eff_mass = modal_data.effective_mass\n modal_mass_matrix = eff_mass.tolist() if hasattr(eff_mass, 'tolist') else list(eff_mass)\n \n # Method 2: Check eigenvalues for generalized mass\n # pyNastran stores generalized mass in eigenvalues object\n if not modal_mass_matrix and subcase_id in op2.eigenvalues:\n eig = op2.eigenvalues[subcase_id]\n \n # Generalized mass is typically stored as 'generalized_mass' attribute\n if hasattr(eig, 'generalized_mass'):\n gen_mass = eig.generalized_mass\n modal_mass_matrix = gen_mass.tolist() if hasattr(gen_mass, 'tolist') else list(gen_mass)\n \n # Alternative: mass-normalized modes have unit modal mass\n # Check the mode_cycle attribute for mass normalization info\n elif hasattr(eig, 'mass'):\n mass = eig.mass\n modal_mass_matrix = mass.tolist() if hasattr(mass, 'tolist') else list(mass)\n \n # Method 3: For mass-normalized eigenvectors, modal mass = 1.0\n # Check if eigenvectors exist and compute modal mass from them\n if not modal_mass_matrix and subcase_id in op2.eigenvectors:\n eigvec = op2.eigenvectors[subcase_id]\n n_modes = eigvec.data.shape[1] if len(eigvec.data.shape) > 1 else 1\n # For mass-normalized modes, modal mass is unity\n modal_mass_matrix = [1.0] * n_modes\n \n # Extract individual modal masses\n if modal_mass_matrix:\n if len(modal_mass_matrix) >= 1:\n modal_mass_1 = float(modal_mass_matrix[0])\n if len(modal_mass_matrix) >= 2:\n modal_mass_2 = float(modal_mass_matrix[1])\n if len(modal_mass_matrix) >= 3:\n modal_mass_3 = float(modal_mass_matrix[2])\n total_modal_mass = float(sum(modal_mass_matrix))\n \n except Exception as e:\n # Log error but return zeros gracefully\n print(f\"Warning: Could not extract modal mass matrix: {e}\")\n \n return {\n 'modal_mass_1': modal_mass_1,\n 'modal_mass_2': modal_mass_2,\n 'modal_mass_3': modal_mass_3,\n 'total_modal_mass': total_modal_mass,\n 'modal_mass_matrix': modal_mass_matrix,\n }"
}
},
{
"name": "a1768934465995fsfdadd",
"name": "extractor_1768938758443",
"type": "custom_function",
"builtin": false,
"enabled": true,
@@ -247,31 +279,31 @@
}
],
"canvas_position": {
"x": 661.4703818070815,
"y": 655.713625352519
},
"id": "ext_004"
},
{
"name": "extractor_1768934622682",
"type": "custom_function",
"builtin": false,
"enabled": true,
"function": {
"name": "extract",
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
},
"outputs": [
{
"name": "value",
"metric": "custom"
}
],
"canvas_position": {
"x": 588.8370255010856,
"y": 516.8654070156841
"x": 522.740988960073,
"y": 560.0208026883463
},
"id": "ext_005"
},
{
"name": "extractor_1768938897219",
"type": "custom_function",
"builtin": false,
"enabled": true,
"function": {
"name": "extract",
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
},
"outputs": [
{
"name": "value",
"metric": "custom"
}
],
"canvas_position": {
"x": -197.5451097726711,
"y": 262.2501934501369
},
"id": "ext_004"
}
],
"objectives": [
@@ -418,10 +450,6 @@
"source": "ext_001",
"target": "obj_003"
},
{
"source": "ext_002",
"target": "con_001"
},
{
"source": "obj_001",
"target": "optimization"
@@ -437,6 +465,10 @@
{
"source": "con_001",
"target": "optimization"
},
{
"source": "ext_002",
"target": "con_001"
}
],
"layout_version": "2.0"

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Introspect M1_Blank.prt to get current expression values."""
import sys
import json
sys.path.insert(0, 'c:/Users/antoi/Atomizer')
from optimization_engine.extractors.introspect_part import introspect_part, get_expressions_dict
MODEL_PATH = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model/M1_Blank.prt'
OUTPUT_DIR = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model'
# Expressions we care about
LATERAL_VARS = [
'lateral_inner_u',
'lateral_outer_u',
'lateral_middle_pivot',
'lateral_inner_angle',
'lateral_outer_angle',
'lateral_closeness',
]
print("Running NX introspection on M1_Blank.prt (cost reduction model)...")
result = introspect_part(MODEL_PATH, OUTPUT_DIR, verbose=True)
if result.get('success'):
exprs = get_expressions_dict(result)
print()
print("=" * 60)
print("DESIGN VARIABLES (lateral) - current values in model:")
print("=" * 60)
for name in LATERAL_VARS:
if name in exprs:
print(f" {name}: {exprs[name]}")
else:
print(f" {name}: NOT FOUND")
# Save all expressions to JSON for easy reference
output_json = 'c:/Users/antoi/Atomizer/studies/M1_Mirror/m1_mirror_cost_reduction_lateral/1_setup/model_expressions.json'
with open(output_json, 'w') as f:
json.dump(exprs, f, indent=2)
print(f"\nAll expressions saved to: {output_json}")
else:
print(f"Introspection failed: {result.get('error', 'Unknown error')}")

View File

@@ -0,0 +1,695 @@
#!/usr/bin/env python3
"""
M1 Mirror Cost Reduction Lateral - Lateral Supports Optimization (CMA-ES)
==========================================================================
Lateral support optimization for COST REDUCTION model with new U-joint expressions:
- lateral_inner_u (replaces lateral_inner_pivot)
- lateral_outer_u (replaces lateral_outer_pivot)
- lateral_middle_pivot (unchanged)
- lateral_inner_angle (unchanged)
- lateral_outer_angle (unchanged)
Key Features:
1. CMA-ES sampler - ideal for 5D continuous optimization
2. ANNULAR APERTURE - excludes 271.5mm central hole from Zernike fitting
3. Uses ZernikeOPDExtractor.extract_relative() with inner_radius=135.75mm
4. Weighted sum: 6*wfe_40_20 + 5*wfe_60_20 + 3*mfg_90 (NO mass objective)
5. Hard constraint: mass <= 120 kg (still enforced)
Usage:
python run_optimization.py --start
python run_optimization.py --start --trials 100
python run_optimization.py --start --trials 100 --resume
python run_optimization.py --test # Single trial test
Author: Atomizer
Created: 2026-01-13
"""
import sys
import os
import subprocess
LICENSE_SERVER = "28000@dalidou;28000@100.80.199.40"
os.environ['SPLM_LICENSE_SERVER'] = LICENSE_SERVER
print(f"[LICENSE] SPLM_LICENSE_SERVER set to: {LICENSE_SERVER}")
# Add Atomizer root to path (study is at studies/M1_Mirror/study_name/)
STUDY_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(STUDY_DIR)))
sys.path.insert(0, PROJECT_ROOT)
import json
import time
import argparse
import logging
import shutil
import re
from pathlib import Path
from typing import Dict, Optional, Any
from datetime import datetime
# ============================================================================
# Dashboard Auto-Launch
# ============================================================================
def launch_dashboard():
"""Launch the Atomizer dashboard in background."""
dashboard_dir = Path(PROJECT_ROOT) / "atomizer-dashboard"
start_script = dashboard_dir / "start-dashboard.bat"
if not start_script.exists():
print(f"[DASHBOARD] Warning: start-dashboard.bat not found at {start_script}")
return False
try:
# Launch dashboard in background (detached process)
subprocess.Popen(
["cmd", "/c", "start", "/min", str(start_script)],
cwd=str(dashboard_dir),
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
print("[DASHBOARD] Launched in background")
print("[DASHBOARD] Frontend: http://localhost:5173")
print("[DASHBOARD] Backend: http://localhost:8000")
return True
except Exception as e:
print(f"[DASHBOARD] Failed to launch: {e}")
return False
import optuna
from optuna.samplers import CmaEsSampler
# Atomizer imports
from optimization_engine.nx.solver import NXSolver
from optimization_engine.extractors import ZernikeOPDExtractor # Supports annular apertures
# ============================================================================
# Paths
# ============================================================================
STUDY_DIR = Path(__file__).parent
SETUP_DIR = STUDY_DIR / "1_setup"
MODEL_DIR = SETUP_DIR / "model"
ITERATIONS_DIR = STUDY_DIR / "2_iterations"
RESULTS_DIR = STUDY_DIR / "3_results"
CONFIG_PATH = SETUP_DIR / "optimization_config.json"
# Ensure directories exist
ITERATIONS_DIR.mkdir(exist_ok=True)
RESULTS_DIR.mkdir(exist_ok=True)
# Logging
LOG_FILE = RESULTS_DIR / "optimization.log"
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__)
# ============================================================================
# Configuration
# ============================================================================
with open(CONFIG_PATH) as f:
CONFIG = json.load(f)
STUDY_NAME = CONFIG["study_name"]
# Objective weights from config (NO MASS!)
OBJ_WEIGHTS = {
'wfe_40_20': 6.0,
'wfe_60_20': 5.0,
'mfg_90': 3.0
}
# Hard constraint: blank_mass <= 120kg (still enforced even though not optimizing)
MAX_BLANK_MASS_KG = 120.0
CONSTRAINT_PENALTY = 1e6
# ANNULAR APERTURE: 271.5mm central hole diameter -> 135.75mm radius
INNER_RADIUS_MM = CONFIG.get('extraction_method', {}).get('inner_radius', 135.75)
def compute_weighted_sum(objectives: Dict[str, float]) -> float:
"""Compute weighted sum of objectives (NO MASS!)."""
return (OBJ_WEIGHTS['wfe_40_20'] * objectives.get('wfe_40_20', 1000.0) +
OBJ_WEIGHTS['wfe_60_20'] * objectives.get('wfe_60_20', 1000.0) +
OBJ_WEIGHTS['mfg_90'] * objectives.get('mfg_90', 1000.0))
def check_mass_constraint(mass_kg: float) -> tuple:
"""Check if mass constraint is satisfied."""
if mass_kg <= MAX_BLANK_MASS_KG:
return True, 0.0
else:
return False, mass_kg - MAX_BLANK_MASS_KG
# ============================================================================
# FEA Runner with Annular Zernike Extraction
# ============================================================================
class FEARunner:
"""Runs FEA simulations with annular aperture Zernike extraction."""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.nx_solver = None
self.master_model_dir = MODEL_DIR
# Get fixed parameter values to apply on every run
self.fixed_params = {}
for fp in config.get('fixed_parameters', []):
self.fixed_params[fp['name']] = fp['value']
def setup(self):
"""Setup NX solver (assumes NX is already running)."""
study_name = self.config.get('study_name', 'm1_mirror_flatback_lateral')
nx_settings = self.config.get('nx_settings', {})
nx_install_dir = nx_settings.get('nx_install_path', 'C:\\Program Files\\Siemens\\DesigncenterNX2512')
version_match = re.search(r'NX(\d+)|DesigncenterNX(\d+)', nx_install_dir)
nastran_version = (version_match.group(1) or version_match.group(2)) if version_match else "2512"
self.nx_solver = NXSolver(
master_model_dir=str(self.master_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=study_name
)
logger.info(f"[NX] Solver ready (Nastran {nastran_version})")
def run_fea(self, params: Dict[str, float], trial_num: int) -> Optional[Dict]:
"""Run FEA and extract objectives using ZernikeOPDExtractor with annular aperture."""
if self.nx_solver is None:
self.setup()
logger.info(f" [FEA {trial_num}] Running simulation...")
# Build expressions: start with fixed params, then add optimization params
expressions = {}
# Fixed parameters
for name, value in self.fixed_params.items():
expressions[name] = value
# Optimization variables (the ones we're actually varying)
for var in self.config['design_variables']:
if var.get('enabled', True) and var['name'] in params:
expressions[var['expression_name']] = params[var['name']]
iter_folder = self.nx_solver.create_iteration_folder(
iterations_base_dir=ITERATIONS_DIR,
iteration_number=trial_num,
expression_updates=expressions
)
try:
nx_settings = self.config.get('nx_settings', {})
sim_file = iter_folder / nx_settings.get('sim_file', 'ASSY_M1_assyfem1_sim1.sim')
t_start = time.time()
result = self.nx_solver.run_simulation(
sim_file=sim_file,
working_dir=iter_folder,
expression_updates=expressions,
solution_name=nx_settings.get('solution_name', 'Solution 1'),
cleanup=False
)
solve_time = time.time() - t_start
if not result['success']:
logger.error(f" [FEA {trial_num}] Solve failed: {result.get('error')}")
return None
logger.info(f" [FEA {trial_num}] Solved in {solve_time:.1f}s")
# Extract objectives using ZernikeOPDExtractor with ANNULAR APERTURE
op2_path = Path(result['op2_file'])
objectives, lateral_diag = self._extract_objectives_annular(op2_path, iter_folder)
if objectives is None:
return None
# Check constraint
mass_kg = objectives['mass_kg']
is_feasible, violation = check_mass_constraint(mass_kg)
if is_feasible:
weighted_sum = compute_weighted_sum(objectives)
constraint_status = "OK"
else:
weighted_sum = compute_weighted_sum(objectives) + CONSTRAINT_PENALTY * violation
constraint_status = f"VIOLATED (+{violation:.1f}kg)"
logger.info(f" [FEA {trial_num}] 40-20: {objectives['wfe_40_20']:.2f} nm (Annular OPD)")
logger.info(f" [FEA {trial_num}] 60-20: {objectives['wfe_60_20']:.2f} nm (Annular OPD)")
logger.info(f" [FEA {trial_num}] Mfg: {objectives['mfg_90']:.2f} nm (Annular OPD)")
logger.info(f" [FEA {trial_num}] Mass: {objectives['mass_kg']:.3f} kg [Constraint: {constraint_status}]")
logger.info(f" [FEA {trial_num}] Weighted Sum: {weighted_sum:.2f}")
return {
'trial_num': trial_num,
'params': params,
'objectives': objectives,
'weighted_sum': weighted_sum,
'is_feasible': is_feasible,
'constraint_violation': violation,
'source': 'FEA_ZernikeOPD_Annular',
'solve_time': solve_time,
'iter_folder': str(iter_folder),
'lateral_diagnostics': lateral_diag
}
except Exception as e:
logger.error(f" [FEA {trial_num}] Error: {e}")
import traceback
traceback.print_exc()
return None
def _extract_objectives_annular(self, op2_path: Path, iter_folder: Path) -> tuple:
"""
Extract objectives using ZernikeOPDExtractor with ANNULAR APERTURE.
The central hole (271.5mm diameter, inner_radius=135.75mm) is EXCLUDED
from Zernike fitting and RMS calculations.
"""
try:
zernike_settings = self.config.get('zernike_settings', {})
# Create ZernikeOPDExtractor with ANNULAR APERTURE
extractor = ZernikeOPDExtractor(
op2_path,
figure_path=None, # Uses BDF geometry
bdf_path=None, # Auto-detected
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),
inner_radius=INNER_RADIUS_MM # ANNULAR APERTURE!
)
ref = zernike_settings.get('reference_subcase', '2')
# Extract RELATIVE metrics with annular masking
rel_40 = extractor.extract_relative("3", ref) # 40 deg vs 20 deg
rel_60 = extractor.extract_relative("4", ref) # 60 deg vs 20 deg
rel_90 = extractor.extract_relative("1", ref) # 90 deg vs 20 deg (for MFG)
# Log annular info
if 'obscuration_ratio' in rel_40:
logger.info(f" [Annular] Inner R={INNER_RADIUS_MM:.1f}mm, Obscuration={rel_40['obscuration_ratio']*100:.1f}%")
logger.info(f" [Annular] Using {rel_40.get('n_annular_nodes', '?')} nodes (excl. central hole)")
# Extract mass from temp file
mass_kg = 0.0
mass_file = iter_folder / "_temp_mass.txt"
if mass_file.exists():
try:
with open(mass_file, 'r') as f:
mass_kg = float(f.read().strip())
except Exception as mass_err:
logger.warning(f" Could not read mass file: {mass_err}")
# Also check _temp_part_properties.json
if mass_kg == 0:
props_file = iter_folder / "_temp_part_properties.json"
if props_file.exists():
try:
with open(props_file, 'r') as f:
props = json.load(f)
mass_kg = props.get('mass_kg', 0)
except Exception:
pass
lateral_diag = {
'max_um': rel_40.get('max_lateral_displacement_um', 0),
'rms_um': rel_40.get('rms_lateral_displacement_um', 0),
}
objectives = {
'wfe_40_20': rel_40['relative_filtered_rms_nm'],
'wfe_60_20': rel_60['relative_filtered_rms_nm'],
'mfg_90': rel_90['relative_rms_filter_j1to3'],
'mass_kg': mass_kg
}
return objectives, lateral_diag
except Exception as e:
logger.error(f"Annular Zernike extraction failed: {e}")
import traceback
traceback.print_exc()
return None, {}
# ============================================================================
# CMA-ES Optimizer
# ============================================================================
class CMAESOptimizer:
"""CMA-ES optimizer for lateral support parameters."""
def __init__(self, config: Dict[str, Any], resume: bool = False):
self.config = config
self.resume = resume
self.fea_runner = FEARunner(config)
# Load design variable bounds (only enabled variables)
self.design_vars = {
v['name']: {'min': v['min'], 'max': v['max'], 'baseline': v.get('baseline')}
for v in config['design_variables']
if v.get('enabled', True)
}
# CMA-ES settings
opt_settings = config.get('optimization', {})
self.sigma0 = opt_settings.get('sigma0', 0.3)
self.seed = opt_settings.get('seed', 42)
# Study
self.study_name = config.get('study_name', 'm1_mirror_flatback_lateral')
self.db_path = RESULTS_DIR / "study.db"
# Track best
self.best_weighted_sum = float('inf')
self.best_trial_info = None
# Track FEA count
self._count_existing_iterations()
def _count_existing_iterations(self):
"""Count existing iteration folders."""
self.fea_count = 0
if ITERATIONS_DIR.exists():
for d in ITERATIONS_DIR.iterdir():
if d.is_dir() and d.name.startswith('iter'):
try:
num = int(d.name.replace('iter', ''))
self.fea_count = max(self.fea_count, num)
except ValueError:
pass
logger.info(f"Existing FEA iterations: {self.fea_count}")
def create_study(self) -> optuna.Study:
"""Create or load Optuna study with CMA-ES sampler."""
# Get baseline values for x0 (starting point)
x0 = {}
for name, bounds in self.design_vars.items():
x0[name] = bounds['baseline']
sampler = CmaEsSampler(
x0=x0,
sigma0=self.sigma0,
seed=self.seed,
restart_strategy='ipop'
)
storage = f"sqlite:///{self.db_path}"
if self.resume:
try:
study = optuna.load_study(
study_name=self.study_name,
storage=storage,
sampler=sampler
)
logger.info(f"Resumed study with {len(study.trials)} existing trials")
# Find current best
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE]
feasible = [t for t in completed if t.user_attrs.get('is_feasible', False)]
if feasible:
best = min(feasible, key=lambda t: t.value if t.value else float('inf'))
if best.value:
self.best_weighted_sum = best.value
logger.info(f"Current best (feasible): {self.best_weighted_sum:.2f}")
return study
except KeyError:
logger.info("No existing study found, creating new one")
study = optuna.create_study(
study_name=self.study_name,
storage=storage,
sampler=sampler,
direction="minimize",
load_if_exists=True
)
# Enqueue baseline as first trial
if len(study.trials) == 0:
logger.info("Enqueueing baseline as trial 0...")
study.enqueue_trial(x0)
return study
def objective(self, trial: optuna.Trial) -> float:
"""CMA-ES objective function."""
# Sample parameters
params = {}
for name, bounds in self.design_vars.items():
params[name] = trial.suggest_float(name, bounds['min'], bounds['max'])
# Increment FEA counter
self.fea_count += 1
iter_num = self.fea_count
logger.info(f"Trial {trial.number} -> iter{iter_num}")
for name, value in params.items():
logger.info(f" {name} = {value:.3f}")
# Run FEA
result = self.fea_runner.run_fea(params, iter_num)
if result is None:
trial.set_user_attr('source', 'FEA_FAILED')
trial.set_user_attr('iter_num', iter_num)
trial.set_user_attr('is_feasible', False)
return 1e6
# Store metadata
trial.set_user_attr('source', 'FEA_ZernikeOPD_Annular')
trial.set_user_attr('iter_num', iter_num)
trial.set_user_attr('iter_folder', result['iter_folder'])
trial.set_user_attr('wfe_40_20', result['objectives']['wfe_40_20'])
trial.set_user_attr('wfe_60_20', result['objectives']['wfe_60_20'])
trial.set_user_attr('mfg_90', result['objectives']['mfg_90'])
trial.set_user_attr('mass_kg', result['objectives']['mass_kg'])
trial.set_user_attr('solve_time', result['solve_time'])
trial.set_user_attr('is_feasible', result['is_feasible'])
weighted_sum = result['weighted_sum']
# Check if new best
if result['is_feasible'] and weighted_sum < self.best_weighted_sum:
logger.info(f" NEW BEST! {weighted_sum:.2f} (was {self.best_weighted_sum:.2f})")
self.best_weighted_sum = weighted_sum
self.best_trial_info = {
'trial_number': trial.number,
'iter_num': iter_num,
'iter_folder': result['iter_folder'],
'weighted_sum': weighted_sum,
'objectives': result['objectives'],
'params': params
}
self._archive_best_design()
return weighted_sum
def _archive_best_design(self):
"""Archive current best design."""
if self.best_trial_info is None:
return
try:
archive_dir = RESULTS_DIR / "best_design_archive"
archive_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dest_dir = archive_dir / timestamp
src_dir = Path(self.best_trial_info['iter_folder'])
if src_dir.exists():
shutil.copytree(src_dir, dest_dir)
info = {
'study_name': self.study_name,
'trial_number': self.best_trial_info['trial_number'],
'iteration_folder': f"iter{self.best_trial_info['iter_num']}",
'weighted_sum': self.best_trial_info['weighted_sum'],
'objectives': self.best_trial_info['objectives'],
'params': self.best_trial_info['params'],
'extraction_method': 'ZernikeOPD_Annular (271.5mm central hole excluded)',
'inner_radius_mm': INNER_RADIUS_MM,
'archived_at': datetime.now().isoformat()
}
with open(dest_dir / '_archive_info.json', 'w') as f:
json.dump(info, f, indent=2)
logger.info(f" Archived to: {dest_dir.name}")
except Exception as e:
logger.warning(f"Could not archive best design: {e}")
def run(self, n_trials: int):
"""Run CMA-ES optimization."""
study = self.create_study()
logger.info("=" * 70)
logger.info("M1 MIRROR FLAT BACK - LATERAL SUPPORTS OPTIMIZATION (CMA-ES)")
logger.info("=" * 70)
logger.info("*** ANNULAR APERTURE: 271.5mm central hole EXCLUDED ***")
logger.info(f"*** Inner radius: {INNER_RADIUS_MM} mm ***")
logger.info(f"Study: {self.study_name}")
logger.info("*** OBJECTIVES: WFE only (mass NOT in objective) ***")
logger.info(f"Total trials in DB: {len(study.trials)}")
logger.info(f"New FEA trials to run: {n_trials}")
logger.info(f"Active Design Variables: {len(self.design_vars)}")
for name, bounds in self.design_vars.items():
baseline = bounds.get('baseline', 'N/A')
logger.info(f" - {name}: [{bounds['min']}, {bounds['max']}] (baseline: {baseline})")
logger.info(f"CONSTRAINT: blank_mass <= {MAX_BLANK_MASS_KG} kg")
logger.info(f"CMA-ES sigma0: {self.sigma0}")
logger.info("=" * 70)
try:
study.optimize(
self.objective,
n_trials=n_trials,
show_progress_bar=True,
gc_after_trial=True
)
except KeyboardInterrupt:
logger.info("Optimization interrupted by user")
self._report_results(study)
return study
def _report_results(self, study: optuna.Study):
"""Report optimization results."""
logger.info("\n" + "=" * 70)
logger.info("OPTIMIZATION RESULTS (Annular Aperture)")
logger.info("=" * 70)
completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE and t.value < 1e5]
feasible = [t for t in completed if t.user_attrs.get('is_feasible', False)]
logger.info(f"\nTotal completed: {len(completed)}")
logger.info(f"Feasible (mass <= {MAX_BLANK_MASS_KG}kg): {len(feasible)}")
if not feasible:
logger.warning("No feasible trials found!")
return
sorted_trials = sorted(feasible, key=lambda t: t.value)
print(f"\n{'Trial':>6} | {'WS':>10} | {'40vs20':>10} | {'60vs20':>10} | {'MFG':>10} | {'Mass':>10} | Iter")
print("-" * 85)
for t in sorted_trials[:15]:
obj_40 = t.user_attrs.get('wfe_40_20', 0)
obj_60 = t.user_attrs.get('wfe_60_20', 0)
obj_mfg = t.user_attrs.get('mfg_90', 0)
obj_mass = t.user_attrs.get('mass_kg', 0)
iter_num = t.user_attrs.get('iter_num', '?')
print(f"{t.number:>6} | {t.value:>10.2f} | {obj_40:>10.2f} | {obj_60:>10.2f} | {obj_mfg:>10.2f} | {obj_mass:>10.3f} | iter{iter_num}")
best = sorted_trials[0]
logger.info(f"\nBEST FEASIBLE TRIAL: #{best.number}")
logger.info(f" Weighted Sum: {best.value:.2f}")
logger.info(f" 40-20: {best.user_attrs.get('wfe_40_20', 0):.2f} nm")
logger.info(f" 60-20: {best.user_attrs.get('wfe_60_20', 0):.2f} nm")
logger.info(f" MFG: {best.user_attrs.get('mfg_90', 0):.2f} nm")
logger.info(f" Mass: {best.user_attrs.get('mass_kg', 0):.3f} kg")
logger.info(f"\n Best Lateral Parameters:")
for k, v in best.params.items():
logger.info(f" {k}: {v:.3f}")
# Save summary
results_summary = {
'study_name': self.study_name,
'algorithm': 'CMA-ES',
'extraction_method': 'ZernikeOPD_Annular',
'inner_radius_mm': INNER_RADIUS_MM,
'objectives_note': 'Mass NOT in objective - WFE only',
'total_trials': len(study.trials),
'feasible_trials': len(feasible),
'best_trial': {
'number': best.number,
'weighted_sum': best.value,
'objectives': {
'wfe_40_20': best.user_attrs.get('wfe_40_20'),
'wfe_60_20': best.user_attrs.get('wfe_60_20'),
'mfg_90': best.user_attrs.get('mfg_90'),
'mass_kg': best.user_attrs.get('mass_kg')
},
'params': dict(best.params),
'iter_folder': best.user_attrs.get('iter_folder')
},
'timestamp': datetime.now().isoformat()
}
with open(RESULTS_DIR / 'optimization_summary.json', 'w') as f:
json.dump(results_summary, f, indent=2)
logger.info(f"\nResults saved to: {RESULTS_DIR / 'optimization_summary.json'}")
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(description="M1 Mirror Cost Reduction Lateral - Lateral Supports Optimization")
parser.add_argument("--start", action="store_true", help="Start optimization")
parser.add_argument("--trials", type=int, default=100, help="Number of FEA trials")
parser.add_argument("--resume", action="store_true", help="Resume interrupted run")
parser.add_argument("--test", action="store_true", help="Run single test trial")
parser.add_argument("--no-dashboard", action="store_true", help="Don't auto-launch dashboard")
args = parser.parse_args()
if not args.start and not args.test:
parser.print_help()
print("\nUse --start to begin optimization or --test for single trial")
print("\n*** Optimizing LATERAL SUPPORT parameters only ***")
print("*** Design Variables: lateral_inner_u, lateral_outer_u, lateral_middle_pivot, ***")
print("*** lateral_inner_angle, lateral_outer_angle ***")
print("*** Objectives: WFE only (mass NOT included) ***")
print("*** Using ANNULAR APERTURE - central hole excluded from Zernike fitting ***")
return
if not CONFIG_PATH.exists():
print(f"Error: Config not found at {CONFIG_PATH}")
sys.exit(1)
# Auto-launch dashboard (unless disabled)
if not args.no_dashboard:
launch_dashboard()
time.sleep(2) # Give dashboard time to start
with open(CONFIG_PATH) as f:
config = json.load(f)
optimizer = CMAESOptimizer(config, resume=args.resume)
n_trials = 1 if args.test else args.trials
optimizer.run(n_trials=n_trials)
if __name__ == "__main__":
main()

View File

@@ -4,25 +4,25 @@
"extraction_method": "ZernikeOPD_Annular",
"inner_radius_mm": 135.75,
"objectives_note": "Mass NOT in objective - WFE only",
"total_trials": 1,
"feasible_trials": 1,
"total_trials": 101,
"feasible_trials": 100,
"best_trial": {
"number": 0,
"weighted_sum": 341.40717511411987,
"number": 76,
"weighted_sum": 220.12317796085603,
"objectives": {
"wfe_40_20": 9.738648075724171,
"wfe_60_20": 24.138392317227122,
"mfg_90": 54.09444169121308,
"mass_kg": 102.89579477048632
"wfe_40_20": 7.033921022459454,
"wfe_60_20": 16.109562572565014,
"mfg_90": 32.457279654424745,
"mass_kg": 102.89579477048622
},
"params": {
"lateral_inner_u": 0.4,
"lateral_outer_u": 0.4,
"lateral_middle_pivot": 22.42,
"lateral_inner_angle": 31.96,
"lateral_outer_angle": 9.08
"lateral_inner_u": 0.40304412850085514,
"lateral_outer_u": 0.9043062289622721,
"lateral_middle_pivot": 25.869245488671304,
"lateral_inner_angle": 32.008659765295675,
"lateral_outer_angle": 13.952742709877848
},
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_flatback_lateral\\2_iterations\\iter1"
"iter_folder": "C:\\Users\\antoi\\Atomizer\\studies\\M1_Mirror\\m1_mirror_flatback_lateral\\2_iterations\\iter77"
},
"timestamp": "2026-01-13T11:01:22.360549"
"timestamp": "2026-01-13T18:41:14.992549"
}

View File

@@ -2,9 +2,9 @@
"meta": {
"version": "2.0",
"created": "2026-01-17T15:35:12.034330Z",
"modified": "2026-01-17T15:35:12.034330Z",
"modified": "2026-01-20T18:24:29.805432Z",
"created_by": "migration",
"modified_by": "migration",
"modified_by": "canvas",
"study_name": "m1_mirror_flatback_lateral",
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u). Focus on WFE and MFG only - no mass objective.",
"tags": [
@@ -104,10 +104,10 @@
"expression_name": "lateral_outer_angle",
"type": "continuous",
"bounds": {
"min": 8.0,
"max": 17.0
"min": 8,
"max": 17
},
"baseline": 9.08,
"baseline": 10,
"units": "degrees",
"enabled": true,
"description": "Outer lateral support angle",