diff --git a/python/src/adctoolbox/spectrum/plot_spectrum.py b/python/src/adctoolbox/spectrum/plot_spectrum.py index 81a53e8..8eee64c 100644 --- a/python/src/adctoolbox/spectrum/plot_spectrum.py +++ b/python/src/adctoolbox/spectrum/plot_spectrum.py @@ -38,6 +38,73 @@ def _should_label_harmonic(harmonic_power_db, nf_line_level, margin_db=20): return harmonic_power_db >= nf_line_level - margin_db +def _refresh_max_spur_annotation( + ax, + marker_artist, + text_artist, + spur_db, + above_db=10, + below_db=8, +): + """Keep the MaxSpur marker/label meaningful within the current y-limits.""" + y0, y1 = ax.get_ylim() + y_min, y_max = min(y0, y1), max(y0, y1) + if not np.isfinite(spur_db) or not np.isfinite(y_min) or not np.isfinite(y_max) or y_max <= y_min: + marker_artist.set_visible(False) + text_artist.set_visible(False) + return + + y_span = y_max - y_min + margin_db = min(max(0.04 * y_span, 1.0), 6.0) + + if spur_db <= y_min + margin_db or spur_db > y_max: + marker_artist.set_visible(False) + text_artist.set_visible(False) + return + + spur_x = float(np.ravel(marker_artist.get_xdata())[0]) + x_axes = ax.transAxes.inverted().transform( + ax.transData.transform((spur_x, spur_db)) + )[0] + if not np.isfinite(x_axes) or x_axes < 0 or x_axes > 1: + marker_artist.set_visible(False) + text_artist.set_visible(False) + return + + marker_artist.set_visible(True) + text_artist.set_visible(True) + + top_limit = y_max - margin_db + bottom_limit = y_min + margin_db + label_y = spur_db + above_db + va = 'bottom' + if label_y > top_limit: + label_y = spur_db - below_db + va = 'top' + if label_y < bottom_limit: + label_y = min(max(spur_db, bottom_limit), top_limit) + va = 'center' + + text_artist.set_y(label_y) + text_artist.set_va(va) + if x_axes > 0.95: + text_artist.set_ha('right') + elif x_axes < 0.05: + text_artist.set_ha('left') + else: + text_artist.set_ha('center') + + +def _attach_max_spur_annotation(ax, marker_artist, text_artist, spur_db): + """Update MaxSpur annotation when callers adjust y-limits after plotting.""" + def _on_limits_changed(changed_ax): + _refresh_max_spur_annotation(changed_ax, marker_artist, text_artist, spur_db) + + _on_limits_changed(ax) + ax.callbacks.connect('ylim_changed', _on_limits_changed) + ax.callbacks.connect('xlim_changed', _on_limits_changed) + + 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. @@ -131,12 +198,19 @@ def plot_spectrum(compute_results, show_title=True, show_label=True, plot_harmon ): 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') + fontname='Arial', fontsize=12, ha='center', clip_on=True) # 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') + max_spur_marker, = ax.plot(spur_bin_idx / N * fs, spur_db, 'rd', markersize=5) + max_spur_label = ax.text( + spur_bin_idx / N * fs, + spur_db + 10, + 'MaxSpur', + fontname='Arial', + fontsize=10, + ha='center', + clip_on=True, + ) # --- Set axis limits (plotspec.m: median(in-band)-20, clamped) --- median_inband = float(np.median(spec_db[:n_inband])) @@ -148,6 +222,8 @@ def plot_spectrum(compute_results, show_title=True, show_label=True, plot_harmon x_max = fs / 2 ax.set_xlim(x_min, x_max) ax.set_ylim(minx, 0) + if show_label: + _attach_max_spur_annotation(ax, max_spur_marker, max_spur_label, spur_db) # --- Add annotations --- if show_label: diff --git a/python/src/adctoolbox/spectrum/plot_spectrum_virtuoso.py b/python/src/adctoolbox/spectrum/plot_spectrum_virtuoso.py index b703877..f3cf4ea 100644 --- a/python/src/adctoolbox/spectrum/plot_spectrum_virtuoso.py +++ b/python/src/adctoolbox/spectrum/plot_spectrum_virtuoso.py @@ -22,6 +22,7 @@ import matplotlib.pyplot as plt from adctoolbox.spectrum.plot_spectrum import ( + _attach_max_spur_annotation, _noise_floor_axis_min, _should_label_harmonic, ) @@ -137,13 +138,14 @@ def plot_spectrum_virtuoso(compute_results, show_title=True, show_label=True, ax.plot(bin_center * fs / N, spec_db[bin_center], 's', color=_C_HARM, markersize=5) ax.text(bin_center * fs / N, spec_db[bin_center] + 3, str(order), - color=_C_HARM, fontsize=11, ha='center') + color=_C_HARM, fontsize=11, ha='center', clip_on=True) # ---- Max-spur diamond + "MaxSpur" text --------------------------- - ax.plot(spur_bin_idx / N * fs, spur_db, - 'd', color=_C_FUND, markersize=5) - ax.text(spur_bin_idx / N * fs, spur_db + 10, 'MaxSpur', - color=_C_FUND, fontsize=10, ha='center') + max_spur_marker, = ax.plot(spur_bin_idx / N * fs, spur_db, + 'd', color=_C_FUND, markersize=5) + max_spur_label = ax.text(spur_bin_idx / N * fs, spur_db + 10, 'MaxSpur', + color=_C_FUND, fontsize=10, ha='center', clip_on=True) + _attach_max_spur_annotation(ax, max_spur_marker, max_spur_label, spur_db) # ---- Text-block positioning (mirrors plot_spectrum.py) ----------- # Axes-relative metric text stays fixed if callers change y-limits. diff --git a/python/tests/unit/spectrum/test_plot_spectrum.py b/python/tests/unit/spectrum/test_plot_spectrum.py index 3032594..54db8f9 100644 --- a/python/tests/unit/spectrum/test_plot_spectrum.py +++ b/python/tests/unit/spectrum/test_plot_spectrum.py @@ -10,6 +10,7 @@ _should_label_harmonic, plot_spectrum, ) +from adctoolbox.spectrum.plot_spectrum_virtuoso import plot_spectrum_virtuoso # Create output directory for test figures @@ -21,6 +22,37 @@ def _text_values(ax): return [text.get_text() for text in ax.texts] +def _max_spur_text(ax): + return next(text for text in ax.texts if text.get_text() == 'MaxSpur') + + +def _max_spur_marker(ax): + return next(line for line in ax.lines if line.get_marker() == 'd') + + +def _thermal_noise_demo_result(noise_rms=0.0): + n_fft = 2**13 + fs = 100e6 + fund_bin = 983 + n = np.arange(n_fft) + signal = 0.5 * np.sin(2 * np.pi * fund_bin * n / n_fft) + 0.5 + if noise_rms: + rng = np.random.default_rng(20260628) + signal = signal + rng.standard_normal(n_fft) * noise_rms + return compute_spectrum(signal, fs=fs) + + +def _strong_spur_result(): + n_fft = 2**14 + fs = 1.0 + n = np.arange(n_fft) + signal = ( + 0.49 * np.sin(2 * np.pi * 997 * n / n_fft) + + 0.42 * np.sin(2 * np.pi * 2501 * n / n_fft) + ) + return compute_spectrum(signal, fs=fs, win_type='rectangular', side_bin=0) + + def _assert_spectrum_axes(ax, result, *, show_label=True, expected_title='Power Spectrum'): """Verify that plot_spectrum rendered the expected plot structure.""" spectrum_line = ax.lines[0] @@ -126,6 +158,64 @@ def test_plot_spectrum_labels_stay_fixed_when_ylim_changes(): np.testing.assert_allclose(after, before, atol=0.5) +@pytest.mark.parametrize("plotter", [plot_spectrum, plot_spectrum_virtuoso]) +def test_max_spur_annotation_hides_when_spur_is_below_user_ylim(plotter): + result = _thermal_noise_demo_result(noise_rms=0.0) + + fig, ax = plt.subplots() + plotter(result, show_title=False, show_label=True, ax=ax) + ax.set_ylim([-140, 0]) + fig.canvas.draw() + + assert not _max_spur_text(ax).get_visible() + assert not _max_spur_marker(ax).get_visible() + plt.close(fig) + + +@pytest.mark.parametrize("plotter", [plot_spectrum, plot_spectrum_virtuoso]) +def test_max_spur_annotation_remains_visible_for_in_range_spur_after_user_ylim(plotter): + result = _thermal_noise_demo_result(noise_rms=50e-6) + + fig, ax = plt.subplots() + plotter(result, show_title=False, show_label=True, ax=ax) + ax.set_ylim([-140, 0]) + fig.canvas.draw() + + max_spur_text = _max_spur_text(ax) + ymin, ymax = ax.get_ylim() + assert max_spur_text.get_visible() + assert _max_spur_marker(ax).get_visible() + assert ymin <= max_spur_text.get_position()[1] <= ymax + renderer = fig.canvas.get_renderer() + text_bbox = max_spur_text.get_window_extent(renderer=renderer) + axes_bbox = ax.get_window_extent(renderer=renderer) + assert text_bbox.x0 >= axes_bbox.x0 - 0.5 + assert text_bbox.x1 <= axes_bbox.x1 + 0.5 + plt.close(fig) + + +@pytest.mark.parametrize("plotter", [plot_spectrum, plot_spectrum_virtuoso]) +def test_max_spur_annotation_stays_inside_axes_for_high_spur(plotter): + result = _strong_spur_result() + + fig, ax = plt.subplots(figsize=(7, 5)) + plotter(result, show_title=False, show_label=True, ax=ax) + fig.canvas.draw() + + max_spur_text = _max_spur_text(ax) + renderer = fig.canvas.get_renderer() + text_bbox = max_spur_text.get_window_extent(renderer=renderer) + axes_bbox = ax.get_window_extent(renderer=renderer) + + assert max_spur_text.get_visible() + assert _max_spur_marker(ax).get_visible() + assert text_bbox.x0 >= axes_bbox.x0 - 0.5 + assert text_bbox.x1 <= axes_bbox.x1 + 0.5 + assert text_bbox.y0 >= axes_bbox.y0 - 0.5 + assert text_bbox.y1 <= axes_bbox.y1 + 0.5 + plt.close(fig) + + @pytest.mark.parametrize( "harmonic_power_db,nf_line_level,expected", [ @@ -345,6 +435,9 @@ def test_plot_spectrum_high_harmonics(): texts = set(_text_values(ax)) assert {'2', '3', '4'}.issubset(texts) assert sum(line.get_marker() == 's' for line in ax.lines) >= 3 + for text in ax.texts: + if text.get_text() in {'2', '3', '4'}: + assert text.get_clip_on() # Save figure fig_path = output_dir / 'test_plot_high_harmonics.png'