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>
240 lines
9.0 KiB
Python
240 lines
9.0 KiB
Python
#!/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())
|