feat: Add dashboard chat integration and MCP server
Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ WHY THIS IS THE MOST ROBUST:
|
||||
3. Uses the actual mesh geometry as ground truth
|
||||
4. Eliminates errors from prescription/shape approximations
|
||||
5. Properly accounts for lateral (X, Y) displacement via interpolation
|
||||
6. Supports ANNULAR APERTURES (mirrors with central holes)
|
||||
|
||||
HOW IT WORKS:
|
||||
-------------
|
||||
@@ -25,23 +26,37 @@ reference surface (i.e., the optical figure before deformation).
|
||||
- Interpolate z_figure at the deformed (x,y) position
|
||||
- Surface error = (z0 + dz) - z_interpolated
|
||||
4. Fit Zernike polynomials to the surface error map
|
||||
5. For annular apertures: exclude central hole from fitting & RMS calculations
|
||||
|
||||
The interpolation accounts for the fact that when a node moves laterally,
|
||||
it should be compared against the figure height at its NEW position.
|
||||
|
||||
ANNULAR APERTURE SUPPORT:
|
||||
-------------------------
|
||||
For mirrors with central holes (e.g., M1 Mirror with 271.5mm diameter hole):
|
||||
- Set inner_radius to exclude the central obscuration from Zernike fitting
|
||||
- Standard Zernike polynomials are fit only to the annular region
|
||||
- RMS calculations are computed only over the annular aperture
|
||||
|
||||
USAGE:
|
||||
------
|
||||
from optimization_engine.extractors import ZernikeOPDExtractor
|
||||
|
||||
# Standard usage (full disk)
|
||||
extractor = ZernikeOPDExtractor(op2_file)
|
||||
result = extractor.extract_subcase('20')
|
||||
|
||||
# With annular aperture (e.g., 271.5mm diameter central hole)
|
||||
extractor = ZernikeOPDExtractor(op2_file, inner_radius=135.75)
|
||||
result = extractor.extract_subcase('20')
|
||||
|
||||
# Simple convenience function
|
||||
from optimization_engine.extractors import extract_zernike_opd
|
||||
result = extract_zernike_opd(op2_file, subcase='20')
|
||||
result = extract_zernike_opd(op2_file, subcase='20', inner_radius=135.75)
|
||||
|
||||
Author: Atomizer Framework
|
||||
Date: 2024
|
||||
Updated: 2025-01-06 - Added annular aperture support
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -70,6 +85,101 @@ except ImportError:
|
||||
BDF = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Annular Aperture Support
|
||||
# ============================================================================
|
||||
|
||||
def compute_zernike_coefficients_annular(
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
values: np.ndarray,
|
||||
n_modes: int,
|
||||
inner_radius: float,
|
||||
chunk_size: int = 100000
|
||||
) -> Tuple[np.ndarray, float, float, np.ndarray]:
|
||||
"""
|
||||
Fit Zernike coefficients with ANNULAR APERTURE masking.
|
||||
|
||||
Points inside the central obscuration (r < inner_radius) are EXCLUDED
|
||||
from the least-squares fitting. This is the correct approach for mirrors
|
||||
with central holes where no actual surface exists.
|
||||
|
||||
Args:
|
||||
x, y: Node coordinates (in same units as inner_radius, typically mm)
|
||||
values: Surface values at each node (e.g., WFE in nm)
|
||||
n_modes: Number of Zernike modes to fit
|
||||
inner_radius: Inner radius of annular aperture (same units as x, y)
|
||||
chunk_size: Chunk size for memory-efficient processing
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- coefficients: Zernike coefficients
|
||||
- R_outer: Outer radius (normalization radius)
|
||||
- r_inner_normalized: Inner radius normalized to unit disk
|
||||
- annular_mask: Boolean mask for points in annular region
|
||||
"""
|
||||
from numpy.linalg import LinAlgError
|
||||
|
||||
# Center coordinates
|
||||
x_centered = x - np.mean(x)
|
||||
y_centered = y - np.mean(y)
|
||||
|
||||
# Compute radial distance (in original units, before normalization)
|
||||
r_physical = np.hypot(x_centered, y_centered)
|
||||
|
||||
# Outer radius for normalization
|
||||
R_outer = float(np.max(r_physical))
|
||||
|
||||
# Normalize to unit disk
|
||||
r = (r_physical / R_outer).astype(np.float32)
|
||||
theta = np.arctan2(y_centered, x_centered).astype(np.float32)
|
||||
|
||||
# Compute normalized inner radius
|
||||
r_inner_normalized = inner_radius / R_outer
|
||||
|
||||
# ANNULAR MASK: r must be between inner and outer radius
|
||||
# Points inside central hole are EXCLUDED from fitting
|
||||
annular_mask = (r >= r_inner_normalized) & (r <= 1.0) & ~np.isnan(values)
|
||||
|
||||
if not np.any(annular_mask):
|
||||
raise RuntimeError(
|
||||
f"No valid points in annular region for Zernike fitting. "
|
||||
f"Inner radius = {inner_radius:.2f}, Outer radius = {R_outer:.2f}"
|
||||
)
|
||||
|
||||
idx = np.nonzero(annular_mask)[0]
|
||||
m = int(n_modes)
|
||||
|
||||
# Normal equations: (Z^T Z) c = Z^T v
|
||||
G = np.zeros((m, m), dtype=np.float64)
|
||||
h = np.zeros((m,), dtype=np.float64)
|
||||
v = values.astype(np.float64)
|
||||
|
||||
for start in range(0, len(idx), chunk_size):
|
||||
chunk_idx = idx[start:start + chunk_size]
|
||||
r_chunk = r[chunk_idx]
|
||||
theta_chunk = theta[chunk_idx]
|
||||
v_chunk = v[chunk_idx]
|
||||
|
||||
# Build Zernike basis for this chunk
|
||||
Z_chunk = np.column_stack([
|
||||
zernike_noll(j, r_chunk, theta_chunk).astype(np.float32)
|
||||
for j in range(1, m + 1)
|
||||
])
|
||||
|
||||
# Accumulate normal equations
|
||||
G += (Z_chunk.T @ Z_chunk).astype(np.float64)
|
||||
h += (Z_chunk.T @ v_chunk).astype(np.float64)
|
||||
|
||||
# Solve normal equations
|
||||
try:
|
||||
coeffs = np.linalg.solve(G, h)
|
||||
except LinAlgError:
|
||||
coeffs = np.linalg.lstsq(G, h, rcond=None)[0]
|
||||
|
||||
return coeffs, R_outer, r_inner_normalized, annular_mask
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Figure Geometry Parser
|
||||
# ============================================================================
|
||||
@@ -299,16 +409,25 @@ class ZernikeOPDExtractor:
|
||||
2. Accounts for lateral displacement via interpolation
|
||||
3. Works with any surface shape (parabola, hyperbola, asphere, freeform)
|
||||
4. No need to know focal length or optical prescription
|
||||
5. Supports annular apertures (mirrors with central holes)
|
||||
|
||||
The extractor works in two modes:
|
||||
1. Default: Uses BDF geometry for nodes present in OP2 (RECOMMENDED)
|
||||
2. With figure_file: Uses explicit figure.dat for reference geometry
|
||||
|
||||
For annular apertures (mirrors with central holes):
|
||||
- Set inner_radius to exclude the central obscuration from fitting
|
||||
- Example: M1 Mirror with 271.5mm diameter hole -> inner_radius=135.75
|
||||
|
||||
Example:
|
||||
# Simple usage - BDF geometry filtered to OP2 nodes (RECOMMENDED)
|
||||
extractor = ZernikeOPDExtractor(op2_file)
|
||||
results = extractor.extract_all_subcases()
|
||||
|
||||
# With annular aperture (central hole)
|
||||
extractor = ZernikeOPDExtractor(op2_file, inner_radius=135.75)
|
||||
results = extractor.extract_all_subcases()
|
||||
|
||||
# With explicit figure file (advanced - ensure coordinates match!)
|
||||
extractor = ZernikeOPDExtractor(op2_file, figure_file='figure.dat')
|
||||
"""
|
||||
@@ -321,7 +440,8 @@ class ZernikeOPDExtractor:
|
||||
displacement_unit: str = 'mm',
|
||||
n_modes: int = DEFAULT_N_MODES,
|
||||
filter_orders: int = DEFAULT_FILTER_ORDERS,
|
||||
interpolation_method: str = 'linear'
|
||||
interpolation_method: str = 'linear',
|
||||
inner_radius: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
Initialize figure-based Zernike extractor.
|
||||
@@ -335,6 +455,9 @@ class ZernikeOPDExtractor:
|
||||
n_modes: Number of Zernike modes to fit
|
||||
filter_orders: Number of low-order modes to filter for RMS
|
||||
interpolation_method: 'linear' or 'cubic' for figure interpolation
|
||||
inner_radius: Inner radius of central hole for annular apertures (same
|
||||
units as geometry, typically mm). If None, full disk is used.
|
||||
Example: For M1 Mirror with 271.5mm diameter hole, use 135.75
|
||||
"""
|
||||
self.op2_path = Path(op2_path)
|
||||
self.figure_path = Path(figure_path) if figure_path else None
|
||||
@@ -343,7 +466,9 @@ class ZernikeOPDExtractor:
|
||||
self.n_modes = n_modes
|
||||
self.filter_orders = filter_orders
|
||||
self.interpolation_method = interpolation_method
|
||||
self.inner_radius = inner_radius
|
||||
self.use_explicit_figure = figure_path is not None
|
||||
self.use_annular = inner_radius is not None and inner_radius > 0
|
||||
|
||||
# Unit conversion
|
||||
self.nm_scale = UNIT_TO_NM.get(displacement_unit.lower(), 1e6)
|
||||
@@ -509,8 +634,18 @@ class ZernikeOPDExtractor:
|
||||
Y = opd_data['y_deformed']
|
||||
WFE = opd_data['wfe_nm']
|
||||
|
||||
# Fit Zernike coefficients
|
||||
coeffs, R_max = compute_zernike_coefficients(X, Y, WFE, self.n_modes)
|
||||
# Fit Zernike coefficients (with or without annular masking)
|
||||
if self.use_annular:
|
||||
coeffs, R_outer, r_inner_norm, annular_mask = compute_zernike_coefficients_annular(
|
||||
X, Y, WFE, self.n_modes, self.inner_radius
|
||||
)
|
||||
R_max = R_outer
|
||||
n_annular = int(np.sum(annular_mask))
|
||||
else:
|
||||
coeffs, R_max = compute_zernike_coefficients(X, Y, WFE, self.n_modes)
|
||||
annular_mask = np.ones(len(X), dtype=bool) # All points included
|
||||
r_inner_norm = 0.0
|
||||
n_annular = len(X)
|
||||
|
||||
# Compute RMS metrics
|
||||
x_c = X - np.mean(X)
|
||||
@@ -526,38 +661,49 @@ class ZernikeOPDExtractor:
|
||||
# Filtered WFE (J5+)
|
||||
wfe_filtered = WFE - Z_low @ coeffs[:self.filter_orders]
|
||||
|
||||
global_rms = float(np.sqrt(np.mean(WFE**2)))
|
||||
filtered_rms = float(np.sqrt(np.mean(wfe_filtered**2)))
|
||||
|
||||
# J1-J3 filtered (for manufacturing/optician)
|
||||
Z_j1to3 = np.column_stack([
|
||||
zernike_noll(j, r, theta) for j in range(1, 4)
|
||||
])
|
||||
wfe_j1to3 = WFE - Z_j1to3 @ coeffs[:3]
|
||||
rms_j1to3 = float(np.sqrt(np.mean(wfe_j1to3**2)))
|
||||
|
||||
# Compute RMS ONLY over annular region (or full disk if not annular)
|
||||
global_rms = float(np.sqrt(np.mean(WFE[annular_mask]**2)))
|
||||
filtered_rms = float(np.sqrt(np.mean(wfe_filtered[annular_mask]**2)))
|
||||
rms_j1to3 = float(np.sqrt(np.mean(wfe_j1to3[annular_mask]**2)))
|
||||
|
||||
# Aberration magnitudes
|
||||
aberrations = compute_aberration_magnitudes(coeffs)
|
||||
|
||||
result = {
|
||||
'subcase': subcase_label,
|
||||
'method': 'figure_opd',
|
||||
'method': 'figure_opd_annular' if self.use_annular else 'figure_opd',
|
||||
'rms_wfe_nm': filtered_rms, # Primary metric (J5+)
|
||||
'global_rms_nm': global_rms,
|
||||
'filtered_rms_nm': filtered_rms,
|
||||
'rms_filter_j1to3_nm': rms_j1to3,
|
||||
'n_nodes': len(X),
|
||||
'n_annular_nodes': n_annular,
|
||||
'n_figure_nodes': opd_data['n_figure_nodes'],
|
||||
'figure_file': str(self.figure_path.name) if self.figure_path else 'BDF (filtered to OP2)',
|
||||
**aberrations
|
||||
}
|
||||
|
||||
# Add annular aperture info
|
||||
if self.use_annular:
|
||||
result.update({
|
||||
'inner_radius': self.inner_radius,
|
||||
'outer_radius': R_max,
|
||||
'obscuration_ratio': r_inner_norm,
|
||||
})
|
||||
|
||||
if include_diagnostics:
|
||||
lateral = opd_data['lateral_disp']
|
||||
# Compute lateral displacement stats over annular region
|
||||
result.update({
|
||||
'max_lateral_displacement_um': float(np.max(lateral) * self.um_scale),
|
||||
'rms_lateral_displacement_um': float(np.sqrt(np.mean(lateral**2)) * self.um_scale),
|
||||
'mean_lateral_displacement_um': float(np.mean(lateral) * self.um_scale),
|
||||
'max_lateral_displacement_um': float(np.max(lateral[annular_mask]) * self.um_scale),
|
||||
'rms_lateral_displacement_um': float(np.sqrt(np.mean(lateral[annular_mask]**2)) * self.um_scale),
|
||||
'mean_lateral_displacement_um': float(np.mean(lateral[annular_mask]) * self.um_scale),
|
||||
})
|
||||
|
||||
if include_coefficients:
|
||||
@@ -595,6 +741,8 @@ class ZernikeOPDExtractor:
|
||||
This is the CORRECT way to compute relative WFE for optimization.
|
||||
It properly accounts for lateral (X,Y) displacement via OPD interpolation.
|
||||
|
||||
For annular apertures, the central hole is excluded from fitting and RMS.
|
||||
|
||||
Args:
|
||||
target_subcase: Subcase to analyze (e.g., "3" for 40 deg)
|
||||
reference_subcase: Reference subcase to subtract (e.g., "2" for 20 deg)
|
||||
@@ -640,8 +788,18 @@ class ZernikeOPDExtractor:
|
||||
if len(X_rel) == 0:
|
||||
raise ValueError(f"No common nodes between subcases {target_subcase} and {reference_subcase}")
|
||||
|
||||
# Fit Zernike coefficients to the relative WFE
|
||||
coeffs, R_max = compute_zernike_coefficients(X_rel, Y_rel, WFE_rel, self.n_modes)
|
||||
# Fit Zernike coefficients to the relative WFE (with or without annular masking)
|
||||
if self.use_annular:
|
||||
coeffs, R_outer, r_inner_norm, annular_mask = compute_zernike_coefficients_annular(
|
||||
X_rel, Y_rel, WFE_rel, self.n_modes, self.inner_radius
|
||||
)
|
||||
R_max = R_outer
|
||||
n_annular = int(np.sum(annular_mask))
|
||||
else:
|
||||
coeffs, R_max = compute_zernike_coefficients(X_rel, Y_rel, WFE_rel, self.n_modes)
|
||||
annular_mask = np.ones(len(X_rel), dtype=bool)
|
||||
r_inner_norm = 0.0
|
||||
n_annular = len(X_rel)
|
||||
|
||||
# Compute RMS metrics
|
||||
x_c = X_rel - np.mean(X_rel)
|
||||
@@ -657,15 +815,16 @@ class ZernikeOPDExtractor:
|
||||
# Filtered WFE (J5+) - this is the primary optimization metric
|
||||
wfe_filtered = WFE_rel - Z_low @ coeffs[:self.filter_orders]
|
||||
|
||||
global_rms = float(np.sqrt(np.mean(WFE_rel**2)))
|
||||
filtered_rms = float(np.sqrt(np.mean(wfe_filtered**2)))
|
||||
|
||||
# J1-J3 filtered (for manufacturing/optician workload)
|
||||
Z_j1to3 = np.column_stack([
|
||||
zernike_noll(j, r, theta) for j in range(1, 4)
|
||||
])
|
||||
wfe_j1to3 = WFE_rel - Z_j1to3 @ coeffs[:3]
|
||||
rms_j1to3 = float(np.sqrt(np.mean(wfe_j1to3**2)))
|
||||
|
||||
# Compute RMS ONLY over annular region (or full disk if not annular)
|
||||
global_rms = float(np.sqrt(np.mean(WFE_rel[annular_mask]**2)))
|
||||
filtered_rms = float(np.sqrt(np.mean(wfe_filtered[annular_mask]**2)))
|
||||
rms_j1to3 = float(np.sqrt(np.mean(wfe_j1to3[annular_mask]**2)))
|
||||
|
||||
# Aberration magnitudes
|
||||
aberrations = compute_aberration_magnitudes(coeffs)
|
||||
@@ -673,16 +832,25 @@ class ZernikeOPDExtractor:
|
||||
result = {
|
||||
'target_subcase': target_subcase,
|
||||
'reference_subcase': reference_subcase,
|
||||
'method': 'figure_opd_relative',
|
||||
'method': 'figure_opd_relative_annular' if self.use_annular else 'figure_opd_relative',
|
||||
'relative_global_rms_nm': global_rms,
|
||||
'relative_filtered_rms_nm': filtered_rms,
|
||||
'relative_rms_filter_j1to3': rms_j1to3,
|
||||
'n_common_nodes': len(X_rel),
|
||||
'max_lateral_displacement_um': float(np.max(lateral_rel) * self.um_scale),
|
||||
'rms_lateral_displacement_um': float(np.sqrt(np.mean(lateral_rel**2)) * self.um_scale),
|
||||
'n_annular_nodes': n_annular,
|
||||
'max_lateral_displacement_um': float(np.max(lateral_rel[annular_mask]) * self.um_scale),
|
||||
'rms_lateral_displacement_um': float(np.sqrt(np.mean(lateral_rel[annular_mask]**2)) * self.um_scale),
|
||||
**{f'relative_{k}': v for k, v in aberrations.items()}
|
||||
}
|
||||
|
||||
# Add annular aperture info
|
||||
if self.use_annular:
|
||||
result.update({
|
||||
'inner_radius': self.inner_radius,
|
||||
'outer_radius': R_max,
|
||||
'obscuration_ratio': r_inner_norm,
|
||||
})
|
||||
|
||||
if include_coefficients:
|
||||
result['coefficients'] = coeffs.tolist()
|
||||
result['coefficient_labels'] = [
|
||||
@@ -740,6 +908,7 @@ def extract_zernike_opd(
|
||||
op2_file: Union[str, Path],
|
||||
figure_file: Optional[Union[str, Path]] = None,
|
||||
subcase: Union[int, str] = 1,
|
||||
inner_radius: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -751,26 +920,45 @@ def extract_zernike_opd(
|
||||
op2_file: Path to OP2 results
|
||||
figure_file: Path to explicit figure.dat (uses BDF geometry if None - RECOMMENDED)
|
||||
subcase: Subcase identifier
|
||||
inner_radius: Inner radius of central hole for annular apertures (mm).
|
||||
If None, full disk is used.
|
||||
Example: For M1 Mirror with 271.5mm diameter hole, use 135.75
|
||||
**kwargs: Additional args for ZernikeOPDExtractor
|
||||
|
||||
Returns:
|
||||
Dict with Zernike metrics, aberrations, and lateral displacement info
|
||||
"""
|
||||
extractor = ZernikeOPDExtractor(op2_file, figure_path=figure_file, **kwargs)
|
||||
extractor = ZernikeOPDExtractor(
|
||||
op2_file,
|
||||
figure_path=figure_file,
|
||||
inner_radius=inner_radius,
|
||||
**kwargs
|
||||
)
|
||||
return extractor.extract_subcase(str(subcase))
|
||||
|
||||
|
||||
def extract_zernike_opd_filtered_rms(
|
||||
op2_file: Union[str, Path],
|
||||
subcase: Union[int, str] = 1,
|
||||
inner_radius: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> float:
|
||||
"""
|
||||
Extract filtered RMS WFE using OPD method (most rigorous).
|
||||
|
||||
Primary metric for mirror optimization.
|
||||
|
||||
Args:
|
||||
op2_file: Path to OP2 results
|
||||
subcase: Subcase identifier
|
||||
inner_radius: Inner radius of central hole for annular apertures (mm).
|
||||
If None, full disk is used.
|
||||
**kwargs: Additional args for ZernikeOPDExtractor
|
||||
|
||||
Returns:
|
||||
Filtered RMS WFE in nanometers
|
||||
"""
|
||||
result = extract_zernike_opd(op2_file, subcase=subcase, **kwargs)
|
||||
result = extract_zernike_opd(op2_file, subcase=subcase, inner_radius=inner_radius, **kwargs)
|
||||
return result['filtered_rms_nm']
|
||||
|
||||
|
||||
@@ -790,6 +978,9 @@ __all__ = [
|
||||
'extract_zernike_opd',
|
||||
'extract_zernike_opd_filtered_rms',
|
||||
|
||||
# Annular aperture support
|
||||
'compute_zernike_coefficients_annular',
|
||||
|
||||
# Utility functions
|
||||
'load_figure_geometry',
|
||||
'build_figure_interpolator',
|
||||
|
||||
Reference in New Issue
Block a user