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