adctoolbox.spectrum.sweep_performance_vs_osr 源代码
"""Performance metrics vs oversampling ratio (OSR) sweep.
Analyzes how ADC performance (SNDR, SFDR, ENOB) varies with oversampling ratio
by separating ideal signal from error via sine fitting.
"""
import numpy as np
import matplotlib.pyplot as plt
from adctoolbox.fundamentals import fit_sine_4param, fold_frequency_to_nyquist
from adctoolbox.spectrum._bin_ranges import rfft_inband_bin_count
def _one_sided_rms_power_spectrum(data: np.ndarray) -> np.ndarray:
"""Return a one-sided spectrum whose sum equals time-domain RMS power."""
n = len(data)
spectrum = np.abs(np.fft.rfft(data)) ** 2 / n**2
if n <= 1:
return spectrum
if n % 2 == 0:
spectrum[1:-1] *= 2.0
else:
spectrum[1:] *= 2.0
return spectrum
[文档]
def sweep_performance_vs_osr(
data: np.ndarray,
osr: np.ndarray | None = None,
harmonic: int = 5,
create_plot: bool = True,
ax: plt.Axes | None = None,
logscale: bool = True,
smooth: int | None = None,
) -> dict:
"""
Sweep ADC performance metrics versus oversampling ratio.
Parameters
----------
data : np.ndarray
Input signal (1D), typically ADC output samples.
osr : np.ndarray, optional
OSR values to evaluate. Default: N/2 / (N/2, N/2-1, ..., 1).
harmonic : int, default=5
Number of harmonics to mark on plot.
create_plot : bool, default=True
If True, create performance plot(s).
ax : plt.Axes, optional
Axes for main performance plot. If None and create_plot, creates
2-subplot figure (performance + slope).
logscale : bool, default=True
Use logarithmic OSR axis, matching MATLAB ``perfosr`` default.
smooth : int, optional
Half-width used for local SNDR slope estimation. Default matches the
local MATLAB-style heuristic.
Returns
-------
dict
'osr': OSR values
'sndr': SNDR in dB at each OSR
'sfdr': Fast residual-spectrum single-bin SFDR estimate in dB at each OSR
'enob': ENOB in bits at each OSR
"""
data = np.asarray(data, dtype=float).ravel()
n = len(data)
# Default OSR: sweep from 1 to N/2
if osr is None:
n_bins = rfft_inband_bin_count(n, osr=1) - 1
osr = (n / 2) / np.arange(n_bins, 0, -1)
osr = np.asarray(osr, dtype=float)
# Step 1: Sine fit to separate ideal signal from error
fit_result = fit_sine_4param(data)
sig_fit = fit_result['fitted_signal']
freq = fit_result['frequency']
amplitude = fit_result['amplitude']
# Step 2: Error spectrum with Hann window. Use one-sided RMS-power
# scaling so sum(err_spec) matches the residual RMS power.
err = data - sig_fit
win = 0.5 * (1 - np.cos(2 * np.pi * np.arange(n) / n))
err_windowed = err * win / np.sqrt(np.mean(win ** 2))
err_spec = _one_sided_rms_power_spectrum(err_windowed)
# Signal power (constant)
sig_power = amplitude ** 2 / 2
# Step 3: Sweep OSR (sorted descending for incremental accumulation)
n_osr = len(osr)
sndr = np.zeros(n_osr)
sfdr = np.zeros(n_osr)
enob = np.zeros(n_osr)
sort_idx = np.argsort(osr)[::-1] # descending
osr_sorted = osr[sort_idx]
n_inband_prev = 0
noi_power = 0.0
spur_power = 0.0
for ii in range(n_osr):
n_inband = rfft_inband_bin_count(n, osr_sorted[ii])
if n_inband > n_inband_prev:
incremental = err_spec[n_inband_prev:n_inband]
noi_power += np.sum(incremental)
spur_power = max(spur_power, np.max(incremental))
n_inband_prev = n_inband
orig_idx = sort_idx[ii]
sndr[orig_idx] = 10 * np.log10(sig_power / noi_power)
sfdr[orig_idx] = 10 * np.log10(sig_power / spur_power)
enob[orig_idx] = (sndr[orig_idx] - 1.76) / 6.02
# Step 4: Plot
if create_plot:
make_slope = (ax is None)
if ax is None:
fig, axes_arr = plt.subplots(1, 2, figsize=(14, 5))
ax_main = axes_arr[0]
ax_slope = axes_arr[1]
else:
ax_main = ax
# --- Main performance plot ---
plot_fn = ax_main.semilogx if logscale else ax_main.plot
plot_fn(osr, sndr, 'b-', linewidth=1.5, label='SNDR (ENOB)')
plot_fn(osr, sfdr, 'r-', linewidth=1.5, label='SFDR')
ax_main.set_ylabel('SNDR / SFDR (dB)')
ax_main.set_xlabel('OSR')
ax_main.set_title('Performance vs OSR')
ax_main.grid(True)
ax_main.legend(loc='lower right')
# Right y-axis for ENOB
ax_enob = ax_main.twinx()
sndr_lim = [min(np.min(sndr), np.min(sfdr)) - 5,
max(np.max(sndr), np.max(sfdr)) + 5]
enob_lim = [(s - 1.76) / 6.02 for s in sndr_lim]
ax_main.set_ylim(sndr_lim)
ax_enob.set_ylim(enob_lim)
ax_enob.set_ylabel('ENOB (bits)')
# Mark fundamental and harmonics
osr_sig = 1.0 / (2 * freq)
y_lim = ax_main.get_ylim()
if min(osr) <= osr_sig <= max(osr):
ax_main.axvline(osr_sig, color='k', linewidth=1)
ax_main.text(osr_sig, y_lim[0], 'Fund', fontsize=8,
ha='right', va='bottom', color='k')
for h in range(2, harmonic + 1):
f_h = fold_frequency_to_nyquist(freq * h, 1.0)
osr_h = 1.0 / (2 * f_h)
if min(osr) <= osr_h <= max(osr):
ax_main.axvline(osr_h, color='k', linestyle='--', linewidth=0.5)
ax_main.text(osr_h, y_lim[0], f'HD{h}', fontsize=8,
ha='right', va='bottom', color='k')
# --- Slope subplot ---
if make_slope and len(osr) >= 3:
log_osr = np.log10(osr)
n_pts = len(osr)
smooth_win = max(5, round(n_pts / 10)) if smooth is None else int(smooth)
smooth_win = min(smooth_win, (n_pts - 1) // 2)
local_slope = np.zeros(n_pts)
for i in range(n_pts):
i_lo = max(0, i - smooth_win)
i_hi = min(n_pts - 1, i + smooth_win)
denom = log_osr[i_hi] - log_osr[i_lo]
if abs(denom) > 1e-15:
local_slope[i] = (sndr[i_hi] - sndr[i_lo]) / denom
slope_plot_fn = ax_slope.semilogx if logscale else ax_slope.plot
slope_plot_fn(osr, local_slope, 'b-', linewidth=1.5)
ax_slope.axhline(10, color='k', linestyle='--', linewidth=0.5)
ax_slope.text(max(osr), 10, 'White Noise Limit', fontsize=8,
ha='right', va='bottom')
ax_slope.set_ylabel('SNDR Slope (dB/decade)')
ax_slope.set_xlabel('OSR')
ax_slope.grid(True)
slope_range = np.ptp(local_slope)
ax_slope.set_ylim([np.min(local_slope) - 0.1 * slope_range - 5,
np.max(local_slope) + 0.1 * slope_range + 5])
plt.tight_layout()
return {'osr': osr, 'sndr': sndr, 'sfdr': sfdr, 'enob': enob}