Fix plot_waveform data indexing bug, add multi-signal overlay and adaptive axis labels

Row-major data from raw_parser was indexed as column-major, producing
garbled plots for digital waveforms. Also adds signals parameter for
overlaying multiple time-domain traces with legend, and adaptive X-axis
labels (ns/µs/ms/s) based on time span.
This commit is contained in:
Ryan Malloy 2026-02-14 20:16:10 -07:00
parent 8f0b9ad46a
commit b0db898ab4
5 changed files with 372 additions and 64 deletions

View File

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

View File

@ -114,7 +114,7 @@ from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation
from .schematic import modify_component_value, parse_schematic
from .stability import compute_stability_metrics
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
from .touchstone import parse_touchstone, s_param_to_db
from .waveform_expr import WaveformCalculator
from .waveform_math import (
@ -1319,6 +1319,7 @@ async def tune_circuit(
async def plot_waveform(
raw_file: str,
signal: str = "V(out)",
signals: list[str] | None = None,
plot_type: str = "auto",
output_path: str | None = None,
max_points: int = 10000,
@ -1338,6 +1339,7 @@ async def plot_waveform(
Args:
raw_file: Path to the LTspice .raw binary file
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
signals: Multiple signal names to overlay (e.g. ["V(tx)", "V(rx)"]). Overrides signal when set. Time-domain only.
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)
@ -1358,33 +1360,35 @@ async def plot_waveform(
except Exception as e:
return {"error": f"Failed to parse raw file: {e}"}
# Find the requested signal
# Build signal list (signals overrides signal when set)
signal_list = signals if signals else [signal]
is_multi = len(signal_list) > 1
# Resolve all signal names (case-insensitive)
sig_names = [v.name for v in raw_data.variables]
sig_lower = {s.lower(): s for s in sig_names}
actual_name = sig_lower.get(signal.lower())
if actual_name is None:
resolved: list[tuple[str, int]] = [] # (actual_name, index)
for s in signal_list:
actual = sig_lower.get(s.lower())
if actual is None:
return {
"error": f"Signal '{signal}' not found",
"error": f"Signal '{s}' not found",
"available_signals": sig_names,
}
sig_idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual_name)
values = raw_data.data[:, sig_idx]
idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual)
resolved.append((actual, idx))
# Get x-axis data (time or frequency)
x_var = raw_data.variables[0]
x_data = raw_data.data[:, 0]
x_data = raw_data.data[0]
is_freq = x_var.name.lower() == "frequency"
# Handle complex data (AC analysis produces complex values)
if np.iscomplexobj(values):
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
phase_deg = np.degrees(np.angle(values))
# For single-signal: check complex data (AC analysis)
first_values = raw_data.data[resolved[0][1]]
if np.iscomplexobj(first_values):
is_complex = True
else:
is_complex = False
mag_db = None
phase_deg = None
# Determine plot type
if plot_type == "auto":
@ -1395,8 +1399,17 @@ async def plot_waveform(
else:
plot_type = "time"
# Multi-signal only supported for time-domain
if is_multi and plot_type != "time":
return {
"error": "Multi-signal overlay is only supported for time-domain plots.",
"plot_type": plot_type,
"hint": "Use single signal for bode/spectrum, or set plot_type='time'.",
}
# Clip to X range (before downsampling for efficiency)
real_x = np.real(x_data)
mask = None
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:
@ -1404,28 +1417,56 @@ async def plot_waveform(
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)
step = None
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
if is_multi:
# Multi-signal time-domain overlay
traces: list[tuple[str, np.ndarray]] = []
for name, idx in resolved:
v = raw_data.data[idx]
if mask is not None:
v = v[mask]
if step is not None:
v = v[::step]
traces.append((name, np.real(v)))
if title is None:
names_str = ", ".join(n for n, _ in resolved)
title = f"Time Domain \u2014 {names_str}"
svg = plot_timeseries_multi(
time=real_x, traces=traces,
title=title, width=width, height=height,
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
)
actual_name = ", ".join(n for n, _ in resolved)
else:
# Single-signal path (original logic)
actual_name, sig_idx = resolved[0]
values = raw_data.data[sig_idx]
if mask is not None:
values = values[mask]
if step is not None:
values = values[::step]
if np.iscomplexobj(values):
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
phase_deg = np.degrees(np.angle(values))
else:
mag_db = None
phase_deg = None
# Title default
if title is None:
if plot_type == "bode":
@ -1472,7 +1513,7 @@ async def plot_waveform(
"svg_path": str(out),
"plot_type": plot_type,
"signal": actual_name,
"points": len(x_data),
"points": len(real_x),
"dimensions": f"{width}x{height}",
}

View File

@ -145,6 +145,64 @@ def _data_extent(arr: np.ndarray, pad_frac: float = 0.05) -> tuple[float, float]
_FONT = "system-ui, -apple-system, sans-serif"
_OVERLAY_COLORS = [
"#2563eb", # blue (matches existing single-trace)
"#dc2626", # red
"#16a34a", # green
"#d97706", # amber
"#0891b2", # cyan
"#db2777", # pink
"#9333ea", # violet
"#4b5563", # gray
]
def _render_legend(
names: list[str],
colors: list[str],
plot_x: float,
plot_y: float,
plot_w: float,
) -> str:
"""Render a legend box positioned top-right of the plot area."""
if not names:
return ""
line_h = 18
swatch_w = 20
text_offset = swatch_w + 6
padding = 8
char_w = 6.6 # approximate character width at 11px
max_label_w = max(len(n) for n in names) * char_w
box_w = text_offset + max_label_w + padding * 2
box_h = len(names) * line_h + padding * 2
box_x = plot_x + plot_w - box_w - 10
box_y = plot_y + 10
lines: list[str] = []
# Background
lines.append(
f'<rect x="{box_x:.1f}" y="{box_y:.1f}" width="{box_w:.1f}" '
f'height="{box_h:.1f}" rx="3" fill="white" fill-opacity="0.9" '
f'stroke="#ccc" stroke-width="1"/>'
)
for i, (name, color) in enumerate(zip(names, colors)):
y = box_y + padding + i * line_h + line_h / 2
lx = box_x + padding
# Color swatch line
lines.append(
f'<line x1="{lx:.1f}" y1="{y:.1f}" '
f'x2="{lx + swatch_w:.1f}" y2="{y:.1f}" '
f'stroke="{color}" stroke-width="2.5"/>'
)
# Label
lines.append(
f'<text x="{lx + text_offset:.1f}" y="{y:.1f}" '
f'dominant-baseline="middle" font-size="11" '
f'font-family="{_FONT}" fill="#333">{_svg_escape(name)}</text>'
)
return "\n".join(lines)
def _build_path_d(
xs: np.ndarray,
@ -361,6 +419,17 @@ def plot_timeseries(
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
# Adaptive xlabel based on time span
t_span = x_max - x_min
if t_span < 1e-6:
xlabel = "Time (ns)"
elif t_span < 1e-3:
xlabel = "Time (\u00b5s)"
elif t_span < 1:
xlabel = "Time (ms)"
else:
xlabel = "Time (s)"
inner = _render_subplot(
xs=t,
ys=v,
@ -373,7 +442,7 @@ def plot_timeseries(
plot_w=plot_w,
plot_h=plot_h,
log_x=False,
xlabel="Time (s)",
xlabel=xlabel,
ylabel=ylabel,
title=title,
stroke="#2563eb",
@ -563,3 +632,104 @@ def plot_spectrum(
stroke="#2563eb",
)
return _wrap_svg(inner, width, height)
def plot_timeseries_multi(
time: list[float] | np.ndarray,
traces: list[tuple[str, list[float] | np.ndarray]],
title: str = "Time Domain",
ylabel: str = "",
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 multiple time-domain signals overlaid on shared axes.
*traces* is a list of ``(name, values)`` tuples. All traces share the
same *time* array. Returns a complete ``<svg>`` XML string.
"""
t = np.asarray(time, dtype=float).ravel()
# Convert all traces and compute unified Y extent
prepared: list[tuple[str, np.ndarray]] = []
global_ymin = float("inf")
global_ymax = float("-inf")
for name, vals in traces:
v = np.asarray(vals, dtype=float).ravel()
n = min(len(t), len(v))
v = v[:n]
lo, hi = _data_extent(v)
if lo < global_ymin:
global_ymin = lo
if hi > global_ymax:
global_ymax = hi
prepared.append((name, v))
if prepared:
min_len = min(len(t), min(len(v) for _, v in prepared))
t = t[:min_len]
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
plot_x = float(margin_l)
plot_y = float(margin_t)
plot_w = float(width - margin_l - margin_r)
plot_h = float(height - margin_t - margin_b)
auto_xmin, auto_xmax = _data_extent(t)
xlo = x_min if x_min is not None else auto_xmin
xhi = x_max if x_max is not None else auto_xmax
ylo = y_min if y_min is not None else global_ymin
yhi = y_max if y_max is not None else global_ymax
# Adaptive xlabel
t_span = xhi - xlo
if t_span < 1e-6:
xlabel = "Time (ns)"
elif t_span < 1e-3:
xlabel = "Time (\u00b5s)"
elif t_span < 1:
xlabel = "Time (ms)"
else:
xlabel = "Time (s)"
# Use first trace to render grid, axes, title via _render_subplot
first_name, first_vals = prepared[0] if prepared else ("", np.array([]))
color0 = _OVERLAY_COLORS[0]
inner = _render_subplot(
xs=t,
ys=first_vals,
x_min=xlo,
x_max=xhi,
y_min=ylo,
y_max=yhi,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=False,
xlabel=xlabel,
ylabel=ylabel,
title=title,
stroke=color0,
)
# Additional traces
extra_paths: list[str] = []
for i, (name, v) in enumerate(prepared[1:], start=1):
color = _OVERLAY_COLORS[i % len(_OVERLAY_COLORS)]
d = _build_path_d(t, v, xlo, xhi, ylo, yhi, plot_x, plot_y, plot_w, plot_h)
if d:
extra_paths.append(
f'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.5" '
f'stroke-linejoin="round" stroke-linecap="round"/>'
)
# Legend
names = [name for name, _ in prepared]
colors = [_OVERLAY_COLORS[i % len(_OVERLAY_COLORS)] for i in range(len(prepared))]
legend = _render_legend(names, colors, plot_x, plot_y, plot_w)
all_inner = "\n".join([inner] + extra_paths + [legend])
return _wrap_svg(all_inner, width, height)

View File

@ -9,6 +9,7 @@ from mcltspice.svg_plot import (
plot_bode,
plot_spectrum,
plot_timeseries,
plot_timeseries_multi,
)
# ---------------------------------------------------------------------------
@ -276,3 +277,99 @@ class TestAxisRangeOverrides:
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
# ---------------------------------------------------------------------------
# Adaptive X-axis label
# ---------------------------------------------------------------------------
class TestAdaptiveXLabel:
def test_timeseries_adaptive_xlabel_ms(self):
"""A 2 ms span should produce 'Time (ms)' label."""
t = np.linspace(0, 0.002, 200)
v = np.sin(2 * np.pi * 1000 * t)
svg = plot_timeseries(t, v)
assert "Time (ms)" in svg
def test_timeseries_adaptive_xlabel_us(self):
"""A 50 µs span should produce 'Time (µs)' label."""
t = np.linspace(0, 50e-6, 200)
v = np.sin(2 * np.pi * 50e3 * t)
svg = plot_timeseries(t, v)
assert "Time (\u00b5s)" in svg or "Time (µs)" in svg
def test_timeseries_adaptive_xlabel_s(self):
"""A 2 s span should produce 'Time (s)' label."""
t = np.linspace(0, 2, 200)
v = np.sin(2 * np.pi * t)
svg = plot_timeseries(t, v)
assert "Time (s)" in svg
def test_timeseries_adaptive_xlabel_ns(self):
"""A 500 ns span should produce 'Time (ns)' label."""
t = np.linspace(0, 500e-9, 200)
v = np.sin(2 * np.pi * 1e6 * t)
svg = plot_timeseries(t, v)
assert "Time (ns)" in svg
# ---------------------------------------------------------------------------
# Multi-signal overlay
# ---------------------------------------------------------------------------
class TestPlotTimeseriesMulti:
@pytest.fixture()
def two_traces(self):
"""Two signals with different amplitudes over 10 ms."""
t = np.linspace(0, 0.01, 500)
v1 = np.sin(2 * np.pi * 1000 * t)
v2 = 0.5 * np.cos(2 * np.pi * 1000 * t) + 2.0
return t, [("V(sig1)", v1), ("V(sig2)", v2)]
def test_timeseries_multi_basic(self, two_traces):
"""Two traces produce valid SVG with at least 2 <path> elements."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert svg.startswith("<svg")
assert "</svg>" in svg
assert svg.count("<path") >= 2
def test_timeseries_multi_legend(self, two_traces):
"""Legend should contain both trace names."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert "V(sig1)" in svg
assert "V(sig2)" in svg
def test_timeseries_multi_unified_yrange(self, two_traces):
"""Y axis should cover both traces — sig2 peaks at ~2.5, sig1 at ~1.0."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
# The SVG should not clip — both traces' paths should be present
path_count = svg.count("<path")
assert path_count >= 2
def test_timeseries_multi_colors(self, two_traces):
"""Paths should use distinct stroke colors."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
# First trace is blue (#2563eb), second is red (#dc2626)
assert "#2563eb" in svg
assert "#dc2626" in svg
def test_timeseries_multi_single_trace(self):
"""Single trace through multi function produces valid SVG with one path."""
t = np.linspace(0, 0.001, 100)
v = np.sin(2 * np.pi * 5000 * t)
svg = plot_timeseries_multi(t, [("V(out)", v)])
assert svg.startswith("<svg")
assert "<path" in svg
assert "V(out)" in svg
def test_timeseries_multi_adaptive_xlabel(self, two_traces):
"""Multi plot should also get adaptive xlabel (10 ms → 'Time (ms)')."""
t, traces = two_traces
svg = plot_timeseries_multi(t, traces)
assert "Time (ms)" in svg

2
uv.lock generated
View File

@ -1028,7 +1028,7 @@ wheels = [
[[package]]
name = "mcltspice"
version = "2026.2.10"
version = "2026.2.14"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },