feat(report): replace LSF/MSF with Tony Hull PSD analysis
- Remove compute_spatial_freq_metrics() and _spatial_freq_html() - Add compute_surface_psd(): 2D FFT + Hann window + radial averaging - Add compute_psd_band_rms(): gravity/support/HF band decomposition - Add make_psd_plot(): interactive log-log PSD plot with band annotations - Add _psd_summary_html(): band RMS cards with % breakdown - New section in report: Power Spectral Density Analysis - Zernike details now show only coefficient bar charts (cleaner) - Methodology: Tony Hull JWST approach for WFE spatial frequency analysis
This commit is contained in:
@@ -44,6 +44,8 @@ from math import factorial
|
||||
from datetime import datetime
|
||||
import numpy as np
|
||||
from numpy.linalg import LinAlgError
|
||||
from scipy.interpolate import griddata
|
||||
from scipy.fft import fft2, fftfreq
|
||||
|
||||
# Add Atomizer root to path
|
||||
ATOMIZER_ROOT = Path(__file__).parent.parent
|
||||
@@ -277,101 +279,159 @@ def aberration_magnitudes(coeffs):
|
||||
}
|
||||
|
||||
|
||||
def compute_spatial_freq_metrics(coefficients):
|
||||
"""Compute spatial frequency band metrics from Zernike coefficients.
|
||||
def compute_surface_psd(X, Y, Z, aperture_radius):
|
||||
"""Compute power spectral density of surface height data (Tony Hull methodology).
|
||||
|
||||
Decomposes the Zernike spectrum into Low Spatial Frequency (form/figure)
|
||||
and Mid Spatial Frequency (ripple) bands based on radial order.
|
||||
Interpolates scattered FEA node data onto a uniform grid, applies a Hann
|
||||
window, computes the 2D FFT, and radially averages to produce a 1D PSD.
|
||||
|
||||
Args:
|
||||
coefficients: array of Zernike coefficients (50 modes, Noll convention)
|
||||
X: Surface x-coordinates [mm]
|
||||
Y: Surface y-coordinates [mm]
|
||||
Z: Surface height values [nm]
|
||||
aperture_radius: Physical radius of the mirror [mm]
|
||||
|
||||
Returns:
|
||||
dict with band RMS values and per-radial-order breakdown
|
||||
(freqs, psd_values) — spatial frequencies in cycles/aperture and PSD amplitude
|
||||
"""
|
||||
n_modes = min(len(coefficients), 50)
|
||||
coeffs = np.array(coefficients[:n_modes])
|
||||
Xc = X - np.mean(X)
|
||||
Yc = Y - np.mean(Y)
|
||||
|
||||
# Build radial order mapping using noll_indices
|
||||
order_map = {} # n -> list of coeff values
|
||||
for j in range(1, n_modes + 1):
|
||||
n, m = noll_indices(j)
|
||||
if n not in order_map:
|
||||
order_map[n] = []
|
||||
order_map[n].append(coeffs[j - 1])
|
||||
grid_size = 256 if len(X) >= 1000 else min(128, int(np.sqrt(len(X))))
|
||||
|
||||
# Band definitions (by Noll index, 1-based)
|
||||
# LSF: J4-J15 (n=2..4) — Low Spatial Frequency (form/figure)
|
||||
# MSF: J16-J50 (n=5..9) — Mid Spatial Frequency (ripple)
|
||||
lsf_coeffs = coeffs[3:15] # indices 3..14 → J4..J15
|
||||
msf_coeffs = coeffs[15:n_modes] # indices 15..49 → J16..J50
|
||||
total_coeffs = coeffs[3:n_modes] # J4..J50 (excluding piston/tilt)
|
||||
x_range = np.linspace(Xc.min(), Xc.max(), grid_size)
|
||||
y_range = np.linspace(Yc.min(), Yc.max(), grid_size)
|
||||
X_grid, Y_grid = np.meshgrid(x_range, y_range)
|
||||
|
||||
lsf_rms = float(np.sqrt(np.sum(lsf_coeffs**2)))
|
||||
msf_rms = float(np.sqrt(np.sum(msf_coeffs**2)))
|
||||
total_filtered_rms = float(np.sqrt(np.sum(total_coeffs**2)))
|
||||
lsf_msf_ratio = lsf_rms / msf_rms if msf_rms > 0 else float('inf')
|
||||
Z_grid = griddata((Xc, Yc), Z, (X_grid, Y_grid), method='cubic')
|
||||
Z_grid = np.nan_to_num(Z_grid, nan=0.0)
|
||||
|
||||
# Per-radial-order RMS
|
||||
per_order = {}
|
||||
for n in sorted(order_map.keys()):
|
||||
order_coeffs = np.array(order_map[n])
|
||||
per_order[n] = float(np.sqrt(np.sum(order_coeffs**2)))
|
||||
# Circular aperture mask
|
||||
R_grid = np.sqrt(X_grid**2 + Y_grid**2)
|
||||
Z_grid[R_grid > aperture_radius] = 0.0
|
||||
|
||||
# Hann window to reduce spectral leakage
|
||||
hann = np.outer(np.hanning(grid_size), np.hanning(grid_size))
|
||||
Z_windowed = Z_grid * hann
|
||||
|
||||
fft_result = fft2(Z_windowed)
|
||||
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)
|
||||
fy, fx = np.meshgrid(freqs_y, freqs_x)
|
||||
freqs_radial = np.sqrt(fx**2 + fy**2) * 2 * aperture_radius # cycles/aperture
|
||||
|
||||
power_spectrum = np.abs(fft_result)**2
|
||||
|
||||
bin_edges = np.logspace(-1.5, np.log10(grid_size / 2), 60)
|
||||
freqs_center, psd_values = [], []
|
||||
for i in range(len(bin_edges) - 1):
|
||||
mask = (freqs_radial >= bin_edges[i]) & (freqs_radial < bin_edges[i + 1])
|
||||
if np.any(mask):
|
||||
avg = float(np.mean(power_spectrum[mask]) * dx * dy)
|
||||
if avg > 0:
|
||||
psd_values.append(avg)
|
||||
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 {
|
||||
'lsf_rms': lsf_rms,
|
||||
'msf_rms': msf_rms,
|
||||
'total_filtered_rms': total_filtered_rms,
|
||||
'lsf_msf_ratio': lsf_msf_ratio,
|
||||
'per_order': per_order,
|
||||
'gravity_rms': gravity,
|
||||
'support_rms': support,
|
||||
'hf_rms': hf,
|
||||
'total_rms': total,
|
||||
}
|
||||
|
||||
|
||||
def _spatial_freq_html(metrics):
|
||||
"""Generate HTML card for spatial frequency band metrics."""
|
||||
m = metrics
|
||||
def make_psd_plot(psd_data_dict, title="Power Spectral Density"):
|
||||
"""Create a log-log PSD plot for multiple angles.
|
||||
|
||||
# Per-order breakdown for n=2..9
|
||||
order_items = ""
|
||||
for n in range(2, 10):
|
||||
val = m['per_order'].get(n, 0.0)
|
||||
order_items += (
|
||||
f'<div class="sf-order-item">'
|
||||
f'<span class="sf-order-n">n={n}:</span>'
|
||||
f'<span class="sf-order-val">{val:.2f}</span>'
|
||||
f'</div>\n'
|
||||
)
|
||||
Args:
|
||||
psd_data_dict: {label: (freqs, psd)} for each angle/condition
|
||||
"""
|
||||
colors = {'40° vs 20°': '#2563eb', '60° vs 20°': '#dc2626',
|
||||
'90° (Abs)': '#16a34a', '20° (Abs)': '#64748b'}
|
||||
fig = go.Figure()
|
||||
|
||||
ratio_str = (
|
||||
f"{m['lsf_msf_ratio']:.1f}\u00d7"
|
||||
if m['lsf_msf_ratio'] < 1000
|
||||
else "\u221e"
|
||||
for label, (freqs, psd) in psd_data_dict.items():
|
||||
valid = psd > 0
|
||||
fig.add_trace(go.Scatter(
|
||||
x=freqs[valid], y=psd[valid],
|
||||
mode='lines', name=label,
|
||||
line=dict(color=colors.get(label, '#6366f1'), width=2),
|
||||
hovertemplate="%{x:.1f} cyc/apt: %{y:.2e}<extra>" + label + "</extra>",
|
||||
))
|
||||
|
||||
# Band annotations
|
||||
fig.add_vrect(x0=0.1, x1=2.0, fillcolor='rgba(59,130,246,0.08)', line_width=0, layer='below',
|
||||
annotation_text="Gravity", annotation_position="top left",
|
||||
annotation=dict(font=dict(size=10, color='#3b82f6')))
|
||||
fig.add_vrect(x0=2.0, x1=20.0, fillcolor='rgba(245,158,11,0.08)', line_width=0, layer='below',
|
||||
annotation_text="Support", annotation_position="top left",
|
||||
annotation=dict(font=dict(size=10, color='#f59e0b')))
|
||||
fig.add_vrect(x0=20.0, x1=200.0, fillcolor='rgba(239,68,68,0.06)', line_width=0, layer='below',
|
||||
annotation_text="High Freq", annotation_position="top left",
|
||||
annotation=dict(font=dict(size=10, color='#ef4444')))
|
||||
|
||||
fig.update_layout(
|
||||
height=500, width=1100,
|
||||
margin=dict(t=30, b=60, l=80, r=30),
|
||||
**_PLOTLY_LIGHT_LAYOUT,
|
||||
xaxis=dict(type='log', title=dict(text="Spatial Frequency [cycles/aperture]", font=dict(color='#1e293b')),
|
||||
gridcolor='#e2e8f0', tickfont=dict(color='#475569')),
|
||||
yaxis=dict(type='log', title=dict(text="PSD [nm²·mm²]", font=dict(color='#1e293b')),
|
||||
gridcolor='#e2e8f0', tickfont=dict(color='#475569')),
|
||||
legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.9)',
|
||||
bordercolor='#e2e8f0', borderwidth=1, font=dict(size=11, color='#1e293b')),
|
||||
)
|
||||
return fig.to_html(include_plotlyjs=False, full_html=False, div_id="psd_plot")
|
||||
|
||||
|
||||
def _psd_summary_html(band_dict):
|
||||
"""Generate an HTML summary card for PSD band RMS values."""
|
||||
m = band_dict
|
||||
total = m['total_rms']
|
||||
grav_pct = 100 * m['gravity_rms'] / total if total > 0 else 0
|
||||
supp_pct = 100 * m['support_rms'] / total if total > 0 else 0
|
||||
hf_pct = 100 * m['hf_rms'] / total if total > 0 else 0
|
||||
|
||||
return f"""
|
||||
<div class="sf-breakdown">
|
||||
<h4>Spatial Frequency Breakdown</h4>
|
||||
<h4>PSD Band Decomposition (Tony Hull Methodology)</h4>
|
||||
<div class="sf-band-grid">
|
||||
<div class="sf-band-card sf-lsf">
|
||||
<div class="sf-band-label">LSF (Form)</div>
|
||||
<div class="sf-band-range">J4\u2013J15 n\u22644</div>
|
||||
<div class="sf-band-value">{m['lsf_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||||
<div class="sf-band-label">Gravity Signature</div>
|
||||
<div class="sf-band-range">0.1\u20132 cyc/apt</div>
|
||||
<div class="sf-band-value">{m['gravity_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||||
<div class="sf-band-range">{grav_pct:.0f}% of total</div>
|
||||
</div>
|
||||
<div class="sf-band-card sf-msf">
|
||||
<div class="sf-band-label">MSF (Ripple)</div>
|
||||
<div class="sf-band-range">J16\u2013J50 n\u22655</div>
|
||||
<div class="sf-band-value">{m['msf_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||||
<div class="sf-band-label">Support Print-through</div>
|
||||
<div class="sf-band-range">2\u201320 cyc/apt</div>
|
||||
<div class="sf-band-value">{m['support_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||||
<div class="sf-band-range">{supp_pct:.0f}% of total</div>
|
||||
</div>
|
||||
<div class="sf-band-card sf-ratio">
|
||||
<div class="sf-band-label">LSF / MSF</div>
|
||||
<div class="sf-band-range">Ratio</div>
|
||||
<div class="sf-band-value">{ratio_str}</div>
|
||||
<div class="sf-band-label">High Frequency</div>
|
||||
<div class="sf-band-range">>20 cyc/apt</div>
|
||||
<div class="sf-band-value">{m['hf_rms']:.2f} <span class="sf-unit">nm</span></div>
|
||||
<div class="sf-band-range">{hf_pct:.0f}% of total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sf-order-grid">
|
||||
<div class="sf-order-title">Per-Order RMS (nm)</div>
|
||||
<div class="sf-order-items">{order_items}</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -870,10 +930,36 @@ def generate_report(
|
||||
bar_60 = make_bar_chart(angle_results[60]['rms_rel']['coefficients'], title="60v20 coeffs")
|
||||
bar_90 = make_bar_chart(angle_results[90]['rms_abs']['coefficients'], title="90abs coeffs")
|
||||
|
||||
# Spatial frequency band metrics
|
||||
sfm_40 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[40]['rms_rel']['coefficients']))
|
||||
sfm_60 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[60]['rms_rel']['coefficients']))
|
||||
sfm_90 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[90]['rms_abs']['coefficients']))
|
||||
# PSD analysis (Tony Hull methodology)
|
||||
print("\n[PSD] Computing power spectral density...")
|
||||
R_outer_est = float(np.max(np.hypot(
|
||||
angle_results[40]['X'] - np.mean(angle_results[40]['X']),
|
||||
angle_results[40]['Y'] - np.mean(angle_results[40]['Y']))))
|
||||
|
||||
psd_plot_data = {}
|
||||
psd_bands = {}
|
||||
for ang_label, ang_key, wfe_key in [
|
||||
('40° vs 20°', 40, 'WFE_rel'),
|
||||
('60° vs 20°', 60, 'WFE_rel'),
|
||||
('90° (Abs)', 90, 'WFE'),
|
||||
]:
|
||||
r = angle_results[ang_key]
|
||||
Xp = r['X_rel'] if 'rel' in wfe_key else r['X']
|
||||
Yp = r['Y_rel'] if 'rel' in wfe_key else r['Y']
|
||||
Zp = r[wfe_key] if wfe_key == 'WFE' else r['WFE_rel']
|
||||
try:
|
||||
freqs, psd = compute_surface_psd(Xp, Yp, Zp, R_outer_est)
|
||||
psd_plot_data[ang_label] = (freqs, psd)
|
||||
psd_bands[ang_label] = compute_psd_band_rms(freqs, psd)
|
||||
print(f" {ang_label}: gravity={psd_bands[ang_label]['gravity_rms']:.2f} nm, "
|
||||
f"support={psd_bands[ang_label]['support_rms']:.2f} nm")
|
||||
except Exception as e:
|
||||
print(f" [WARN] PSD for {ang_label} failed: {e}")
|
||||
|
||||
psd_plot_html = make_psd_plot(psd_plot_data) if psd_plot_data else ""
|
||||
psd_summary_40 = _psd_summary_html(psd_bands['40° vs 20°']) if '40° vs 20°' in psd_bands else ""
|
||||
psd_summary_60 = _psd_summary_html(psd_bands['60° vs 20°']) if '60° vs 20°' in psd_bands else ""
|
||||
psd_summary_90 = _psd_summary_html(psd_bands['90° (Abs)']) if '90° (Abs)' in psd_bands else ""
|
||||
|
||||
# Per-angle RMS plot
|
||||
angle_rms_data = {}
|
||||
@@ -982,7 +1068,8 @@ def generate_report(
|
||||
# Section numbering: adjust if trajectory present
|
||||
sec_surface = 3
|
||||
sec_traj = 4
|
||||
sec_mfg = 5 if traj_result else 4
|
||||
sec_psd = 5 if traj_result else 4
|
||||
sec_mfg = sec_psd + 1
|
||||
sec_params = sec_mfg + 1
|
||||
sec_zernike = sec_params + 1 if (study_params and study_params.get('parameters')) else sec_mfg + 1
|
||||
sec_method = sec_zernike + 1
|
||||
@@ -1370,6 +1457,15 @@ def generate_report(
|
||||
'<div class="plot-container"><h3>Axial vs Lateral Sensitivity</h3>' + sens_plot_html + '</div>' +
|
||||
'</div></div>' if traj_result else ''}
|
||||
|
||||
<!-- PSD Analysis -->
|
||||
{'<div class="section"><h2>' + str(sec_psd) + '. Power Spectral Density Analysis</h2>' +
|
||||
'<p class="note">Surface PSD computed via 2D FFT with Hann windowing and radial averaging ' +
|
||||
'(Tony Hull / JWST methodology). Frequency bands: Gravity signature (0.1\u20132 cyc/apt), ' +
|
||||
'Support print-through (2\u201320 cyc/apt), High frequency (>20 cyc/apt).</p>' +
|
||||
'<div class="plot-container" style="margin:1rem 0"><h3>PSD \u2014 Log-Log</h3>' + psd_plot_html + '</div>' +
|
||||
psd_summary_40 + psd_summary_60 + psd_summary_90 +
|
||||
'</div>' if psd_plot_html else ''}
|
||||
|
||||
<!-- Manufacturing Analysis -->
|
||||
<div class="section">
|
||||
<h2>{sec_mfg}. Manufacturing Analysis (90\u00b0 Orientation)</h2>
|
||||
@@ -1388,15 +1484,15 @@ def generate_report(
|
||||
<h2>{sec_zernike}. Zernike Coefficient Details</h2>
|
||||
<details>
|
||||
<summary>40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
|
||||
<div>{sfm_40}{bar_40}</div>
|
||||
<div>{bar_40}</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
|
||||
<div>{sfm_60}{bar_60}</div>
|
||||
<div>{bar_60}</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>90\u00b0 \u2014 Absolute Coefficients</summary>
|
||||
<div>{sfm_90}{bar_90}</div>
|
||||
<div>{bar_90}</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user