Source code for adctoolbox.dout.analyze_enob_sweep

"""ENOB sweep analysis versus number of calibration bits.

Sweeps through bit counts to evaluate how calibration quality improves with more bits.
"""

import numpy as np
import matplotlib.pyplot as plt
from adctoolbox.calibration import calibrate_weight_sine
from adctoolbox.spectrum import analyze_spectrum

[docs] def analyze_enob_sweep( bits: np.ndarray, freq: float | None = None, harmonic_order: int = 1, osr: int = 1, win_type: str = 'hamming', calibration_mode: str = "prefix_of_full_calibration", frequency_policy: str = "python", create_plot: bool = True, ax=None, title: str | None = None, verbose: bool = False ) -> tuple[np.ndarray, np.ndarray]: """ Sweep ENOB vs number of bits used for calibration. Incrementally adds bits (MSB to LSB) and measures ENOB after calibration to understand diminishing returns and optimal bit count. Parameters ---------- bits : np.ndarray Binary matrix (N samples x M bits, MSB to LSB order) freq : float, optional Normalized frequency (0-0.5). If None, auto-detect from data harmonic_order : int, default=1 Harmonic order for calibrate_weight_sine osr : int, default=1 Oversampling ratio for spectrum analysis win_type : str, default='hamming' Window function: 'boxcar', 'hann', 'hamming' calibration_mode : {'prefix_of_full_calibration', 'recalibrate_each_subset'}, default='prefix_of_full_calibration' ENOB sweep calibration policy. ``'prefix_of_full_calibration'`` calibrates all bits once and sweeps prefixes of the full-weight solution. ``'recalibrate_each_subset'`` estimates the frequency once when needed, then recalibrates each bit-prefix subset independently. frequency_policy : {'python', 'matlab'}, default='python' Coarse frequency estimator passed to ``calibrate_weight_sine`` when automatic frequency search is requested. create_plot : bool, default=True If True, plot ENOB sweep curve ax : plt.Axes, optional Axes to plot on. If None, uses current axes (plt.gca()) title : str, optional Title for the plot. If None, uses default title verbose : bool, default=False If True, print progress messages Returns ------- tuple[np.ndarray, np.ndarray] - enob_sweep: ENOB for each bit count (length M) - n_bits_vec: Bit counts from 1 to M Notes ----- The default ``'prefix_of_full_calibration'`` mode answers the calibration ablation question: "after a full-bit calibration, how much performance remains if lower-bit terms are removed?" Use ``'recalibrate_each_subset'`` only when intentionally asking: "if only the first n bits are available, how well can that n-bit subsystem be calibrated?" What to look for in the plot: - Increasing trend: More bits improve resolution - Plateau: Additional bits don't help (noise/distortion limited) - Decrease: Extra bits add noise/calibration errors """ bits = np.asarray(bits) _, m_bits = bits.shape valid_modes = {"recalibrate_each_subset", "prefix_of_full_calibration"} if calibration_mode not in valid_modes: raise ValueError( f"Unknown calibration_mode {calibration_mode!r}. " f"Expected one of {sorted(valid_modes)}." ) enob_sweep = np.zeros(m_bits) n_bits_vec = np.arange(1, m_bits + 1) if calibration_mode == "recalibrate_each_subset": enob_sweep = _sweep_recalibrating_each_subset( bits=bits, freq=freq, harmonic_order=harmonic_order, osr=osr, win_type=win_type, frequency_policy=frequency_policy, verbose=verbose, ) else: enob_sweep = _sweep_prefix_of_full_calibration( bits=bits, freq=freq, harmonic_order=harmonic_order, osr=osr, win_type=win_type, frequency_policy=frequency_policy, verbose=verbose, ) # Plotting if create_plot: if ax is None: ax = plt.gca() ax.plot(n_bits_vec, enob_sweep, 'o-k', linewidth=2, markersize=8, markerfacecolor='k') ax.grid(True) ax.set_xlabel('Number of Bits Used for Calibration') ax.set_ylabel('ENOB (bits)') # Set title if provided if title is not None: ax.set_title(title) else: ax.set_title('ENOB vs Number of Bits Used for Calibration') ax.set_xlim([0.5, m_bits + 0.5]) ax.set_xticks(n_bits_vec) valid_enob = enob_sweep[~np.isnan(enob_sweep)] if len(valid_enob) > 0: ax.set_ylim([np.min(valid_enob) - 0.5, np.max(valid_enob) + 2]) # Annotate with delta ENOB delta_enob = np.concatenate([[enob_sweep[0]], np.diff(enob_sweep)]) if len(valid_enob) > 0: y_offset = (np.max(valid_enob) - np.min(valid_enob)) * 0.06 else: y_offset = 0.1 for i in range(m_bits): if not np.isnan(enob_sweep[i]) and not np.isnan(delta_enob[i]): if i == 0: annotation_text = f'{delta_enob[i]:.2f}' text_color = [0, 0, 0] else: annotation_text = f'+{delta_enob[i]:.2f}' normalized_delta = max(0, min(1, delta_enob[i])) text_color = [1 - normalized_delta, 0, 0] ax.text(n_bits_vec[i], enob_sweep[i] + y_offset, annotation_text, ha='center', va='bottom', fontsize=10, fontweight='bold', color=text_color) return enob_sweep, n_bits_vec
def _sweep_recalibrating_each_subset( bits: np.ndarray, freq: float | None, harmonic_order: int, osr: int, win_type: str, frequency_policy: str, verbose: bool, ) -> np.ndarray: """Diagnostic mode: recalibrate every bit-prefix subset independently.""" _, m_bits = bits.shape enob_sweep = np.zeros(m_bits) auto_frequency_requested = freq is None or np.all(np.asarray(freq) == 0) if auto_frequency_requested: full_result = calibrate_weight_sine( bits, freq=freq, harmonic_order=harmonic_order, frequency_policy=frequency_policy, ) subset_freq = full_result["refined_frequency"] else: subset_freq = freq for n_bits in range(1, m_bits + 1): bits_subset = bits[:, :n_bits] try: result = calibrate_weight_sine( bits_subset, freq=subset_freq, force_search=False, harmonic_order=harmonic_order, frequency_policy=frequency_policy, ) spectrum_result = analyze_spectrum( result["calibrated_signal"], osr=osr, win_type=win_type, create_plot=False, ) enob_sweep[n_bits - 1] = spectrum_result["enob"] except Exception as exc: enob_sweep[n_bits - 1] = np.nan if verbose: print(f"[{n_bits:2d} bits] FAILED: {exc}") continue if verbose: print(f"[{n_bits:2d} bits] ENOB = {enob_sweep[n_bits - 1]:.2f}") return enob_sweep def _sweep_prefix_of_full_calibration( bits: np.ndarray, freq: float | None, harmonic_order: int, osr: int, win_type: str, frequency_policy: str, verbose: bool, ) -> np.ndarray: """Historical Python mode: sweep prefixes of one full-bit calibration.""" _, m_bits = bits.shape enob_sweep = np.zeros(m_bits) result = calibrate_weight_sine( bits, freq=freq, harmonic_order=harmonic_order, frequency_policy=frequency_policy, ) weights_all = result["weight"] for n_bits in range(1, m_bits + 1): bits_subset = bits[:, :n_bits] weights_subset = weights_all[:n_bits] calibrated_signal = bits_subset @ weights_subset try: spectrum_result = analyze_spectrum( calibrated_signal, osr=osr, win_type=win_type, create_plot=False, ) enob_sweep[n_bits - 1] = spectrum_result["enob"] except Exception as exc: enob_sweep[n_bits - 1] = np.nan if verbose: print(f"[{n_bits:2d} bits] FAILED: {exc}") continue if verbose: print(f"[{n_bits:2d} bits] ENOB = {enob_sweep[n_bits - 1]:.2f}") return enob_sweep