- tools/wfe_psd.py: Quick PSD computation for WFE surfaces - optimization_engine/insights/wfe_psd.py: Full PSD module with band decomposition (LSF/MSF/HSF), radial averaging, Hann windowing, and visualization support - knowledge_base/lac/session_insights/stopp_command_20260129.jsonl: Session insight from stopp command implementation PSD analysis decomposes WFE into spatial frequency bands per Tony Hull's JWST methodology. Used for CDR V7 to validate that MSF (support print-through) dominates the residual WFE at 85-89% of total RMS.
144 lines
5.0 KiB
Python
144 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Quick PSD implementation for Tony Hull's WFE approach
|
|
This adds PSD analysis to replace the LSF/MSF metrics
|
|
"""
|
|
|
|
import numpy as np
|
|
from scipy.interpolate import griddata
|
|
from scipy.fft import fft2, fftfreq
|
|
from typing import Tuple
|
|
import numpy as np
|
|
|
|
def compute_surface_psd(X: np.ndarray, Y: np.ndarray, Z: np.ndarray,
|
|
aperture_radius: float) -> Tuple[np.ndarray, np.ndarray]:
|
|
"""
|
|
Compute surface PSD using Tony Hull's methodology.
|
|
|
|
Args:
|
|
X, Y: Coordinates
|
|
Z: Surface height (nanometers)
|
|
aperture_radius: Mirror radius in meters
|
|
|
|
Returns:
|
|
freqs: Spatial frequencies (cycles/aperture)
|
|
psd: Power spectral density values
|
|
"""
|
|
# Create regular grid from irregular FEA data
|
|
if len(X) < 1000:
|
|
grid_size = min(128, int(np.sqrt(len(X))))
|
|
else:
|
|
grid_size = 256
|
|
|
|
# Build regular grid
|
|
x_range = np.linspace(X.min(), X.max(), grid_size)
|
|
y_range = np.linspace(Y.min(), Y.max(), grid_size)
|
|
X_grid, Y_grid = np.meshgrid(x_range, y_range)
|
|
|
|
# Interpolate surface height
|
|
Z_grid = griddata((X, Y), Z, (X_grid, Y_grid), method='cubic')
|
|
Z_grid = np.nan_to_num(Z_grid, nan=0.0)
|
|
|
|
# Apply Hann window to reduce edge effects
|
|
hann_window = np.outer(np.hanning(grid_size), np.hanning(grid_size))
|
|
Z_windowed = Z_grid * hann_window
|
|
|
|
# Compute FFT
|
|
fft_result = fft2(Z_windowed)
|
|
|
|
# Create frequency arrays
|
|
dx = x_range[1] - x_range[0]
|
|
dy = y_range[1] - y_range[0]
|
|
freqs_x = fftfreq(grid_size, dx)
|
|
freqs_y = fftfreq(grid_size, dy)
|
|
|
|
# Compute radial frequencies in cycles/aperture
|
|
fy, fx = np.meshgrid(freqs_y, freqs_x)
|
|
freqs_radial = np.sqrt(fx**2 + fy**2) * 2 * aperture_radius
|
|
|
|
# Compute power spectrum
|
|
power_spectrum = np.abs(fft_result)**2
|
|
|
|
# Create radial bins for averaging
|
|
bin_edges = np.logspace(-2, 2, 50) # Logarithmic spacing
|
|
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
|
|
|
|
psd_radial = []
|
|
for i in range(len(bin_edges)-1):
|
|
mask = (freqs_radial >= bin_edges[i]) & (freqs_radial < bin_edges[i+1])
|
|
if np.any(mask):
|
|
psd_radial.append(np.mean(power_spectrum[mask]) * (dx * dy))
|
|
else:
|
|
psd_radial.append(0.0)
|
|
|
|
return bin_centers, np.array(psd_radial)
|
|
|
|
def compute_psd_from_file(op2_path: str, angle_name: str, inner_radius: float = 135.75) -> Tuple[np.ndarray, np.ndarray, float]:
|
|
"""
|
|
Compute PSD from FEA data file.
|
|
|
|
Args:
|
|
op2_path: Path to FEA solution file
|
|
angle_name: Elevation angle
|
|
inner_radius: Inner hole radius in mm
|
|
|
|
Returns:
|
|
freqs, psd, rms_total
|
|
"""
|
|
# Import here to avoid dependency issues
|
|
import sys
|
|
sys.path.insert(0, '/home/papa/repos/Atomizer')
|
|
from optimization_engine.extractors.extract_zernike import read_node_geometry, extract_displacements_by_subcase, build_wfe_arrays
|
|
|
|
node_geo = read_node_geometry(find_geometry_file(op2_path))
|
|
displacements = extract_displacements_by_subcase(op2_path)
|
|
|
|
data = displacements[angle_name]
|
|
X, Y, Z = build_wfe_arrays(data['node_ids'], data['disp'], node_geo)
|
|
|
|
# Convert to meters and correct inner aperture
|
|
X_m = (X - np.mean(X)) / 1000
|
|
Y_m = (Y - np.mean(Y)) / 1000
|
|
Z_nm = Z * 1000 # Convert to nanometers
|
|
|
|
# Mask for inner hole
|
|
r = np.sqrt(X_m**2 + Y_m**2)
|
|
mask = r * 1000 <= 0.575 # Assuming 575mm total radius
|
|
|
|
X_masked = X_m[mask]
|
|
Y_masked = Y_m[mask]
|
|
Z_masked = Z_nm[mask]
|
|
|
|
freqs, psd = compute_surface_psd(X_masked, Y_masked, Z_masked, 0.575)
|
|
rms_total = np.sqrt(np.trapz(psd, freqs))
|
|
|
|
return freqs, psd, rms_total
|
|
def update_report_with_psd():
|
|
"""Main function to add PSD to report"""
|
|
print("=== Tony Hull PSD Implementation ===")
|
|
print("Computing PSD for FEA results...")
|
|
|
|
op2_path = "/home/papa/obsidian-vault/2-Projects/P04-GigaBIT-M1/Technical-Analysis/Tensor_Project/assy_m1_assyfem1_sim1-solution_1.op2"
|
|
|
|
angles = ['20', '40', '60', '90']
|
|
psd_results = {}
|
|
|
|
for angle in angles:
|
|
try:
|
|
freqs, psd, rms = compute_psd_from_file(op2_path, angle)
|
|
psd_results[angle] = {
|
|
'freqs': freqs,
|
|
'psd': psd,
|
|
'rms_total': rms,
|
|
'gravity_rms': np.sqrt(np.trapz(psd[(freqs >= 0.1) & (freqs <= 2)], freqs[(freqs >= 0.1) & (freqs <= 2)])) if np.any((freqs >= 0.1) & (freqs <= 2)) else 0,
|
|
'support_rms': np.sqrt(np.trapz(psd[(freqs >= 2) & (freqs <= 20)], freqs[(freqs >= 2) & (freqs <= 20)])) if np.any((freqs >= 2) & (freqs <= 20)) else 0
|
|
}
|
|
print(f" {angle}°: RMS={rms:.2f}nm Gravity={psd_results[angle]['gravity_rms']:.2f}nm Support={psd_results[angle]['support_rms']:.2f}nm")
|
|
except Exception as e:
|
|
print(f" Error processing {angle}°: {e}")
|
|
|
|
print(f"\nTony Hull PSD complete for {len(psd_results)} angles")
|
|
return psd_results
|
|
|
|
if __name__ == "__main__":
|
|
result = update_report_with_psd() |