mcltspice/src/mcp_ltspice/noise_analysis.py
2026-02-10 23:35:53 -07:00

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,
}