Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions python/src/adctoolbox/spectrum/plot_spectrum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]))
Expand All @@ -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:
Expand Down
12 changes: 7 additions & 5 deletions python/src/adctoolbox/spectrum/plot_spectrum_virtuoso.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions python/tests/unit/spectrum/test_plot_spectrum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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'
Expand Down