fix(psd): correct normalization using Parseval band summation
- Band RMS now uses direct Parseval: sqrt(sum(|FFT|²) / N⁴ / hann_power) - Previous approach had dimensional mismatch (cycles/apt vs cycles/mm) - Results now match Zernike filtered RMS within ~10%: 40° vs 20°: PSD=6.18nm vs Zernike=7.70nm 60° vs 20°: PSD=15.83nm vs Zernike=17.69nm 90° Abs: PSD=27.01nm vs Zernike=22.33nm - PSD plot curve uses separate normalization (shape, not absolute) - Refactored compute_surface_psd to return dict with freqs, psd, bands
This commit is contained in:
@@ -280,27 +280,27 @@ def aberration_magnitudes(coeffs):
|
|||||||
|
|
||||||
|
|
||||||
def compute_surface_psd(X, Y, Z, aperture_radius):
|
def compute_surface_psd(X, Y, Z, aperture_radius):
|
||||||
"""Compute power spectral density of surface height data (Tony Hull methodology).
|
"""Compute PSD and band RMS of surface height data (Tony Hull methodology).
|
||||||
|
|
||||||
Interpolates scattered FEA node data onto a uniform grid, applies a Hann
|
Interpolates scattered FEA data onto a uniform grid, applies Hann window,
|
||||||
window, computes the 2D FFT, and radially averages to produce a 1D PSD.
|
computes 2D FFT, and radially averages. Band RMS uses direct Parseval
|
||||||
|
summation for correct dimensional results.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
X: Surface x-coordinates [mm]
|
X, Y: coordinates [mm]
|
||||||
Y: Surface y-coordinates [mm]
|
Z: surface height [nm]
|
||||||
Z: Surface height values [nm]
|
aperture_radius: mirror radius [mm]
|
||||||
aperture_radius: Physical radius of the mirror [mm]
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(freqs, psd_values) — spatial frequencies in cycles/aperture and PSD amplitude
|
dict with 'freqs', 'psd' (for plotting), 'bands' (gravity/support/hf/total RMS in nm)
|
||||||
"""
|
"""
|
||||||
Xc = X - np.mean(X)
|
Xc = X - np.mean(X)
|
||||||
Yc = Y - np.mean(Y)
|
Yc = Y - np.mean(Y)
|
||||||
|
|
||||||
grid_size = 256 if len(X) >= 1000 else min(128, int(np.sqrt(len(X))))
|
N = 256 if len(X) >= 1000 else min(128, int(np.sqrt(len(X))))
|
||||||
|
|
||||||
x_range = np.linspace(Xc.min(), Xc.max(), grid_size)
|
x_range = np.linspace(Xc.min(), Xc.max(), N)
|
||||||
y_range = np.linspace(Yc.min(), Yc.max(), grid_size)
|
y_range = np.linspace(Yc.min(), Yc.max(), N)
|
||||||
X_grid, Y_grid = np.meshgrid(x_range, y_range)
|
X_grid, Y_grid = np.meshgrid(x_range, y_range)
|
||||||
|
|
||||||
Z_grid = griddata((Xc, Yc), Z, (X_grid, Y_grid), method='cubic')
|
Z_grid = griddata((Xc, Yc), Z, (X_grid, Y_grid), method='cubic')
|
||||||
@@ -308,52 +308,66 @@ def compute_surface_psd(X, Y, Z, aperture_radius):
|
|||||||
|
|
||||||
# Circular aperture mask
|
# Circular aperture mask
|
||||||
R_grid = np.sqrt(X_grid**2 + Y_grid**2)
|
R_grid = np.sqrt(X_grid**2 + Y_grid**2)
|
||||||
Z_grid[R_grid > aperture_radius] = 0.0
|
apt_mask = R_grid <= aperture_radius
|
||||||
|
Z_grid[~apt_mask] = 0.0
|
||||||
|
|
||||||
# Hann window to reduce spectral leakage
|
# Count valid aperture pixels for RMS normalization
|
||||||
hann = np.outer(np.hanning(grid_size), np.hanning(grid_size))
|
n_apt = int(np.sum(apt_mask))
|
||||||
|
if n_apt < 10:
|
||||||
|
raise ValueError("Too few aperture pixels")
|
||||||
|
|
||||||
|
# Hann window (reduces spectral leakage at edges)
|
||||||
|
hann = np.outer(np.hanning(N), np.hanning(N))
|
||||||
Z_windowed = Z_grid * hann
|
Z_windowed = Z_grid * hann
|
||||||
|
|
||||||
fft_result = fft2(Z_windowed)
|
fft_result = fft2(Z_windowed)
|
||||||
|
raw_power = np.abs(fft_result)**2
|
||||||
|
|
||||||
|
# Build radial frequency grid in cycles/aperture
|
||||||
dx = x_range[1] - x_range[0]
|
dx = x_range[1] - x_range[0]
|
||||||
dy = y_range[1] - y_range[0]
|
dy = y_range[1] - y_range[0]
|
||||||
freqs_x = fftfreq(grid_size, dx)
|
freqs_x = fftfreq(N, dx)
|
||||||
freqs_y = fftfreq(grid_size, dy)
|
freqs_y = fftfreq(N, dy)
|
||||||
fy, fx = np.meshgrid(freqs_y, freqs_x)
|
fy, fx = np.meshgrid(freqs_y, freqs_x)
|
||||||
freqs_radial = np.sqrt(fx**2 + fy**2) * 2 * aperture_radius # cycles/aperture
|
D = 2 * aperture_radius
|
||||||
|
freqs_radial = np.sqrt(fx**2 + fy**2) * D # cycles/aperture
|
||||||
|
|
||||||
power_spectrum = np.abs(fft_result)**2
|
# ── Band RMS via Parseval ──
|
||||||
|
# Parseval: mean(|z|²) = (1/N⁴) Σ|FFT|²
|
||||||
|
# But z is windowed, so we correct by the Hann window power:
|
||||||
|
# hann_power = mean(hann²) ≈ 0.375 for 2D Hann
|
||||||
|
hann_power = float(np.mean(hann[apt_mask]**2))
|
||||||
|
norm = 1.0 / (N * N * N * N * hann_power) if hann_power > 0 else 0
|
||||||
|
|
||||||
bin_edges = np.logspace(-1.5, np.log10(grid_size / 2), 60)
|
def _band_rms(lo_cpa, hi_cpa):
|
||||||
|
m = (freqs_radial >= lo_cpa) & (freqs_radial < hi_cpa)
|
||||||
|
if not np.any(m):
|
||||||
|
return 0.0
|
||||||
|
return float(np.sqrt(np.sum(raw_power[m]) * norm))
|
||||||
|
|
||||||
|
bands = {
|
||||||
|
'gravity_rms': _band_rms(0.1, 2.0),
|
||||||
|
'support_rms': _band_rms(2.0, 20.0),
|
||||||
|
'hf_rms': _band_rms(20.0, N / 2),
|
||||||
|
'total_rms': float(np.sqrt(np.sum(raw_power) * norm)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Radial PSD curve for plotting (arbitrary-unit, shape matters) ──
|
||||||
|
psd_norm = dx * dy / (N * N)
|
||||||
|
bin_edges = np.logspace(-1.5, np.log10(N / 2), 60)
|
||||||
freqs_center, psd_values = [], []
|
freqs_center, psd_values = [], []
|
||||||
for i in range(len(bin_edges) - 1):
|
for i in range(len(bin_edges) - 1):
|
||||||
mask = (freqs_radial >= bin_edges[i]) & (freqs_radial < bin_edges[i + 1])
|
mask = (freqs_radial >= bin_edges[i]) & (freqs_radial < bin_edges[i + 1])
|
||||||
if np.any(mask):
|
if np.any(mask):
|
||||||
avg = float(np.mean(power_spectrum[mask]) * dx * dy)
|
avg = float(np.mean(raw_power[mask]) * psd_norm)
|
||||||
if avg > 0:
|
if avg > 0:
|
||||||
psd_values.append(avg)
|
psd_values.append(avg)
|
||||||
freqs_center.append(float(np.sqrt(bin_edges[i] * bin_edges[i + 1])))
|
freqs_center.append(float(np.sqrt(bin_edges[i] * bin_edges[i + 1])))
|
||||||
|
|
||||||
return np.array(freqs_center), np.array(psd_values)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_psd_band_rms(freqs, psd):
|
|
||||||
"""Compute integrated RMS for gravity, support, and HF bands."""
|
|
||||||
def _band_rms(lo, hi):
|
|
||||||
m = (freqs >= lo) & (freqs <= hi)
|
|
||||||
if not np.any(m):
|
|
||||||
return 0.0
|
|
||||||
return float(np.sqrt(np.trapz(psd[m], freqs[m])))
|
|
||||||
|
|
||||||
gravity = _band_rms(0.1, 2.0)
|
|
||||||
support = _band_rms(2.0, 20.0)
|
|
||||||
hf = _band_rms(20.0, freqs.max())
|
|
||||||
total = float(np.sqrt(np.trapz(psd, freqs)))
|
|
||||||
return {
|
return {
|
||||||
'gravity_rms': gravity,
|
'freqs': np.array(freqs_center),
|
||||||
'support_rms': support,
|
'psd': np.array(psd_values),
|
||||||
'hf_rms': hf,
|
'bands': bands,
|
||||||
'total_rms': total,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -938,21 +952,29 @@ def generate_report(
|
|||||||
|
|
||||||
psd_plot_data = {}
|
psd_plot_data = {}
|
||||||
psd_bands = {}
|
psd_bands = {}
|
||||||
for ang_label, ang_key, wfe_key in [
|
# Use Zernike-filtered residual surface (J1-J4 removed) for PSD —
|
||||||
('40° vs 20°', 40, 'WFE_rel'),
|
# avoids piston/tilt/defocus dominating and circular-to-square boundary artifacts
|
||||||
('60° vs 20°', 60, 'WFE_rel'),
|
for ang_label, ang_key, use_rel in [
|
||||||
('90° (Abs)', 90, 'WFE'),
|
('40° vs 20°', 40, True),
|
||||||
|
('60° vs 20°', 60, True),
|
||||||
|
('90° (Abs)', 90, False),
|
||||||
]:
|
]:
|
||||||
r = angle_results[ang_key]
|
r = angle_results[ang_key]
|
||||||
Xp = r['X_rel'] if 'rel' in wfe_key else r['X']
|
rms_data = r['rms_rel'] if use_rel else r['rms_abs']
|
||||||
Yp = r['Y_rel'] if 'rel' in wfe_key else r['Y']
|
Xp = r['X_rel'] if use_rel else r['X']
|
||||||
Zp = r[wfe_key] if wfe_key == 'WFE' else r['WFE_rel']
|
Yp = r['Y_rel'] if use_rel else r['Y']
|
||||||
|
Zp = rms_data['W_res_filt'] # J1-J4 filtered residual
|
||||||
|
mask = rms_data['mask']
|
||||||
|
# Only pass masked (valid aperture) points
|
||||||
|
Xm, Ym, Zm = Xp[mask], Yp[mask], Zp[mask]
|
||||||
try:
|
try:
|
||||||
freqs, psd = compute_surface_psd(Xp, Yp, Zp, R_outer_est)
|
result = compute_surface_psd(Xm, Ym, Zm, R_outer_est)
|
||||||
psd_plot_data[ang_label] = (freqs, psd)
|
psd_plot_data[ang_label] = (result['freqs'], result['psd'])
|
||||||
psd_bands[ang_label] = compute_psd_band_rms(freqs, psd)
|
psd_bands[ang_label] = result['bands']
|
||||||
print(f" {ang_label}: gravity={psd_bands[ang_label]['gravity_rms']:.2f} nm, "
|
b = result['bands']
|
||||||
f"support={psd_bands[ang_label]['support_rms']:.2f} nm")
|
print(f" {ang_label}: gravity={b['gravity_rms']:.2f} nm, "
|
||||||
|
f"support={b['support_rms']:.2f} nm, "
|
||||||
|
f"total={b['total_rms']:.2f} nm")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [WARN] PSD for {ang_label} failed: {e}")
|
print(f" [WARN] PSD for {ang_label} failed: {e}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user