Source code for adctoolbox.fundamentals.frequency

"""
Frequency Utilities for ADC Testing.

This module provides frequency-related calculations for coherent sampling,
aliasing, FFT bin folding, and frequency estimation in ADC characterization.
"""

import math
import numpy as np
from adctoolbox.fundamentals.fit_sine_4param import fit_sine_4param

[docs] def find_coherent_frequency(fs, fin_target, n_fft, force_odd=True, search_radius=200): """ Calculate the precise coherent input frequency and bin index. Supports Undersampling (Fin > Fs/2). Parameters ---------- fs : float Sampling frequency (Hz) fin_target : float Target input frequency (Hz) n_fft : int FFT size (number of points) force_odd : bool, optional If True, only search for odd bin indices (default: True) search_radius : int, optional Search radius around the ideal bin (default: 200) Returns ------- tuple (fin_actual, best_bin) - Coherent frequency and corresponding bin index Raises ------ ValueError If no valid coherent frequency is found within search radius Examples -------- >>> fin, bin_idx = find_coherent_frequency(800e6, 100e6, 8192) >>> print(f"Coherent frequency: {fin/1e6:.6f} MHz at bin {bin_idx}") """ # 1. Calculate the ideal (fractional) total cycles target_bin_float = fin_target / fs * n_fft # 2. Define search center (nearest integer) center_int = int(round(target_bin_float)) candidates = [] # 3. Search neighborhood for the best candidate for i in range(-search_radius, search_radius + 1): bin_candidate = center_int + i # Validity checks: Only check if positive. # REMOVED: "or bin_candidate >= n_fft // 2" to allow undersampling/high freq. if bin_candidate <= 0: continue # Condition A: Force Odd (Standard practice) if force_odd and (bin_candidate % 2 == 0): continue # Condition B: Coprime Check (GCD == 1) # Even if M > N, if they are coprime, the aliased bin will be unique. if math.gcd(bin_candidate, n_fft) == 1: # Score by distance to target dist = abs(bin_candidate - target_bin_float) candidates.append((dist, bin_candidate)) if not candidates: raise ValueError( f"No valid prime/odd bin found near {target_bin_float:.2f} cycles " f"(Fin={fin_target/1e6:.2f}MHz). Try increasing search_radius." ) # 4. Pick the winner (smallest distance) candidates.sort(key=lambda x: x[0]) best_bin = candidates[0][1] # 5. Calculate precise physical frequency fin_actual = best_bin * fs / n_fft return fin_actual, best_bin
[docs] def fold_frequency_to_nyquist(fin, fs): """ Calculate the aliased (folded) frequency in the first Nyquist zone. The aliased frequency is the absolute difference between the input frequency and the nearest integer multiple of the sampling rate. Parameters ---------- fin : float or np.ndarray Input frequency (Hz). Can be positive or negative. fs : float Sampling frequency (Hz) Returns ------- float or np.ndarray Aliased frequency in range [0, Fs/2] Examples -------- >>> fold_frequency_to_nyquist(100e6, 800e6) 100000000.0 >>> fold_frequency_to_nyquist(900e6, 800e6) 100000000.0 """ fin = np.asarray(fin) # The mathematical one-liner for folding: # F_alias = | Fin - Fs * round(Fin / Fs) | # Note: np.round rounds to nearest even number for .5 cases, which is fine here. f_alias = np.abs(fin - fs * np.round(fin / fs)) # Edge case handling: If logic results in exactly Fs/2, it's correct. # If fin was already scalar, convert back to scalar for cleaner return types (optional) if fin.ndim == 0: return float(f_alias) return f_alias
[docs] def fold_bin_to_nyquist(bin_idx: float, n_fft: int) -> float: """ Calculate the aliased bin index in the first Nyquist zone [0, n_fft/2]. For real signals, FFT bins above n_fft/2 are mirrored to the first Nyquist zone. This function handles the wrapping and mirroring. Parameters ---------- bin_idx : float Bin index (can be fractional, negative, or > n_fft) n_fft : int Total number of FFT bins Returns ------- float Aliased bin index in range [0, n_fft/2] Examples -------- >>> fold_bin_to_nyquist(100, 8192) 100.0 >>> fold_bin_to_nyquist(100.5, 8192) # Fractional bins supported 100.5 >>> fold_bin_to_nyquist(5000, 8192) # Above Nyquist, mirrors back 3192.0 >>> fold_bin_to_nyquist(-100, 8192) # Negative wraps around 92.0 """ # First wrap to [0, n_fft) range bin_idx = bin_idx % n_fft # For real signals, bins > n_fft/2 are mirrored if bin_idx > n_fft // 2: bin_idx = n_fft - bin_idx return bin_idx
[docs] def estimate_frequency(data, frequency_estimate=None, fs=1.0): """ Estimate the physical fundamental frequency (Hz) of a signal. This is a wrapper around the robust `fit_sine_4param` algorithm. It converts the normalized frequency (0 ~ 0.5) returned by fit_sine into physical frequency (Hz) based on the sampling rate. Parameters ---------- data : np.ndarray Input signal data. 1D or 2D array. fs : float, optional Sampling frequency in Hz (default: 1.0) Returns ------- float or np.ndarray Estimated frequency in Hz. (Scalar if input is 1D, Array if input is 2D) Examples -------- >>> import numpy as np >>> # Generate a 100 MHz sine wave sampled at 800 MHz >>> t = np.arange(8192) / 800e6 >>> signal = np.sin(2 * np.pi * 100e6 * t) >>> freq = estimate_frequency(signal, fs=800e6) >>> print(f"Estimated frequency: {freq/1e6:.2f} MHz") Estimated frequency: 100.00 MHz """ result = fit_sine_4param(data, frequency_estimate=frequency_estimate) freq_norm = result['frequency'] fin_hz = freq_norm * fs return fin_hz