adctoolbox.spectrum.plot_spectrum 源代码

"""
Pure spectrum plotting functionality without calculations.

This module extracts the plotting logic from analyze_spectrum to create
a pure plotting function that can be used with pre-computed metrics.
"""

import numpy as np
import matplotlib.pyplot as plt

[文档] def plot_spectrum(compute_results, show_title=True, show_label=True, plot_harmonics_up_to=3, ax=None): """ Pure spectrum plotting using pre-computed analysis results. Parameters: compute_results: Dictionary containing 'metrics' and 'plot_data' from compute_spectrum show_label: Add labels and annotations (True) or not (False) plot_harmonics_up_to: Number of harmonics to highlight show_title: Display auto-generated title (True) or not (False) ax: Optional matplotlib axes object """ # Extract metrics and plot_data from compute_results metrics = compute_results['metrics'] plot_data = compute_results['plot_data'] collided_harmonics = plot_data.get('collided_harmonics', []) # Extract plot data spec_db = plot_data['power_spectrum_db_plot'] freq = plot_data['freq'] fundamental_bin = plot_data['fundamental_bin'] sig_bin_start = plot_data['sig_bin_start'] sig_bin_end = plot_data['sig_bin_end'] spur_bin_idx = plot_data['spur_bin_idx'] spur_db = spec_db[spur_bin_idx] # Calculate from power_spectrum_db_plot is_coherent = plot_data.get('is_coherent', False) # Extract metadata N = compute_results['N'] M = compute_results['M'] fs = compute_results['fs'] osr = compute_results['osr'] v_offset = plot_data['v_offset'] Nd2_inband = len(freq) // osr # Noise floor line: use NSD (per-Hz) converted to per-bin level, then apply v_offset # NSD stays constant across OSR changes, while integrated noise_floor_dbfs varies # v_offset aligns the spectrum with sig_pwr_dbfs, so noise floor line must be shifted too nf_line_level = metrics['nsd_dbfs_hz'] + 10 * np.log10(fs / N) + v_offset # Build harmonics list from plot_data and metrics (for plotting) harmonic_bins = plot_data.get('harmonic_bins', []) harmonics_dbc = metrics.get('harmonics_dbc', []) harmonics = [] if len(harmonic_bins) > 0 and len(harmonics_dbc) > 0: for harmonic_index in range(len(harmonics_dbc)): harmonic_order = harmonic_index + 2 # HD2=2, HD3=3, etc. # Skip if this harmonic collided with fundamental if harmonic_order in collided_harmonics: continue # Get harmonic bin position harmonic_bin_center = harmonic_bins[harmonic_index] # Get power in dB and calculate frequency harmonic_power_db = spec_db[harmonic_bin_center] harmonic_freq = harmonic_bin_center * fs / N harmonics.append({ 'harmonic_num': harmonic_order, 'freq': harmonic_freq, 'power_db': harmonic_power_db }) # Extract metrics enob = metrics['enob'] sndr_dbc = metrics['sndr_dbc'] sfdr_dbc = metrics['sfdr_dbc'] thd_dbc = metrics['thd_dbc'] snr_dbc = metrics['snr_dbc'] sig_pwr_dbfs = metrics['sig_pwr_dbfs'] noise_floor_dbfs = metrics['noise_floor_dbfs'] nsd_dbfs_hz = metrics['nsd_dbfs_hz'] # Setup axes if ax is None: ax = plt.gca() # --- Plot spectrum --- # Always use ax.plot() - when osr>1, the semilogx call later will convert axes to log ax.plot(freq, spec_db) ax.grid(True, which='both', linestyle='--') if show_label: # Highlight fundamental - always use ax.plot(), axes scale handled by osr ax.plot(freq[sig_bin_start:sig_bin_end], spec_db[sig_bin_start:sig_bin_end], 'r-', linewidth=2.0) ax.plot(freq[fundamental_bin], spec_db[fundamental_bin], 'ro', linewidth=1.0, markersize=8) # Plot harmonics if plot_harmonics_up_to > 0: for harm in harmonics: if harm['harmonic_num'] <= plot_harmonics_up_to: ax.plot(harm['freq'], harm['power_db'], 'rs', markersize=5) ax.text(harm['freq'], harm['power_db'] + 3, str(harm['harmonic_num']), fontname='Arial', fontsize=12, ha='center') # Plot max spurious ax.plot(spur_bin_idx / N * fs, spur_db, 'rd', markersize=5) ax.text(spur_bin_idx / N * fs, spur_db + 10, 'MaxSpur', fontname='Arial', fontsize=10, ha='center') # --- Set axis limits --- # Adaptive y-axis: start at -100 dB, extend if >5% of data is below each threshold minx = -100 for threshold in [-100, -120, -140, -160, -180]: below_threshold = np.sum(spec_db[:Nd2_inband] < threshold) percentage = below_threshold / len(spec_db[:Nd2_inband]) * 100 if percentage > 5.0: minx = threshold - 20 # Extend to next level else: break minx = max(minx, -200) # Absolute floor ax.set_xlim(fs/N, fs/2) ax.set_ylim(minx, 0) # --- Add annotations --- if show_label: # OSR line ax.plot([fs/2/osr, fs/2/osr], [0, -1000], '--', color='gray', linewidth=1) # Text positioning if osr > 1: TX = 10**(np.log10(fs)*0.01 + np.log10(fs/N)*0.99) else: if fundamental_bin/N < 0.2: TX = fs * 0.3 else: TX = fs * 0.01 TYD = minx * 0.06 # Format helpers def format_freq(f): if f >= 1e9: return f'{f/1e9:.1f}G' elif f >= 1e6: return f'{f/1e6:.1f}M' elif f >= 1e3: return f'{f/1e3:.1f}K' else: return f'{f:.1f}' txt_fs = format_freq(fs) Fin = fundamental_bin/N * fs if Fin >= 1e9: txt_fin = f'{Fin/1e9:.1f}G' elif Fin >= 1e6: txt_fin = f'{Fin/1e6:.1f}M' elif Fin >= 1e3: txt_fin = f'{Fin/1e3:.1f}K' elif Fin >= 1: txt_fin = f'{Fin/1e3:.1f}' # Matches original logic else: txt_fin = f'{Fin:.3f}' # Annotation block ax.text(TX, TYD, f'Fin/fs = {txt_fin} / {txt_fs} Hz', fontsize=10) ax.text(TX, TYD*2, f'ENoB = {enob:.2f}', fontsize=10) ax.text(TX, TYD*3, f'SNDR = {sndr_dbc:.2f} dB', fontsize=10) ax.text(TX, TYD*4, f'SFDR = {sfdr_dbc:.2f} dB', fontsize=10) ax.text(TX, TYD*5, f'THD = {thd_dbc:.2f} dB', fontsize=10) ax.text(TX, TYD*6, f'SNR = {snr_dbc:.2f} dB', fontsize=10) ax.text(TX, TYD*7, f'Noise Floor = {noise_floor_dbfs:.2f} dB', fontsize=10) ax.text(TX, TYD*8, f'NSD = {nsd_dbfs_hz:.2f} dBFS/Hz', fontsize=10) # Noise floor baseline if osr > 1: ax.semilogx([fs/N, fs/2/osr], [nf_line_level, nf_line_level], 'r--', linewidth=1) ax.text(TX, TYD*9, f'OSR = {osr:.2f}', fontsize=10) else: ax.plot([0, fs/2], [nf_line_level, nf_line_level], 'r--', linewidth=1) # Add coherent integration gain note if is_coherent and M > 1: coh_gain_db = 10 * np.log10(M) if osr > 1: ax.text(TX, TYD*10, f'*Coherent Gain = {coh_gain_db:.2f} dB', fontsize=10) else: ax.text(TX, TYD*9, f'*Coherent Gain = {coh_gain_db:.2f} dB', fontsize=10) # Add collision warning if harmonics collided with fundamental if collided_harmonics: collision_str = ', '.join([f'HD{h}' for h in sorted(collided_harmonics)]) text_y_offset = TYD*11 if (is_coherent and M > 1 and osr > 1) else (TYD*10 if (is_coherent and M > 1) or osr > 1 else TYD*9) ax.text(TX, text_y_offset, f'*Collided with fundamental: {collision_str}', fontsize=10, color='orange') # Signal annotation sig_y_pos = min(sig_pwr_dbfs, TYD/2) if osr > 1: ax.text(freq[fundamental_bin], sig_y_pos, f'Sig = {sig_pwr_dbfs:.2f} dB', fontsize=10) else: offset = -0.01 if fundamental_bin/N > 0.4 else 0.01 ha_align = 'right' if fundamental_bin/N > 0.4 else 'left' ax.text((fundamental_bin/N + offset) * fs, sig_y_pos, f'Sig = {sig_pwr_dbfs:.2f} dB', ha=ha_align, fontsize=10) ax.set_xlabel('Freq (Hz)', fontsize=10) ax.set_ylabel('dBFS', fontsize=10) # Title - auto-generate based on mode and number of runs if show_title: if is_coherent: if M > 1: ax.set_title(f'Coherent averaging (N_run = {M})', fontsize=12, fontweight='bold') else: ax.set_title('Coherent Spectrum', fontsize=12, fontweight='bold') else: if M > 1: ax.set_title(f'Power averaging (N_run = {M})', fontsize=12, fontweight='bold') else: ax.set_title('Power Spectrum', fontsize=12, fontweight='bold')