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:
2026-01-13 15:53:55 -05:00
parent 69c0d76b50
commit 73a7b9d9f1
1680 changed files with 144922 additions and 723 deletions

View File

@@ -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',