feat(report): wider surface maps + spatial frequency band metrics

- CSS .plot-grid: minmax(650px) → minmax(1100px) for full-width surface maps
- Add compute_spatial_freq_metrics(): LSF/MSF band RMS, per-radial-order breakdown
- Add styled metrics cards in Zernike Coefficient Details (section 6)
  showing LSF (J4-J15), MSF (J16-J50), ratio, and per-order RMS n=2..9
This commit is contained in:
2026-01-29 20:46:58 +00:00
parent faab234d05
commit 487ecf67dc

View File

@@ -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'<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'
)
ratio_str = (
f"{m['lsf_msf_ratio']:.1f}\u00d7"
if m['lsf_msf_ratio'] < 1000
else "\u221e"
)
return f"""
<div class="sf-breakdown">
<h4>Spatial Frequency Breakdown</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 &nbsp; n\u22644</div>
<div class="sf-band-value">{m['lsf_rms']:.2f} <span class="sf-unit">nm</span></div>
</div>
<div class="sf-band-card sf-msf">
<div class="sf-band-label">MSF (Ripple)</div>
<div class="sf-band-range">J16\u2013J50 &nbsp; n\u22655</div>
<div class="sf-band-value">{m['msf_rms']:.2f} <span class="sf-unit">nm</span></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>
</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>
"""
# ============================================================================
# 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(
<h2>{sec_zernike}. Zernike Coefficient Details</h2>
<details>
<summary>40\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
<div>{bar_40}</div>
<div>{sfm_40}{bar_40}</div>
</details>
<details>
<summary>60\u00b0 vs 20\u00b0 \u2014 Relative Coefficients</summary>
<div>{bar_60}</div>
<div>{sfm_60}{bar_60}</div>
</details>
<details>
<summary>90\u00b0 \u2014 Absolute Coefficients</summary>
<div>{bar_90}</div>
<div>{sfm_90}{bar_90}</div>
</details>
</div>