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:
parent
59677a6a7d
commit
8f0b9ad46a
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcltspice"
|
name = "mcltspice"
|
||||||
version = "2026.02.10"
|
version = "2026.02.14"
|
||||||
description = "MCP server for LTspice circuit simulation automation"
|
description = "MCP server for LTspice circuit simulation automation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@ -1321,6 +1321,14 @@ async def plot_waveform(
|
|||||||
signal: str = "V(out)",
|
signal: str = "V(out)",
|
||||||
plot_type: str = "auto",
|
plot_type: str = "auto",
|
||||||
output_path: str | None = None,
|
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:
|
) -> dict:
|
||||||
"""Generate an SVG plot from simulation results.
|
"""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)")
|
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
|
||||||
plot_type: "auto" (detect from data), "time", "bode", or "spectrum"
|
plot_type: "auto" (detect from data), "time", "bode", or "spectrum"
|
||||||
output_path: Where to save SVG file (None = auto in /tmp)
|
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)
|
raw_path = Path(raw_file)
|
||||||
if not raw_path.exists():
|
if not raw_path.exists():
|
||||||
@ -1379,29 +1395,70 @@ async def plot_waveform(
|
|||||||
else:
|
else:
|
||||||
plot_type = "time"
|
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
|
# Generate SVG
|
||||||
if plot_type == "bode":
|
if plot_type == "bode":
|
||||||
if mag_db is None:
|
if mag_db is None:
|
||||||
mag_db = np.real(values)
|
mag_db = np.real(values)
|
||||||
phase_deg = None
|
phase_deg = None
|
||||||
freq = np.real(x_data)
|
|
||||||
svg = plot_bode(
|
svg = plot_bode(
|
||||||
freq=freq, mag_db=mag_db, phase_deg=phase_deg,
|
freq=real_x, mag_db=mag_db, phase_deg=phase_deg,
|
||||||
title=f"Bode Plot — {actual_name}",
|
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":
|
elif plot_type == "spectrum":
|
||||||
freq = np.real(x_data)
|
|
||||||
if mag_db is None:
|
if mag_db is None:
|
||||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
|
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
|
||||||
svg = plot_spectrum(
|
svg = plot_spectrum(
|
||||||
freq=freq, mag_db=mag_db,
|
freq=real_x, mag_db=mag_db,
|
||||||
title=f"Spectrum — {actual_name}",
|
title=title, width=width, height=height,
|
||||||
|
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||||
)
|
)
|
||||||
else: # time
|
else: # time
|
||||||
svg = plot_timeseries(
|
svg = plot_timeseries(
|
||||||
time=np.real(x_data), values=np.real(values),
|
time=real_x, values=np.real(values),
|
||||||
title=f"Time Domain — {actual_name}",
|
title=title, ylabel=actual_name,
|
||||||
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
|
# Save SVG
|
||||||
@ -1416,6 +1473,7 @@ async def plot_waveform(
|
|||||||
"plot_type": plot_type,
|
"plot_type": plot_type,
|
||||||
"signal": actual_name,
|
"signal": actual_name,
|
||||||
"points": len(x_data),
|
"points": len(x_data),
|
||||||
|
"dimensions": f"{width}x{height}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -334,6 +334,10 @@ def plot_timeseries(
|
|||||||
ylabel: str = "Voltage (V)",
|
ylabel: str = "Voltage (V)",
|
||||||
width: int = 800,
|
width: int = 800,
|
||||||
height: int = 400,
|
height: int = 400,
|
||||||
|
x_min: float | None = None,
|
||||||
|
x_max: float | None = None,
|
||||||
|
y_min: float | None = None,
|
||||||
|
y_max: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Plot a time-domain signal. Linear X axis (time), linear Y axis.
|
"""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_w = float(width - margin_l - margin_r)
|
||||||
plot_h = float(height - margin_t - margin_b)
|
plot_h = float(height - margin_t - margin_b)
|
||||||
|
|
||||||
x_min, x_max = _data_extent(t)
|
auto_xmin, auto_xmax = _data_extent(t)
|
||||||
y_min, y_max = _data_extent(v)
|
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(
|
inner = _render_subplot(
|
||||||
xs=t,
|
xs=t,
|
||||||
@ -380,12 +388,18 @@ def plot_bode(
|
|||||||
title: str = "Bode Plot",
|
title: str = "Bode Plot",
|
||||||
width: int = 800,
|
width: int = 800,
|
||||||
height: int = 500,
|
height: int = 500,
|
||||||
|
x_min: float | None = None,
|
||||||
|
x_max: float | None = None,
|
||||||
|
y_min: float | None = None,
|
||||||
|
y_max: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
|
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
|
||||||
|
|
||||||
If *phase_deg* is provided, the SVG contains two stacked subplots:
|
If *phase_deg* is provided, the SVG contains two stacked subplots:
|
||||||
magnitude on top, phase on the bottom.
|
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.
|
Returns a complete ``<svg>`` XML string.
|
||||||
"""
|
"""
|
||||||
f = np.asarray(freq, dtype=float).ravel()
|
f = np.asarray(freq, dtype=float).ravel()
|
||||||
@ -404,7 +418,11 @@ def plot_bode(
|
|||||||
plot_w = float(width - margin_l - margin_r)
|
plot_w = float(width - margin_l - margin_r)
|
||||||
|
|
||||||
# Frequency range (shared)
|
# 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:
|
if f_min <= 0:
|
||||||
f_min = 1.0
|
f_min = 1.0
|
||||||
freq_ticks = _log_ticks(f_min, f_max)
|
freq_ticks = _log_ticks(f_min, f_max)
|
||||||
@ -418,7 +436,9 @@ def plot_bode(
|
|||||||
mag_y = float(margin_t)
|
mag_y = float(margin_t)
|
||||||
phase_y = float(margin_t + mag_h + gap)
|
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)
|
p_min, p_max = _data_extent(p)
|
||||||
|
|
||||||
mag_svg = _render_subplot(
|
mag_svg = _render_subplot(
|
||||||
@ -463,7 +483,9 @@ def plot_bode(
|
|||||||
else:
|
else:
|
||||||
plot_y = float(margin_t)
|
plot_y = float(margin_t)
|
||||||
plot_h = float(height - margin_t - margin_b)
|
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(
|
inner = _render_subplot(
|
||||||
xs=f,
|
xs=f,
|
||||||
@ -492,6 +514,10 @@ def plot_spectrum(
|
|||||||
title: str = "FFT Spectrum",
|
title: str = "FFT Spectrum",
|
||||||
width: int = 800,
|
width: int = 800,
|
||||||
height: int = 400,
|
height: int = 400,
|
||||||
|
x_min: float | None = None,
|
||||||
|
x_max: float | None = None,
|
||||||
|
y_min: float | None = None,
|
||||||
|
y_max: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Plot an FFT spectrum. Log10 X axis, linear Y axis (dB).
|
"""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_w = float(width - margin_l - margin_r)
|
||||||
plot_h = float(height - margin_t - margin_b)
|
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:
|
if f_min <= 0:
|
||||||
f_min = 1.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(
|
inner = _render_subplot(
|
||||||
xs=f,
|
xs=f,
|
||||||
|
|||||||
@ -197,3 +197,82 @@ class TestSvgDimensions:
|
|||||||
svg = plot_spectrum(freq, mag, width=1000, height=500)
|
svg = plot_spectrum(freq, mag, width=1000, height=500)
|
||||||
assert 'width="1000"' in svg
|
assert 'width="1000"' in svg
|
||||||
assert 'height="500"' 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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user