366 lines
12 KiB
Python
366 lines
12 KiB
Python
"""Noise analysis for LTspice .noise simulation results.
|
|
|
|
LTspice .noise analysis produces output with variables like 'onoise'
|
|
(output-referred noise spectral density in V/sqrt(Hz)) and 'inoise'
|
|
(input-referred noise spectral density). The data is complex-valued
|
|
in the .raw file; magnitude gives the spectral density.
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
# np.trapz was renamed to np.trapezoid in numpy 2.0
|
|
_trapz = getattr(np, "trapezoid", getattr(np, "trapz", None))
|
|
|
|
# Boltzmann constant (J/K)
|
|
_K_BOLTZMANN = 1.380649e-23
|
|
|
|
|
|
def compute_noise_spectral_density(frequency: np.ndarray, noise_signal: np.ndarray) -> dict:
|
|
"""Compute noise spectral density from raw noise simulation data.
|
|
|
|
Takes the frequency array and complex noise signal directly from the
|
|
.raw file and returns the noise spectral density in V/sqrt(Hz) and dB.
|
|
|
|
Args:
|
|
frequency: Frequency array in Hz (may be complex; real part is used)
|
|
noise_signal: Complex noise signal from .raw file (magnitude = V/sqrt(Hz))
|
|
|
|
Returns:
|
|
Dict with frequency_hz, noise_density_v_per_sqrt_hz, noise_density_db
|
|
"""
|
|
if len(frequency) == 0 or len(noise_signal) == 0:
|
|
return {
|
|
"frequency_hz": [],
|
|
"noise_density_v_per_sqrt_hz": [],
|
|
"noise_density_db": [],
|
|
}
|
|
|
|
freq = np.real(frequency).astype(np.float64)
|
|
density = np.abs(noise_signal)
|
|
|
|
# Single-point case: still return valid data
|
|
density_db = 20.0 * np.log10(np.maximum(density, 1e-30))
|
|
|
|
return {
|
|
"frequency_hz": freq.tolist(),
|
|
"noise_density_v_per_sqrt_hz": density.tolist(),
|
|
"noise_density_db": density_db.tolist(),
|
|
}
|
|
|
|
|
|
def compute_total_noise(
|
|
frequency: np.ndarray,
|
|
noise_signal: np.ndarray,
|
|
f_low: float | None = None,
|
|
f_high: float | None = None,
|
|
) -> dict:
|
|
"""Integrate noise spectral density over frequency to get total RMS noise.
|
|
|
|
Computes total_rms = sqrt(integral(|noise|^2 * df)) using trapezoidal
|
|
integration over the specified frequency range.
|
|
|
|
Args:
|
|
frequency: Frequency array in Hz (may be complex; real part is used)
|
|
noise_signal: Complex noise signal from .raw file
|
|
f_low: Lower integration bound in Hz (default: min frequency in data)
|
|
f_high: Upper integration bound in Hz (default: max frequency in data)
|
|
|
|
Returns:
|
|
Dict with total_rms_v, integration_range_hz, equivalent_noise_bandwidth_hz
|
|
"""
|
|
if len(frequency) < 2 or len(noise_signal) < 2:
|
|
return {
|
|
"total_rms_v": 0.0,
|
|
"integration_range_hz": [0.0, 0.0],
|
|
"equivalent_noise_bandwidth_hz": 0.0,
|
|
}
|
|
|
|
freq = np.real(frequency).astype(np.float64)
|
|
density = np.abs(noise_signal)
|
|
|
|
# Sort by frequency to ensure correct integration order
|
|
sort_idx = np.argsort(freq)
|
|
freq = freq[sort_idx]
|
|
density = density[sort_idx]
|
|
|
|
# Apply frequency bounds
|
|
if f_low is None:
|
|
f_low = float(freq[0])
|
|
if f_high is None:
|
|
f_high = float(freq[-1])
|
|
|
|
mask = (freq >= f_low) & (freq <= f_high)
|
|
freq_band = freq[mask]
|
|
density_band = density[mask]
|
|
|
|
if len(freq_band) < 2:
|
|
return {
|
|
"total_rms_v": 0.0,
|
|
"integration_range_hz": [f_low, f_high],
|
|
"equivalent_noise_bandwidth_hz": 0.0,
|
|
}
|
|
|
|
# Integrate |noise|^2 over frequency, then take sqrt for RMS
|
|
noise_power = density_band**2
|
|
integrated = float(_trapz(noise_power, freq_band))
|
|
total_rms = float(np.sqrt(max(integrated, 0.0)))
|
|
|
|
# Equivalent noise bandwidth: bandwidth of a brick-wall filter with the
|
|
# same peak density that would pass the same total noise power
|
|
peak_density = float(np.max(density_band))
|
|
if peak_density > 1e-30:
|
|
enbw = integrated / (peak_density**2)
|
|
else:
|
|
enbw = 0.0
|
|
|
|
return {
|
|
"total_rms_v": total_rms,
|
|
"integration_range_hz": [f_low, f_high],
|
|
"equivalent_noise_bandwidth_hz": float(enbw),
|
|
}
|
|
|
|
|
|
def compute_spot_noise(frequency: np.ndarray, noise_signal: np.ndarray, target_freq: float) -> dict:
|
|
"""Interpolate noise spectral density at a specific frequency.
|
|
|
|
Uses linear interpolation between adjacent data points to estimate
|
|
the noise density at the requested frequency.
|
|
|
|
Args:
|
|
frequency: Frequency array in Hz (may be complex; real part is used)
|
|
noise_signal: Complex noise signal from .raw file
|
|
target_freq: Desired frequency in Hz
|
|
|
|
Returns:
|
|
Dict with spot_noise_v_per_sqrt_hz, spot_noise_db, actual_freq_hz
|
|
"""
|
|
if len(frequency) == 0 or len(noise_signal) == 0:
|
|
return {
|
|
"spot_noise_v_per_sqrt_hz": 0.0,
|
|
"spot_noise_db": float("-inf"),
|
|
"actual_freq_hz": target_freq,
|
|
}
|
|
|
|
freq = np.real(frequency).astype(np.float64)
|
|
density = np.abs(noise_signal)
|
|
|
|
# Sort by frequency
|
|
sort_idx = np.argsort(freq)
|
|
freq = freq[sort_idx]
|
|
density = density[sort_idx]
|
|
|
|
# Clamp to data range
|
|
if target_freq <= freq[0]:
|
|
spot = float(density[0])
|
|
actual = float(freq[0])
|
|
elif target_freq >= freq[-1]:
|
|
spot = float(density[-1])
|
|
actual = float(freq[-1])
|
|
else:
|
|
# Linear interpolation
|
|
spot = float(np.interp(target_freq, freq, density))
|
|
actual = target_freq
|
|
|
|
spot_db = 20.0 * np.log10(max(spot, 1e-30))
|
|
|
|
return {
|
|
"spot_noise_v_per_sqrt_hz": spot,
|
|
"spot_noise_db": spot_db,
|
|
"actual_freq_hz": actual,
|
|
}
|
|
|
|
|
|
def compute_noise_figure(
|
|
frequency: np.ndarray,
|
|
noise_signal: np.ndarray,
|
|
source_resistance: float = 50.0,
|
|
temperature: float = 290.0,
|
|
) -> dict:
|
|
"""Compute noise figure from noise spectral density.
|
|
|
|
Noise figure is the ratio of the measured output noise power to
|
|
the thermal noise of the source resistance at the given temperature.
|
|
NF(f) = 10*log10(|noise(f)|^2 / (4*k*T*R))
|
|
|
|
Args:
|
|
frequency: Frequency array in Hz (may be complex; real part is used)
|
|
noise_signal: Complex noise signal from .raw file (output-referred)
|
|
source_resistance: Source impedance in ohms (default 50)
|
|
temperature: Temperature in Kelvin (default 290 K, IEEE standard)
|
|
|
|
Returns:
|
|
Dict with noise_figure_db (array), frequency_hz (array),
|
|
min_nf_db, nf_at_1khz
|
|
"""
|
|
if len(frequency) == 0 or len(noise_signal) == 0:
|
|
return {
|
|
"noise_figure_db": [],
|
|
"frequency_hz": [],
|
|
"min_nf_db": None,
|
|
"nf_at_1khz": None,
|
|
}
|
|
|
|
freq = np.real(frequency).astype(np.float64)
|
|
density = np.abs(noise_signal)
|
|
|
|
# Thermal noise power spectral density of the source: 4*k*T*R (V^2/Hz)
|
|
thermal_psd = 4.0 * _K_BOLTZMANN * temperature * source_resistance
|
|
|
|
if thermal_psd < 1e-50:
|
|
return {
|
|
"noise_figure_db": [],
|
|
"frequency_hz": freq.tolist(),
|
|
"min_nf_db": None,
|
|
"nf_at_1khz": None,
|
|
}
|
|
|
|
# NF = 10*log10(measured_noise_power / thermal_noise_power)
|
|
# where noise_power = density^2 per Hz
|
|
noise_power = density**2
|
|
nf_ratio = noise_power / thermal_psd
|
|
nf_db = 10.0 * np.log10(np.maximum(nf_ratio, 1e-30))
|
|
|
|
min_nf_db = float(np.min(nf_db))
|
|
|
|
# Noise figure at 1 kHz (interpolated)
|
|
nf_at_1khz = None
|
|
if freq[0] <= 1000.0 <= freq[-1]:
|
|
sort_idx = np.argsort(freq)
|
|
nf_at_1khz = float(np.interp(1000.0, freq[sort_idx], nf_db[sort_idx]))
|
|
elif len(freq) == 1:
|
|
nf_at_1khz = float(nf_db[0])
|
|
|
|
return {
|
|
"noise_figure_db": nf_db.tolist(),
|
|
"frequency_hz": freq.tolist(),
|
|
"min_nf_db": min_nf_db,
|
|
"nf_at_1khz": nf_at_1khz,
|
|
}
|
|
|
|
|
|
def _estimate_flicker_corner(frequency: np.ndarray, density: np.ndarray) -> float | None:
|
|
"""Estimate the 1/f noise corner frequency.
|
|
|
|
The 1/f corner is where the noise transitions from 1/f (flicker) behavior
|
|
to flat (white) noise. We find where the slope of log(density) vs log(freq)
|
|
crosses -0.25 (midpoint between 0 for white and -0.5 for 1/f in V/sqrt(Hz)).
|
|
|
|
Args:
|
|
frequency: Sorted frequency array in Hz (positive, ascending)
|
|
density: Noise spectral density magnitude (same order as frequency)
|
|
|
|
Returns:
|
|
Corner frequency in Hz, or None if not detectable
|
|
"""
|
|
if len(frequency) < 4:
|
|
return None
|
|
|
|
# Work in log-log space
|
|
pos_mask = (frequency > 0) & (density > 0)
|
|
freq_pos = frequency[pos_mask]
|
|
dens_pos = density[pos_mask]
|
|
|
|
if len(freq_pos) < 4:
|
|
return None
|
|
|
|
log_f = np.log10(freq_pos)
|
|
log_d = np.log10(dens_pos)
|
|
|
|
# Compute local slope using central differences (smoothed)
|
|
# Use a window of ~5 points for robustness
|
|
n = len(log_f)
|
|
slopes = np.zeros(n)
|
|
half_win = min(2, (n - 1) // 2)
|
|
|
|
for i in range(half_win, n - half_win):
|
|
df = log_f[i + half_win] - log_f[i - half_win]
|
|
dd = log_d[i + half_win] - log_d[i - half_win]
|
|
if abs(df) > 1e-15:
|
|
slopes[i] = dd / df
|
|
|
|
# Fill edges with nearest valid slope
|
|
slopes[:half_win] = slopes[half_win]
|
|
slopes[n - half_win :] = slopes[n - half_win - 1]
|
|
|
|
# Find where slope crosses the threshold (-0.25)
|
|
# 1/f noise has slope ~ -0.5 in V/sqrt(Hz), white has slope ~ 0
|
|
threshold = -0.25
|
|
|
|
for i in range(len(slopes) - 1):
|
|
if slopes[i] < threshold <= slopes[i + 1]:
|
|
# Interpolate
|
|
ds = slopes[i + 1] - slopes[i]
|
|
if abs(ds) < 1e-15:
|
|
return float(freq_pos[i])
|
|
frac = (threshold - slopes[i]) / ds
|
|
log_corner = log_f[i] + frac * (log_f[i + 1] - log_f[i])
|
|
return float(10.0**log_corner)
|
|
|
|
return None
|
|
|
|
|
|
def compute_noise_metrics(
|
|
frequency: np.ndarray,
|
|
noise_signal: np.ndarray,
|
|
source_resistance: float = 50.0,
|
|
) -> dict:
|
|
"""Comprehensive noise analysis report.
|
|
|
|
Combines spectral density, spot noise at standard frequencies, total
|
|
integrated noise, noise figure, and 1/f corner estimation.
|
|
|
|
Args:
|
|
frequency: Frequency array in Hz (may be complex; real part is used)
|
|
noise_signal: Complex noise signal from .raw file
|
|
source_resistance: Source impedance in ohms for noise figure (default 50)
|
|
|
|
Returns:
|
|
Dict with spectral_density, spot_noise (at standard frequencies),
|
|
total_noise, noise_figure, flicker_corner_hz
|
|
"""
|
|
if len(frequency) < 2 or len(noise_signal) < 2:
|
|
return {
|
|
"spectral_density": compute_noise_spectral_density(frequency, noise_signal),
|
|
"spot_noise": {},
|
|
"total_noise": compute_total_noise(frequency, noise_signal),
|
|
"noise_figure": compute_noise_figure(frequency, noise_signal, source_resistance),
|
|
"flicker_corner_hz": None,
|
|
}
|
|
|
|
freq = np.real(frequency).astype(np.float64)
|
|
|
|
# Spectral density
|
|
spectral = compute_noise_spectral_density(frequency, noise_signal)
|
|
|
|
# Spot noise at standard frequencies
|
|
spot_freqs = [10.0, 100.0, 1000.0, 10000.0, 100000.0]
|
|
spot_labels = ["10Hz", "100Hz", "1kHz", "10kHz", "100kHz"]
|
|
spot_noise = {}
|
|
|
|
f_min = float(np.min(freq))
|
|
f_max = float(np.max(freq))
|
|
|
|
for label, sf in zip(spot_labels, spot_freqs):
|
|
if f_min <= sf <= f_max:
|
|
spot_noise[label] = compute_spot_noise(frequency, noise_signal, sf)
|
|
|
|
# Total noise over full bandwidth
|
|
total = compute_total_noise(frequency, noise_signal)
|
|
|
|
# Noise figure
|
|
nf = compute_noise_figure(frequency, noise_signal, source_resistance)
|
|
|
|
# 1/f corner frequency estimation
|
|
sort_idx = np.argsort(freq)
|
|
sorted_freq = freq[sort_idx]
|
|
sorted_density = np.abs(noise_signal)[sort_idx]
|
|
flicker_corner = _estimate_flicker_corner(sorted_freq, sorted_density)
|
|
|
|
return {
|
|
"spectral_density": spectral,
|
|
"spot_noise": spot_noise,
|
|
"total_noise": total,
|
|
"noise_figure": nf,
|
|
"flicker_corner_hz": float(flicker_corner) if flicker_corner is not None else None,
|
|
}
|