"""
ADC Signal Generator with Applier Pattern for Non-Idealities
This module provides the ADC_Signal_Generator class, which implements the Applier Pattern
for applying various ADC non-idealities to a base sine wave signal.
"""
import numpy as np
from scipy import signal as scipy_signal
from scipy.interpolate import CubicSpline
[docs]
class ADC_Signal_Generator:
"""
Generates ADC test signals with various non-idealities using the Applier Pattern.
Each method accepts optional input_signal parameter. If None, uses clean base signal.
Methods return signal with non-ideality applied. Supports chaining multiple effects.
Parameters
----------
N : int
Number of samples
Fs : float
Sampling frequency (Hz)
Fin : float
Input signal frequency (Hz)
A : float
Input signal amplitude (e.g., 0.49)
DC : float
Input signal DC offset (e.g., 0.5)
"""
[docs]
def __init__(self, N, Fs, Fin, A, DC):
"""Initialize ADC_Signal_Generator with signal parameters."""
self.N = N
self.Fs = Fs
self.Fin = Fin
self.A = A
self.DC = DC
self.t = np.arange(N) / Fs
def _get_base_signal(self):
"""Generate clean sine wave: A*sin(2π*Fin*t) + DC (no noise)."""
return self.A * np.sin(2 * np.pi * self.Fin * self.t) + self.DC
def _resolve_signal(self, input_signal):
"""Helper: return copy of input_signal, or generate base signal if None."""
if input_signal is None:
return self._get_base_signal()
return input_signal.copy()
[docs]
def get_clean_signal(self):
"""Return clean sine wave: A*sin(2π*Fin*t) + DC (explicit public interface)."""
return self.A * np.sin(2 * np.pi * self.Fin * self.t) + self.DC
[docs]
def apply_thermal_noise(self, input_signal=None, noise_rms=50e-6):
"""Apply white thermal noise. Params: input_signal (None->clean), noise_rms (default 50e-6)."""
signal = self._resolve_signal(input_signal)
noise = np.random.randn(self.N) * noise_rms
return signal + noise
[docs]
def apply_quantization_noise(self, input_signal=None, n_bits=10, quant_range=(0.0, 1.0)):
""" Apply quantization noise. Params: quant_range: tuple (v_min, v_max), e.g., (0, 1) or (-0.5, 0.5)."""
signal = self._resolve_signal(input_signal)
v_min, v_max = quant_range
# 1. Calculate LSB size based on Full Scale Range
lsb = (v_max - v_min) / (2 ** n_bits)
# 2. Voltage to Code (floor operation)
codes = np.floor((signal - v_min) / lsb)
# 3. Simulate ADC Saturation (Clip to valid code range 0 ~ 2^N-1)
max_code = (2 ** n_bits) - 1
codes = np.clip(codes, 0, max_code)
# 4. Code to Voltage (Reconstruction)
return codes * lsb + v_min
[docs]
def apply_jitter(self, input_signal=None, jitter_rms=2e-12):
"""
Apply sampling jitter.
Logic:
- Case 1 (Source Generation): If input_signal is None, regenerates from scratch (Perfect Precision).
- Case 2 (Chain Processing): If input is provided, uses Cubic Spline interpolation (Preserves previous errors).
"""
# 1. Generate timing jitter
t_jitter = np.random.randn(self.N) * jitter_rms
if input_signal is None:
# Case 1: Perfect mathematical generation (No interpolation error)
total_phase = 2 * np.pi * self.Fin * (self.t + t_jitter)
return self.A * np.sin(total_phase) + self.DC
else:
# Case 2: High-precision interpolation (Preserves previous distortions)
signal = input_signal.copy()
t_sample = self.t + t_jitter
# Cubic Spline is much better than linear interp for preserving SNR
cs = CubicSpline(self.t, signal)
return cs(t_sample)
[docs]
def apply_static_nonlinearity(self, input_signal=None, k2=0, k3=0, k4=0, k5=0):
"""Applies static nonlinear distortion to the signal using direct polynomial coefficients (k2 to k5)."""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
# Polynomial calculation (Vectorized)
# y = x + k2*x^2 + ...
# Optimized to avoid creating too many temporary arrays if coeffs are 0
distortion = np.zeros_like(signal_ac)
if k2 != 0: distortion += k2 * (signal_ac ** 2)
if k3 != 0: distortion += k3 * (signal_ac ** 3)
if k4 != 0: distortion += k4 * (signal_ac ** 4)
if k5 != 0: distortion += k5 * (signal_ac ** 5)
# Add distortion to original AC signal
signal_distorted = signal_ac + distortion
return signal_distorted + self.DC
[docs]
def apply_static_nonlinearity_hd(self, input_signal=None, hd2_dB=None, hd3_dB=None, hd4_dB=None, hd5_dB=None):
"""Converts specified Harmonic Distortion levels (dBc) to polynomial coefficients and applies the nonlinearity."""
def db_to_k(db_val, order):
if db_val is None:
return 0.0
amp_ratio = 10 ** (db_val / 20.0) # k = (2^(n-1) * 10^(dB/20)) / A^(n-1)
return (2**(order-1) * amp_ratio) / (self.A**(order-1))
_k2 = db_to_k(hd2_dB, 2)
_k3 = db_to_k(hd3_dB, 3)
_k4 = db_to_k(hd4_dB, 4)
_k5 = db_to_k(hd5_dB, 5)
return self.apply_static_nonlinearity(input_signal, k2=_k2, k3=_k3, k4=_k4, k5=_k5)
[docs]
def apply_memory_effect(self, input_signal=None, memory_strength=0.009):
"""Apply memory effect (charge injection). The previous MSB decision leaks back to the input.
Params: input_signal, memory_strength (default 0.009)."""
signal = self._resolve_signal(input_signal)
# Quantize into MSB (4-bit coarse) and LSB (12-bit fine) stages
msb = np.floor(signal * 2**4) / 2**4
lsb = np.floor((signal - msb) * 2**12) / 2**12
# Apply memory effect from previous MSB
msb_shifted = np.roll(msb, shift=1)
return msb + lsb + memory_strength * msb_shifted
[docs]
def apply_incomplete_sampling(self, input_signal=None, T_track=None, tau_nom=40e-12, coeff_k=0.15):
"""Apply dynamic nonlinearity (tracking/settling). Models slew-rate and signal-dependent settling errors.
Params: input_signal, T_track, tau_nom (default 40ps), coeff_k (default 0.15)."""
signal = self._resolve_signal(input_signal)
if T_track is None:
T_track = (1 / self.Fs) * 0.2
signal_ac = signal - self.DC
vout = np.zeros(self.N)
v_prev = 0
for n in range(self.N):
v_target = signal_ac[n]
# Dynamic time constant changes with voltage (creates HD3)
tau_dynamic = tau_nom * (1 + coeff_k * v_target ** 2)
# Incomplete settling: output depends on previous state
vout[n] = v_target + (v_prev - v_target) * np.exp(-T_track / tau_dynamic)
v_prev = vout[n]
return vout + self.DC
[docs]
def apply_ra_gain_error(self, input_signal=None, relative_gain=0.99, msb_bits=4, lsb_bits=12):
"""Apply interstage gain error (2-stage pipeline ADC). Params: input_signal, relative_gain (default 0.99 = 1% error)."""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
# Quantize into MSB and LSB stages
msb = np.floor(signal_ac * 2**msb_bits) / 2**msb_bits
lsb = np.floor((signal_ac - msb) * 2**lsb_bits) / 2**lsb_bits
# Apply gain error to MSB stage, combine with LSB
return msb * relative_gain + lsb + self.DC
[docs]
def apply_ra_gain_error_dynamic(self, input_signal=None, relative_gain=1, coeff_3=0.15, msb_bits=4, lsb_bits=12):
"""Applies dynamic gain error to the interstage residue amplifier in a pipeline ADC. G[n] is non-linearly dependent on the previous residue output magnitude (V_prev_ac^3), modeling HD3 memory effects."""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
v_output_ac = np.zeros(self.N)
# Memory term initialization: AC component of the previous residue amp output
v_residue_out_prev_ac = 0.0
for n in range(self.N):
v_in_ac = signal_ac[n]
# Coarse Quantization (V_DAC)
v_msb_code = np.floor(v_in_ac * 2**msb_bits) / 2**msb_bits
v_lsb_code = np.floor((v_in_ac - v_msb_code) * 2**lsb_bits) / 2**lsb_bits
G_dynamic = relative_gain + coeff_3 * (v_residue_out_prev_ac ** 2)
v_output_ac[n] = v_msb_code * G_dynamic + v_lsb_code
# Update Memory Term
v_residue_out_prev_ac = v_output_ac[n]
return v_output_ac + self.DC
[docs]
def apply_reference_error(self, input_signal=None, settling_tau=2.0, droop_strength=0.01):
"""
Apply Reference Incomplete Settling error (Vref Memory Effect).
This simulates the reference voltage dropping due to load current (kick)
and failing to recover fully before the next sample.
Params:
input_signal: The input signal.
settling_tau: Recovery time constant in units of samples (e.g., 2.0).
Larger = Slower recovery = Worse settling.
droop_strength: How much Vref drops proportional to signal amplitude (0.01 = 1%).
"""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
# Calculate Vref droop using IIR filter to simulate exponential decay
current_kick = droop_strength * np.abs(signal_ac)
decay = np.exp(-1.0 / settling_tau)
from scipy.signal import lfilter
vref_droop = lfilter([1], [1, -decay], current_kick)
signal_settled = signal * (1.0 - vref_droop)
return signal_settled + self.DC
[docs]
def apply_am_noise(self, input_signal=None, strength=0.01):
"""
Apply random AM noise (Multiplicative Thermal Noise).
Params:
input_signal: The signal to modulate.
am_noise_depth: The RMS level of the noise relative to signal amplitude (default 0.1).
Note:
There is NO frequency parameter here because white noise contains ALL frequencies.
"""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
am_envelope = 1 + strength * np.random.normal(loc=0.0, scale=1.0, size=len(self.t))
signal_am = signal_ac * am_envelope
return signal_am + self.DC
[docs]
def apply_am_tone(self, input_signal=None, am_tone_freq=500e3, am_tone_depth=0.05):
"""Apply AM tone (coherent modulation). Params: input_signal, am_tone_freq (default 500kHz), am_tone_depth (default 0.05)."""
signal = self._resolve_signal(input_signal)
signal_ac = signal - self.DC
am_tone_env = 1 + am_tone_depth * np.sin(2 * np.pi * am_tone_freq * self.t)
signal_am = signal_ac * am_tone_env
return signal_am + self.DC
[docs]
def apply_clipping(self, input_signal=None, percentile_clip=1.0):
"""Apply hard clipping based on signal's percentile (e.g., 1.0 clips top/bottom 1%)."""
signal = self._resolve_signal(input_signal) # Get base signal or input signal
# 1. Determine the clipping thresholds dynamically using percentiles.
lower_threshold = np.percentile(signal, percentile_clip)
upper_threshold = np.percentile(signal, 100.0 - percentile_clip)
# 2. Apply clipping: constrain signal values between the determined thresholds.
signal_clipped = np.clip(signal, lower_threshold, upper_threshold)
return signal_clipped
[docs]
def apply_drift(self, input_signal=None, drift_scale=5e-5):
"""Apply drift (low-frequency random walk). Params: input_signal, drift_scale (default 5e-5)."""
signal = self._resolve_signal(input_signal)
drift_steps = np.random.randn(self.N) * drift_scale
drift_walk = np.cumsum(drift_steps)
b, a = scipy_signal.butter(2, 0.001)
drift = scipy_signal.filtfilt(b, a, drift_walk)
return signal + drift
[docs]
def apply_glitch(self, input_signal=None, glitch_prob=0.00015, glitch_amplitude=0.1):
"""Apply random glitches. Params: input_signal, glitch_prob (default 0.00015 = 0.015%), glitch_amplitude (default 0.1)."""
signal = self._resolve_signal(input_signal)
glitch_mask = np.random.rand(self.N) < glitch_prob
glitch = glitch_mask * glitch_amplitude
return signal + glitch
[docs]
def apply_noise_shaping(self, input_signal=None, n_bits=10, quant_range=(0.0, 1.0), order=1):
"""
Apply noise-shaped quantization (1st to 5th order delta-sigma).
Noise Transfer Function: NTF(z) = (1 - z^-1)^order
Order characteristics:
- 1st order: 20 dB/decade roll-off (common in basic delta-sigma)
- 2nd order: 40 dB/decade roll-off (most common for oversampling ADCs)
- 3rd order: 60 dB/decade roll-off (aggressive shaping)
- 4th order: 80 dB/decade roll-off (maximum practical, stability concerns)
- 5th order: 100 dB/decade roll-off (rarely used, high stability risk)
This method applies actual quantization (using apply_quantization_noise),
then shapes the quantization error spectrum using the NTF filter.
Pushes quantization noise to higher frequencies, improving in-band SNR.
Params:
input_signal: Input signal (None -> clean sine wave)
n_bits: Quantizer resolution (default 10)
quant_range: Quantization range (v_min, v_max), e.g., (0, 1) or (-0.5, 0.5)
order: Noise shaping order (1, 2, 3, 4, or 5, default 1)
Returns:
Signal with noise-shaped quantization noise added
Raises:
ValueError: If order is not in [1, 2, 3, 4, 5]
"""
if order not in [1, 2, 3, 4, 5]:
raise ValueError(f"Noise shaping order must be 1, 2, 3, 4, or 5. Got: {order}")
signal = self._resolve_signal(input_signal)
# Generate actual quantization error (not Gaussian approximation)
signal_quantized = self.apply_quantization_noise(signal, n_bits=n_bits, quant_range=quant_range)
quant_error_white = signal_quantized - signal
# Binomial coefficients for (1 - z^-1)^order using Pascal's triangle
# Order 1: [1, -1]
# Order 2: [1, -2, 1]
# Order 3: [1, -3, 3, -1]
# Order 4: [1, -4, 6, -4, 1]
# Order 5: [1, -5, 10, -10, 5, -1]
coeffs = {
1: [1, -1],
2: [1, -2, 1],
3: [1, -3, 3, -1],
4: [1, -4, 6, -4, 1],
5: [1, -5, 10, -10, 5, -1]
}
coeff = coeffs[order]
# Apply NTF to shape the quantization error
quant_error_shaped = np.zeros(self.N)
# Initialize first 'order' samples
for n in range(min(order, self.N)):
quant_error_shaped[n] = sum(
coeff[k] * quant_error_white[n - k]
for k in range(n + 1)
)
# Apply full filter for remaining samples
for n in range(order, self.N):
quant_error_shaped[n] = sum(
coeff[k] * quant_error_white[n - k]
for k in range(len(coeff))
)
return signal + quant_error_shaped