Add axis control, point limits, and dimension params to plot_waveform

Expose x_min/x_max/y_min/y_max, max_points, width/height, and title
override through the MCP tool. Data is clipped to X range before
stride-based downsampling for better zoomed resolution. All params
default to None/current behavior for backward compatibility.
This commit is contained in:
Ryan Malloy 2026-02-14 18:04:40 -07:00
parent 59677a6a7d
commit 8f0b9ad46a
4 changed files with 186 additions and 17 deletions

View File

@ -1,6 +1,6 @@
[project]
name = "mcltspice"
version = "2026.02.10"
version = "2026.02.14"
description = "MCP server for LTspice circuit simulation automation"
readme = "README.md"
requires-python = ">=3.11"

View File

@ -1321,6 +1321,14 @@ async def plot_waveform(
signal: str = "V(out)",
plot_type: str = "auto",
output_path: str | None = None,
max_points: int = 10000,
x_min: float | None = None,
x_max: float | None = None,
y_min: float | None = None,
y_max: float | None = None,
width: int = 800,
height: int | None = None,
title: str | None = None,
) -> dict:
"""Generate an SVG plot from simulation results.
@ -1332,6 +1340,14 @@ async def plot_waveform(
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
plot_type: "auto" (detect from data), "time", "bode", or "spectrum"
output_path: Where to save SVG file (None = auto in /tmp)
max_points: Maximum data points to plot (stride-sampled if exceeded)
x_min: Left X-axis bound (seconds for time, Hz for frequency). None = auto
x_max: Right X-axis bound. None = auto
y_min: Lower Y-axis bound (volts for time, dB for bode/spectrum). None = auto
y_max: Upper Y-axis bound. None = auto
width: SVG width in pixels
height: SVG height in pixels (None = 500 for bode, 400 otherwise)
title: Plot title (None = auto-generated from signal name and plot type)
"""
raw_path = Path(raw_file)
if not raw_path.exists():
@ -1379,29 +1395,70 @@ async def plot_waveform(
else:
plot_type = "time"
# Clip to X range (before downsampling for efficiency)
real_x = np.real(x_data)
if x_min is not None or x_max is not None:
mask = np.ones(len(real_x), dtype=bool)
if x_min is not None:
mask &= real_x >= x_min
if x_max is not None:
mask &= real_x <= x_max
x_data = x_data[mask]
values = values[mask]
real_x = real_x[mask]
if mag_db is not None:
mag_db = mag_db[mask]
if phase_deg is not None:
phase_deg = phase_deg[mask]
# Downsample (stride-based, same pattern as get_waveform)
if max_points and len(x_data) > max_points:
step = max(1, len(x_data) // max_points)
x_data = x_data[::step]
values = values[::step]
real_x = real_x[::step]
if mag_db is not None:
mag_db = mag_db[::step]
if phase_deg is not None:
phase_deg = phase_deg[::step]
# Height default
if height is None:
height = 500 if plot_type == "bode" else 400
# Title default
if title is None:
if plot_type == "bode":
title = f"Bode Plot \u2014 {actual_name}"
elif plot_type == "spectrum":
title = f"Spectrum \u2014 {actual_name}"
else:
title = f"Time Domain \u2014 {actual_name}"
# Generate SVG
if plot_type == "bode":
if mag_db is None:
mag_db = np.real(values)
phase_deg = None
freq = np.real(x_data)
svg = plot_bode(
freq=freq, mag_db=mag_db, phase_deg=phase_deg,
title=f"Bode Plot — {actual_name}",
freq=real_x, mag_db=mag_db, phase_deg=phase_deg,
title=title, width=width, height=height,
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
)
elif plot_type == "spectrum":
freq = np.real(x_data)
if mag_db is None:
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
svg = plot_spectrum(
freq=freq, mag_db=mag_db,
title=f"Spectrum — {actual_name}",
freq=real_x, mag_db=mag_db,
title=title, width=width, height=height,
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
)
else: # time
svg = plot_timeseries(
time=np.real(x_data), values=np.real(values),
title=f"Time Domain — {actual_name}",
ylabel=actual_name,
time=real_x, values=np.real(values),
title=title, ylabel=actual_name,
width=width, height=height,
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
)
# Save SVG
@ -1416,6 +1473,7 @@ async def plot_waveform(
"plot_type": plot_type,
"signal": actual_name,
"points": len(x_data),
"dimensions": f"{width}x{height}",
}

View File

@ -334,6 +334,10 @@ def plot_timeseries(
ylabel: str = "Voltage (V)",
width: int = 800,
height: int = 400,
x_min: float | None = None,
x_max: float | None = None,
y_min: float | None = None,
y_max: float | None = None,
) -> str:
"""Plot a time-domain signal. Linear X axis (time), linear Y axis.
@ -350,8 +354,12 @@ def plot_timeseries(
plot_w = float(width - margin_l - margin_r)
plot_h = float(height - margin_t - margin_b)
x_min, x_max = _data_extent(t)
y_min, y_max = _data_extent(v)
auto_xmin, auto_xmax = _data_extent(t)
auto_ymin, auto_ymax = _data_extent(v)
x_min = x_min if x_min is not None else auto_xmin
x_max = x_max if x_max is not None else auto_xmax
y_min = y_min if y_min is not None else auto_ymin
y_max = y_max if y_max is not None else auto_ymax
inner = _render_subplot(
xs=t,
@ -380,12 +388,18 @@ def plot_bode(
title: str = "Bode Plot",
width: int = 800,
height: int = 500,
x_min: float | None = None,
x_max: float | None = None,
y_min: float | None = None,
y_max: float | None = None,
) -> str:
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
If *phase_deg* is provided, the SVG contains two stacked subplots:
magnitude on top, phase on the bottom.
*y_min*/*y_max* apply to the magnitude subplot only; phase auto-ranges.
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
@ -404,7 +418,11 @@ def plot_bode(
plot_w = float(width - margin_l - margin_r)
# Frequency range (shared)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
auto_fmin, auto_fmax = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if auto_fmin <= 0:
auto_fmin = 1.0
f_min = x_min if x_min is not None else auto_fmin
f_max = x_max if x_max is not None else auto_fmax
if f_min <= 0:
f_min = 1.0
freq_ticks = _log_ticks(f_min, f_max)
@ -418,7 +436,9 @@ def plot_bode(
mag_y = float(margin_t)
phase_y = float(margin_t + mag_h + gap)
m_min, m_max = _data_extent(m)
auto_mmin, auto_mmax = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
p_min, p_max = _data_extent(p)
mag_svg = _render_subplot(
@ -463,7 +483,9 @@ def plot_bode(
else:
plot_y = float(margin_t)
plot_h = float(height - margin_t - margin_b)
m_min, m_max = _data_extent(m)
auto_mmin, auto_mmax = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
inner = _render_subplot(
xs=f,
@ -492,6 +514,10 @@ def plot_spectrum(
title: str = "FFT Spectrum",
width: int = 800,
height: int = 400,
x_min: float | None = None,
x_max: float | None = None,
y_min: float | None = None,
y_max: float | None = None,
) -> str:
"""Plot an FFT spectrum. Log10 X axis, linear Y axis (dB).
@ -508,10 +534,16 @@ def plot_spectrum(
plot_w = float(width - margin_l - margin_r)
plot_h = float(height - margin_t - margin_b)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
auto_fmin, auto_fmax = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if auto_fmin <= 0:
auto_fmin = 1.0
auto_mmin, auto_mmax = _data_extent(m)
f_min = x_min if x_min is not None else auto_fmin
f_max = x_max if x_max is not None else auto_fmax
if f_min <= 0:
f_min = 1.0
m_min, m_max = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
inner = _render_subplot(
xs=f,

View File

@ -197,3 +197,82 @@ class TestSvgDimensions:
svg = plot_spectrum(freq, mag, width=1000, height=500)
assert 'width="1000"' in svg
assert 'height="500"' in svg
# ---------------------------------------------------------------------------
# Axis range overrides
# ---------------------------------------------------------------------------
class TestAxisRangeOverrides:
def test_timeseries_custom_axis_range(self, sine_wave):
"""Explicit x/y ranges produce valid SVG without crashing."""
t, v = sine_wave
svg = plot_timeseries(
t, v, x_min=0.002, x_max=0.005, y_min=-0.5, y_max=0.5,
)
assert svg.startswith("<svg")
assert "<path" in svg
assert "</svg>" in svg
def test_bode_custom_freq_range(self, bode_data):
"""Bode plot with explicit frequency range includes ticks in range."""
freq, mag_db, phase_deg = bode_data
svg = plot_bode(
freq, mag_db, phase_deg,
x_min=100.0, x_max=100_000.0,
y_min=-40.0, y_max=0.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
# Tick labels should include values within 100 Hz 100 kHz
found = sum(1 for lbl in ("1k", "10k") if lbl in svg)
assert found >= 1, f"Expected freq tick labels in range; found {found}"
def test_spectrum_custom_range(self, spectrum_data):
"""Spectrum with explicit ranges produces valid SVG."""
freq, mag_db = spectrum_data
svg = plot_spectrum(
freq, mag_db,
x_min=100.0, x_max=50_000.0,
y_min=-80.0, y_max=10.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
def test_partial_range_override(self, sine_wave):
"""Setting only y_min while leaving others auto should work."""
t, v = sine_wave
svg = plot_timeseries(t, v, y_min=-2.0)
assert svg.startswith("<svg")
assert "<path" in svg
def test_none_defaults_match_current(self, sine_wave):
"""Passing None for all range params produces identical output."""
t, v = sine_wave
svg_default = plot_timeseries(t, v)
svg_explicit_none = plot_timeseries(
t, v,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_bode_none_defaults_match_current(self, bode_data):
"""Bode with explicit None params matches default output."""
freq, mag_db, phase_deg = bode_data
svg_default = plot_bode(freq, mag_db, phase_deg)
svg_explicit_none = plot_bode(
freq, mag_db, phase_deg,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_spectrum_none_defaults_match_current(self, spectrum_data):
"""Spectrum with explicit None params matches default output."""
freq, mag_db = spectrum_data
svg_default = plot_spectrum(freq, mag_db)
svg_explicit_none = plot_spectrum(
freq, mag_db,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none