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>
456 lines
15 KiB
Python
456 lines
15 KiB
Python
"""
|
|
Displacement Field Extraction for GNN Training
|
|
===============================================
|
|
|
|
This module extracts full displacement fields from Nastran OP2 files for GNN training.
|
|
Unlike the Zernike extractors that reduce to coefficients, this preserves the raw
|
|
spatial data that GNN needs to learn the physics.
|
|
|
|
Key Features:
|
|
1. Extract Z-displacement for all optical surface nodes
|
|
2. Store node coordinates for graph construction
|
|
3. Support for multiple subcases (gravity orientations)
|
|
4. HDF5 storage for efficient loading during training
|
|
|
|
Output Format (HDF5):
|
|
/node_ids - [n_nodes] int array
|
|
/node_coords - [n_nodes, 3] float array (X, Y, Z)
|
|
/subcase_1 - [n_nodes] Z-displacement for subcase 1
|
|
/subcase_2 - [n_nodes] Z-displacement for subcase 2
|
|
/subcase_3 - [n_nodes] Z-displacement for subcase 3
|
|
/subcase_4 - [n_nodes] Z-displacement for subcase 4
|
|
/metadata - JSON string with extraction info
|
|
|
|
Usage:
|
|
from optimization_engine.gnn.extract_displacement_field import extract_displacement_field
|
|
|
|
field_data = extract_displacement_field(op2_path, bdf_path)
|
|
save_field_to_hdf5(field_data, output_path)
|
|
"""
|
|
|
|
import json
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|
from datetime import datetime
|
|
|
|
try:
|
|
import h5py
|
|
HAS_H5PY = True
|
|
except ImportError:
|
|
HAS_H5PY = False
|
|
|
|
from pyNastran.op2.op2 import OP2
|
|
from pyNastran.bdf.bdf import BDF
|
|
|
|
|
|
def identify_optical_surface_nodes(
|
|
node_coords: Dict[int, np.ndarray],
|
|
r_inner: float = 100.0,
|
|
r_outer: float = 650.0,
|
|
z_tolerance: float = 100.0
|
|
) -> Tuple[List[int], np.ndarray]:
|
|
"""
|
|
Identify nodes on the optical surface by spatial filtering.
|
|
|
|
The optical surface is identified by:
|
|
1. Radial position (between inner and outer radius)
|
|
2. Consistent Z range (nodes on the curved mirror surface)
|
|
|
|
Args:
|
|
node_coords: Dictionary mapping node ID to (X, Y, Z) coordinates
|
|
r_inner: Inner radius cutoff (central hole)
|
|
r_outer: Outer radius limit
|
|
z_tolerance: Maximum Z deviation from mean to include
|
|
|
|
Returns:
|
|
Tuple of (node_ids list, coordinates array [n, 3])
|
|
"""
|
|
# Get all coordinates as arrays
|
|
nids = list(node_coords.keys())
|
|
coords = np.array([node_coords[nid] for nid in nids])
|
|
|
|
# Calculate radial position
|
|
r = np.sqrt(coords[:, 0]**2 + coords[:, 1]**2)
|
|
|
|
# Initial radial filter
|
|
radial_mask = (r >= r_inner) & (r <= r_outer)
|
|
|
|
# Find nodes in radial range
|
|
radial_nids = np.array(nids)[radial_mask]
|
|
radial_coords = coords[radial_mask]
|
|
|
|
if len(radial_coords) == 0:
|
|
raise ValueError(f"No nodes found in radial range [{r_inner}, {r_outer}]")
|
|
|
|
# The optical surface should have a relatively small Z range
|
|
z_vals = radial_coords[:, 2]
|
|
z_mean = np.mean(z_vals)
|
|
|
|
# Filter to nodes within z_tolerance of the mean Z
|
|
z_mask = np.abs(radial_coords[:, 2] - z_mean) < z_tolerance
|
|
|
|
surface_nids = radial_nids[z_mask].tolist()
|
|
surface_coords = radial_coords[z_mask]
|
|
|
|
return surface_nids, surface_coords
|
|
|
|
|
|
def extract_displacement_field(
|
|
op2_path: Path,
|
|
bdf_path: Optional[Path] = None,
|
|
r_inner: float = 100.0,
|
|
r_outer: float = 650.0,
|
|
subcases: Optional[List[int]] = None,
|
|
verbose: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract full displacement field for GNN training.
|
|
|
|
This function extracts Z-displacement from OP2 files for nodes on the optical
|
|
surface (defined by radial position). It builds node coordinates directly from
|
|
the OP2 data matched against BDF geometry, then filters by radial position.
|
|
|
|
Args:
|
|
op2_path: Path to OP2 file
|
|
bdf_path: Path to BDF/DAT file (auto-detected if None)
|
|
r_inner: Inner radius for surface identification (mm)
|
|
r_outer: Outer radius for surface identification (mm)
|
|
subcases: List of subcases to extract (default: [1, 2, 3, 4])
|
|
verbose: Print progress messages
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- node_ids: List of node IDs on optical surface
|
|
- node_coords: Array [n_nodes, 3] of coordinates
|
|
- z_displacement: Dict mapping subcase -> [n_nodes] Z-displacements
|
|
- metadata: Extraction metadata
|
|
"""
|
|
op2_path = Path(op2_path)
|
|
|
|
# Find BDF file
|
|
if bdf_path is None:
|
|
for ext in ['.dat', '.bdf']:
|
|
candidate = op2_path.with_suffix(ext)
|
|
if candidate.exists():
|
|
bdf_path = candidate
|
|
break
|
|
if bdf_path is None:
|
|
raise FileNotFoundError(f"No .dat or .bdf found for {op2_path}")
|
|
|
|
if subcases is None:
|
|
subcases = [1, 2, 3, 4]
|
|
|
|
if verbose:
|
|
print(f"[FIELD] Reading geometry from: {bdf_path.name}")
|
|
|
|
# Read geometry from BDF
|
|
bdf = BDF()
|
|
bdf.read_bdf(str(bdf_path))
|
|
node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
|
|
|
|
if verbose:
|
|
print(f"[FIELD] Total nodes in BDF: {len(node_geo)}")
|
|
|
|
# Read OP2
|
|
if verbose:
|
|
print(f"[FIELD] Reading displacements from: {op2_path.name}")
|
|
op2 = OP2()
|
|
op2.read_op2(str(op2_path))
|
|
|
|
if not op2.displacements:
|
|
raise RuntimeError("No displacement data in OP2")
|
|
|
|
# Extract data by iterating through OP2 nodes and matching to BDF geometry
|
|
# This approach works even when node numbering differs between sources
|
|
subcase_data = {}
|
|
|
|
for key, darr in op2.displacements.items():
|
|
isub = int(getattr(darr, 'isubcase', key))
|
|
if isub not in subcases:
|
|
continue
|
|
|
|
data = darr.data
|
|
dmat = data[0] if data.ndim == 3 else data
|
|
ngt = darr.node_gridtype
|
|
op2_node_ids = ngt[:, 0] if ngt.ndim == 2 else ngt
|
|
|
|
# Build arrays of matched data
|
|
nids = []
|
|
X = []
|
|
Y = []
|
|
Z = []
|
|
disp_z = []
|
|
|
|
for i, nid in enumerate(op2_node_ids):
|
|
nid_int = int(nid)
|
|
if nid_int in node_geo:
|
|
pos = node_geo[nid_int]
|
|
nids.append(nid_int)
|
|
X.append(pos[0])
|
|
Y.append(pos[1])
|
|
Z.append(pos[2])
|
|
disp_z.append(float(dmat[i, 2])) # Z component
|
|
|
|
X = np.array(X, dtype=np.float32)
|
|
Y = np.array(Y, dtype=np.float32)
|
|
Z = np.array(Z, dtype=np.float32)
|
|
disp_z = np.array(disp_z, dtype=np.float32)
|
|
nids = np.array(nids, dtype=np.int32)
|
|
|
|
# Filter to optical surface by radial position
|
|
r = np.sqrt(X**2 + Y**2)
|
|
surface_mask = (r >= r_inner) & (r <= r_outer)
|
|
|
|
subcase_data[isub] = {
|
|
'node_ids': nids[surface_mask],
|
|
'coords': np.column_stack([X[surface_mask], Y[surface_mask], Z[surface_mask]]),
|
|
'disp_z': disp_z[surface_mask],
|
|
}
|
|
|
|
if verbose:
|
|
print(f"[FIELD] Subcase {isub}: {len(nids)} matched, {np.sum(surface_mask)} on surface")
|
|
|
|
# Get common nodes across all subcases (should be the same)
|
|
all_subcase_keys = list(subcase_data.keys())
|
|
if not all_subcase_keys:
|
|
raise RuntimeError("No subcases found in OP2")
|
|
|
|
# Use first subcase to define node list
|
|
ref_subcase = all_subcase_keys[0]
|
|
surface_nids = subcase_data[ref_subcase]['node_ids'].tolist()
|
|
surface_coords = subcase_data[ref_subcase]['coords']
|
|
|
|
# Build displacement dict for all subcases
|
|
z_displacement = {}
|
|
for isub in subcases:
|
|
if isub in subcase_data:
|
|
z_displacement[isub] = subcase_data[isub]['disp_z']
|
|
|
|
if verbose:
|
|
print(f"[FIELD] Final surface: {len(surface_nids)} nodes")
|
|
r_surface = np.sqrt(surface_coords[:, 0]**2 + surface_coords[:, 1]**2)
|
|
print(f"[FIELD] Radial range: [{r_surface.min():.1f}, {r_surface.max():.1f}] mm")
|
|
|
|
# Build metadata
|
|
metadata = {
|
|
'extraction_timestamp': datetime.now().isoformat(),
|
|
'op2_file': str(op2_path.name),
|
|
'bdf_file': str(bdf_path.name),
|
|
'n_nodes': len(surface_nids),
|
|
'r_inner': r_inner,
|
|
'r_outer': r_outer,
|
|
'subcases': list(z_displacement.keys()),
|
|
}
|
|
|
|
return {
|
|
'node_ids': surface_nids,
|
|
'node_coords': surface_coords,
|
|
'z_displacement': z_displacement,
|
|
'metadata': metadata,
|
|
}
|
|
|
|
|
|
def save_field_to_hdf5(
|
|
field_data: Dict[str, Any],
|
|
output_path: Path,
|
|
compression: str = 'gzip'
|
|
) -> None:
|
|
"""
|
|
Save displacement field data to HDF5 file.
|
|
|
|
Args:
|
|
field_data: Output from extract_displacement_field()
|
|
output_path: Path to save HDF5 file
|
|
compression: Compression algorithm ('gzip', 'lzf', or None)
|
|
"""
|
|
if not HAS_H5PY:
|
|
raise ImportError("h5py required for HDF5 storage: pip install h5py")
|
|
|
|
output_path = Path(output_path)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with h5py.File(output_path, 'w') as f:
|
|
# Node data
|
|
f.create_dataset('node_ids', data=np.array(field_data['node_ids'], dtype=np.int32),
|
|
compression=compression)
|
|
f.create_dataset('node_coords', data=field_data['node_coords'].astype(np.float32),
|
|
compression=compression)
|
|
|
|
# Displacement for each subcase
|
|
for subcase, z_disp in field_data['z_displacement'].items():
|
|
f.create_dataset(f'subcase_{subcase}', data=z_disp.astype(np.float32),
|
|
compression=compression)
|
|
|
|
# Metadata as JSON string
|
|
f.attrs['metadata'] = json.dumps(field_data['metadata'])
|
|
|
|
# Report file size
|
|
size_kb = output_path.stat().st_size / 1024
|
|
print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")
|
|
|
|
|
|
def load_field_from_hdf5(hdf5_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Load displacement field data from HDF5 file.
|
|
|
|
Args:
|
|
hdf5_path: Path to HDF5 file
|
|
|
|
Returns:
|
|
Dictionary with same structure as extract_displacement_field()
|
|
"""
|
|
if not HAS_H5PY:
|
|
raise ImportError("h5py required for HDF5 storage: pip install h5py")
|
|
|
|
with h5py.File(hdf5_path, 'r') as f:
|
|
node_ids = f['node_ids'][:].tolist()
|
|
node_coords = f['node_coords'][:]
|
|
|
|
# Load subcases
|
|
z_displacement = {}
|
|
for key in f.keys():
|
|
if key.startswith('subcase_'):
|
|
subcase = int(key.split('_')[1])
|
|
z_displacement[subcase] = f[key][:]
|
|
|
|
metadata = json.loads(f.attrs['metadata'])
|
|
|
|
return {
|
|
'node_ids': node_ids,
|
|
'node_coords': node_coords,
|
|
'z_displacement': z_displacement,
|
|
'metadata': metadata,
|
|
}
|
|
|
|
|
|
def save_field_to_npz(
|
|
field_data: Dict[str, Any],
|
|
output_path: Path
|
|
) -> None:
|
|
"""
|
|
Save displacement field data to compressed NPZ file (fallback if no h5py).
|
|
|
|
Args:
|
|
field_data: Output from extract_displacement_field()
|
|
output_path: Path to save NPZ file
|
|
"""
|
|
output_path = Path(output_path)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
save_dict = {
|
|
'node_ids': np.array(field_data['node_ids'], dtype=np.int32),
|
|
'node_coords': field_data['node_coords'].astype(np.float32),
|
|
'metadata_json': np.array([json.dumps(field_data['metadata'])]),
|
|
}
|
|
|
|
# Add subcases
|
|
for subcase, z_disp in field_data['z_displacement'].items():
|
|
save_dict[f'subcase_{subcase}'] = z_disp.astype(np.float32)
|
|
|
|
np.savez_compressed(output_path, **save_dict)
|
|
|
|
size_kb = output_path.stat().st_size / 1024
|
|
print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")
|
|
|
|
|
|
def load_field_from_npz(npz_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Load displacement field data from NPZ file.
|
|
|
|
Args:
|
|
npz_path: Path to NPZ file
|
|
|
|
Returns:
|
|
Dictionary with same structure as extract_displacement_field()
|
|
"""
|
|
data = np.load(npz_path, allow_pickle=True)
|
|
|
|
node_ids = data['node_ids'].tolist()
|
|
node_coords = data['node_coords']
|
|
metadata = json.loads(str(data['metadata_json'][0]))
|
|
|
|
# Load subcases
|
|
z_displacement = {}
|
|
for key in data.keys():
|
|
if key.startswith('subcase_'):
|
|
subcase = int(key.split('_')[1])
|
|
z_displacement[subcase] = data[key]
|
|
|
|
return {
|
|
'node_ids': node_ids,
|
|
'node_coords': node_coords,
|
|
'z_displacement': z_displacement,
|
|
'metadata': metadata,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Convenience functions
|
|
# =============================================================================
|
|
|
|
def save_field(field_data: Dict[str, Any], output_path: Path) -> None:
|
|
"""Save field data using best available format (HDF5 preferred)."""
|
|
output_path = Path(output_path)
|
|
if HAS_H5PY and output_path.suffix == '.h5':
|
|
save_field_to_hdf5(field_data, output_path)
|
|
else:
|
|
if output_path.suffix != '.npz':
|
|
output_path = output_path.with_suffix('.npz')
|
|
save_field_to_npz(field_data, output_path)
|
|
|
|
|
|
def load_field(path: Path) -> Dict[str, Any]:
|
|
"""Load field data from HDF5 or NPZ file."""
|
|
path = Path(path)
|
|
if path.suffix == '.h5':
|
|
return load_field_from_hdf5(path)
|
|
else:
|
|
return load_field_from_npz(path)
|
|
|
|
|
|
# =============================================================================
|
|
# CLI
|
|
# =============================================================================
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='Extract displacement field from Nastran OP2 for GNN training'
|
|
)
|
|
parser.add_argument('op2_path', type=Path, help='Path to OP2 file')
|
|
parser.add_argument('-o', '--output', type=Path, help='Output path (default: same dir as OP2)')
|
|
parser.add_argument('--r-inner', type=float, default=100.0, help='Inner radius (mm)')
|
|
parser.add_argument('--r-outer', type=float, default=650.0, help='Outer radius (mm)')
|
|
parser.add_argument('--format', choices=['h5', 'npz'], default='h5',
|
|
help='Output format (default: h5)')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Extract field
|
|
field_data = extract_displacement_field(
|
|
args.op2_path,
|
|
r_inner=args.r_inner,
|
|
r_outer=args.r_outer,
|
|
)
|
|
|
|
# Determine output path
|
|
if args.output:
|
|
output_path = args.output
|
|
else:
|
|
output_path = args.op2_path.parent / f'displacement_field.{args.format}'
|
|
|
|
# Save
|
|
save_field(field_data, output_path)
|
|
|
|
# Print summary
|
|
print("\n" + "="*60)
|
|
print("EXTRACTION SUMMARY")
|
|
print("="*60)
|
|
print(f"Nodes: {len(field_data['node_ids'])}")
|
|
print(f"Subcases: {list(field_data['z_displacement'].keys())}")
|
|
for sc, disp in field_data['z_displacement'].items():
|
|
print(f" Subcase {sc}: Z range [{disp.min():.4f}, {disp.max():.4f}] mm")
|