diff --git a/pyproject.toml b/pyproject.toml
index b33b9d7..e5c3ad1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py
index 1ad966e..0aa8c26 100644
--- a/src/mcltspice/server.py
+++ b/src/mcltspice/server.py
@@ -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:
- return {
- "error": f"Signal '{signal}' 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]
+ 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 '{s}' not found",
+ "available_signals": sig_names,
+ }
+ 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,62 +1417,90 @@ 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
- # 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}"
+ 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)))
- # Generate SVG
- if plot_type == "bode":
- if mag_db is None:
- mag_db = np.real(values)
+ 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
- svg = plot_bode(
- 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":
- if mag_db is None:
- mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
- svg = plot_spectrum(
- 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=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,
- )
+
+ # 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
+ svg = plot_bode(
+ 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":
+ if mag_db is None:
+ mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
+ svg = plot_spectrum(
+ 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=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
if output_path is None:
@@ -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}",
}
diff --git a/src/mcltspice/svg_plot.py b/src/mcltspice/svg_plot.py
index 7d9fdd1..f59a6de 100644
--- a/src/mcltspice/svg_plot.py
+++ b/src/mcltspice/svg_plot.py
@@ -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''
+ )
+ 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''
+ )
+ # Label
+ lines.append(
+ f'{_svg_escape(name)}'
+ )
+ 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 ``