diff --git a/tools/generate_optical_report.py b/tools/generate_optical_report.py
index 457433ed..957fbce6 100644
--- a/tools/generate_optical_report.py
+++ b/tools/generate_optical_report.py
@@ -277,6 +277,105 @@ def aberration_magnitudes(coeffs):
}
+def compute_spatial_freq_metrics(coefficients):
+ """Compute spatial frequency band metrics from Zernike coefficients.
+
+ Decomposes the Zernike spectrum into Low Spatial Frequency (form/figure)
+ and Mid Spatial Frequency (ripple) bands based on radial order.
+
+ Args:
+ coefficients: array of Zernike coefficients (50 modes, Noll convention)
+
+ Returns:
+ dict with band RMS values and per-radial-order breakdown
+ """
+ n_modes = min(len(coefficients), 50)
+ coeffs = np.array(coefficients[:n_modes])
+
+ # 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])
+
+ # 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)
+
+ 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')
+
+ # 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)))
+
+ 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,
+ }
+
+
+def _spatial_freq_html(metrics):
+ """Generate HTML card for spatial frequency band metrics."""
+ m = metrics
+
+ # 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'
'
+ f'n={n}:'
+ f'{val:.2f}'
+ f'
\n'
+ )
+
+ ratio_str = (
+ f"{m['lsf_msf_ratio']:.1f}\u00d7"
+ if m['lsf_msf_ratio'] < 1000
+ else "\u221e"
+ )
+
+ return f"""
+
+
Spatial Frequency Breakdown
+
+
+
LSF (Form)
+
J4\u2013J15 n\u22644
+
{m['lsf_rms']:.2f} nm
+
+
+
MSF (Ripple)
+
J16\u2013J50 n\u22655
+
{m['msf_rms']:.2f} nm
+
+
+
LSF / MSF
+
Ratio
+
{ratio_str}
+
+
+
+
Per-Order RMS (nm)
+
{order_items}
+
+
+ """
+
+
# ============================================================================
# HTML Report Generation
# ============================================================================
@@ -383,8 +482,8 @@ def make_surface_plot(X, Y, W_res, mask, inner_radius=None, title="", amp=0.5, d
),
margin=dict(t=30, b=10, l=10, r=10),
**_PLOTLY_LIGHT_LAYOUT,
- height=500,
- width=700,
+ height=650,
+ width=1200,
)
return fig.to_html(include_plotlyjs=False, full_html=False, div_id=f"surface_{title.replace(' ','_')}")
@@ -771,6 +870,11 @@ 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']))
+
# Per-angle RMS plot
angle_rms_data = {}
for ang in sorted(angle_results.keys()):
@@ -1024,7 +1128,7 @@ def generate_report(
/* Plots */
.plot-grid {{
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(650px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(1100px, 1fr));
gap: 1rem;
}}
.plot-container {{
@@ -1041,6 +1145,89 @@ def generate_report(
color: var(--text-secondary);
}}
+ /* Spatial Frequency Breakdown */
+ .sf-breakdown {{
+ margin: 1rem 0 1.5rem 0;
+ padding: 1.2rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ }}
+ .sf-breakdown h4 {{
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.8rem;
+ }}
+ .sf-band-grid {{
+ display: grid;
+ grid-template-columns: 1fr 1fr auto;
+ gap: 0.8rem;
+ margin-bottom: 1rem;
+ }}
+ .sf-band-card {{
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.8rem 1rem;
+ text-align: center;
+ }}
+ .sf-band-label {{
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }}
+ .sf-band-range {{
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin: 0.15rem 0 0.4rem 0;
+ }}
+ .sf-band-value {{
+ font-size: 1.3rem;
+ font-weight: 700;
+ color: var(--accent);
+ }}
+ .sf-unit {{
+ font-size: 0.8rem;
+ font-weight: 400;
+ color: var(--text-secondary);
+ }}
+ .sf-lsf {{ border-top: 3px solid #2563eb; }}
+ .sf-msf {{ border-top: 3px solid #7c3aed; }}
+ .sf-ratio {{ border-top: 3px solid #16a34a; min-width: 100px; }}
+ .sf-order-grid {{
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.8rem 1rem;
+ }}
+ .sf-order-title {{
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+ }}
+ .sf-order-items {{
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem 1.2rem;
+ }}
+ .sf-order-item {{
+ display: flex;
+ gap: 0.3rem;
+ font-size: 0.85rem;
+ }}
+ .sf-order-n {{
+ color: var(--text-secondary);
+ font-weight: 500;
+ }}
+ .sf-order-val {{
+ color: var(--text-primary);
+ font-weight: 600;
+ }}
+
/* Collapsible */
details {{ margin: 0.5rem 0; }}
summary {{
@@ -1201,15 +1388,15 @@ def generate_report(
{sec_zernike}. Zernike Coefficient Details
40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients
- {bar_40}
+ {sfm_40}{bar_40}
60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients
- {bar_60}
+ {sfm_60}{bar_60}
90\u00b0 \u2014 Absolute Coefficients
- {bar_90}
+ {sfm_90}{bar_90}