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:
2026-01-29 22:15:42 +00:00
parent 487ecf67dc
commit eeacfbe41a

View File

@@ -44,6 +44,8 @@ from math import factorial
from datetime import datetime from datetime import datetime
import numpy as np import numpy as np
from numpy.linalg import LinAlgError from numpy.linalg import LinAlgError
from scipy.interpolate import griddata
from scipy.fft import fft2, fftfreq
# Add Atomizer root to path # Add Atomizer root to path
ATOMIZER_ROOT = Path(__file__).parent.parent ATOMIZER_ROOT = Path(__file__).parent.parent
@@ -277,101 +279,159 @@ def aberration_magnitudes(coeffs):
} }
def compute_spatial_freq_metrics(coefficients): def compute_surface_psd(X, Y, Z, aperture_radius):
"""Compute spatial frequency band metrics from Zernike coefficients. """Compute power spectral density of surface height data (Tony Hull methodology).
Decomposes the Zernike spectrum into Low Spatial Frequency (form/figure) Interpolates scattered FEA node data onto a uniform grid, applies a Hann
and Mid Spatial Frequency (ripple) bands based on radial order. window, computes the 2D FFT, and radially averages to produce a 1D PSD.
Args: 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: 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) Xc = X - np.mean(X)
coeffs = np.array(coefficients[:n_modes]) Yc = Y - np.mean(Y)
# Build radial order mapping using noll_indices grid_size = 256 if len(X) >= 1000 else min(128, int(np.sqrt(len(X))))
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])
# Band definitions (by Noll index, 1-based) x_range = np.linspace(Xc.min(), Xc.max(), grid_size)
# LSF: J4-J15 (n=2..4) — Low Spatial Frequency (form/figure) y_range = np.linspace(Yc.min(), Yc.max(), grid_size)
# MSF: J16-J50 (n=5..9) — Mid Spatial Frequency (ripple) X_grid, Y_grid = np.meshgrid(x_range, y_range)
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)
lsf_rms = float(np.sqrt(np.sum(lsf_coeffs**2))) Z_grid = griddata((Xc, Yc), Z, (X_grid, Y_grid), method='cubic')
msf_rms = float(np.sqrt(np.sum(msf_coeffs**2))) Z_grid = np.nan_to_num(Z_grid, nan=0.0)
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')
# Per-radial-order RMS # Circular aperture mask
per_order = {} R_grid = np.sqrt(X_grid**2 + Y_grid**2)
for n in sorted(order_map.keys()): Z_grid[R_grid > aperture_radius] = 0.0
order_coeffs = np.array(order_map[n])
per_order[n] = float(np.sqrt(np.sum(order_coeffs**2)))
# 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 { return {
'lsf_rms': lsf_rms, 'gravity_rms': gravity,
'msf_rms': msf_rms, 'support_rms': support,
'total_filtered_rms': total_filtered_rms, 'hf_rms': hf,
'lsf_msf_ratio': lsf_msf_ratio, 'total_rms': total,
'per_order': per_order,
} }
def _spatial_freq_html(metrics): def make_psd_plot(psd_data_dict, title="Power Spectral Density"):
"""Generate HTML card for spatial frequency band metrics.""" """Create a log-log PSD plot for multiple angles.
m = metrics
# Per-order breakdown for n=2..9 Args:
order_items = "" psd_data_dict: {label: (freqs, psd)} for each angle/condition
for n in range(2, 10): """
val = m['per_order'].get(n, 0.0) colors = {'40° vs 20°': '#2563eb', '60° vs 20°': '#dc2626',
order_items += ( '90° (Abs)': '#16a34a', '20° (Abs)': '#64748b'}
f'<div class="sf-order-item">' fig = go.Figure()
f'<span class="sf-order-n">n={n}:</span>'
f'<span class="sf-order-val">{val:.2f}</span>'
f'</div>\n'
)
ratio_str = ( for label, (freqs, psd) in psd_data_dict.items():
f"{m['lsf_msf_ratio']:.1f}\u00d7" valid = psd > 0
if m['lsf_msf_ratio'] < 1000 fig.add_trace(go.Scatter(
else "\u221e" 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""" return f"""
<div class="sf-breakdown"> <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-grid">
<div class="sf-band-card sf-lsf"> <div class="sf-band-card sf-lsf">
<div class="sf-band-label">LSF (Form)</div> <div class="sf-band-label">Gravity Signature</div>
<div class="sf-band-range">J4\u2013J15 &nbsp; n\u22644</div> <div class="sf-band-range">0.1\u20132 cyc/apt</div>
<div class="sf-band-value">{m['lsf_rms']:.2f} <span class="sf-unit">nm</span></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>
<div class="sf-band-card sf-msf"> <div class="sf-band-card sf-msf">
<div class="sf-band-label">MSF (Ripple)</div> <div class="sf-band-label">Support Print-through</div>
<div class="sf-band-range">J16\u2013J50 &nbsp; n\u22655</div> <div class="sf-band-range">2\u201320 cyc/apt</div>
<div class="sf-band-value">{m['msf_rms']:.2f} <span class="sf-unit">nm</span></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>
<div class="sf-band-card sf-ratio"> <div class="sf-band-card sf-ratio">
<div class="sf-band-label">LSF / MSF</div> <div class="sf-band-label">High Frequency</div>
<div class="sf-band-range">Ratio</div> <div class="sf-band-range">&gt;20 cyc/apt</div>
<div class="sf-band-value">{ratio_str}</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> </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> </div>
""" """
@@ -870,10 +930,36 @@ def generate_report(
bar_60 = make_bar_chart(angle_results[60]['rms_rel']['coefficients'], title="60v20 coeffs") 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") bar_90 = make_bar_chart(angle_results[90]['rms_abs']['coefficients'], title="90abs coeffs")
# Spatial frequency band metrics # PSD analysis (Tony Hull methodology)
sfm_40 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[40]['rms_rel']['coefficients'])) print("\n[PSD] Computing power spectral density...")
sfm_60 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[60]['rms_rel']['coefficients'])) R_outer_est = float(np.max(np.hypot(
sfm_90 = _spatial_freq_html(compute_spatial_freq_metrics(angle_results[90]['rms_abs']['coefficients'])) 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 # Per-angle RMS plot
angle_rms_data = {} angle_rms_data = {}
@@ -982,7 +1068,8 @@ def generate_report(
# Section numbering: adjust if trajectory present # Section numbering: adjust if trajectory present
sec_surface = 3 sec_surface = 3
sec_traj = 4 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_params = sec_mfg + 1
sec_zernike = sec_params + 1 if (study_params and study_params.get('parameters')) else 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 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 class="plot-container"><h3>Axial vs Lateral Sensitivity</h3>' + sens_plot_html + '</div>' +
'</div></div>' if traj_result else ''} '</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 (&gt;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 --> <!-- Manufacturing Analysis -->
<div class="section"> <div class="section">
<h2>{sec_mfg}. Manufacturing Analysis (90\u00b0 Orientation)</h2> <h2>{sec_mfg}. Manufacturing Analysis (90\u00b0 Orientation)</h2>
@@ -1388,15 +1484,15 @@ def generate_report(
<h2>{sec_zernike}. Zernike Coefficient Details</h2> <h2>{sec_zernike}. Zernike Coefficient Details</h2>
<details> <details>
<summary>40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary> <summary>40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
<div>{sfm_40}{bar_40}</div> <div>{bar_40}</div>
</details> </details>
<details> <details>
<summary>60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary> <summary>60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
<div>{sfm_60}{bar_60}</div> <div>{bar_60}</div>
</details> </details>
<details> <details>
<summary>90\u00b0 \u2014 Absolute Coefficients</summary> <summary>90\u00b0 \u2014 Absolute Coefficients</summary>
<div>{sfm_90}{bar_90}</div> <div>{bar_90}</div>
</details> </details>
</div> </div>