From b0db898ab48c5b908c0911908936d247af419c2b Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 14 Feb 2026 20:16:10 -0700 Subject: [PATCH] Fix plot_waveform data indexing bug, add multi-signal overlay and adaptive axis labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pyproject.toml | 2 +- src/mcltspice/server.py | 163 ++++++++++++++++++++++-------------- src/mcltspice/svg_plot.py | 172 +++++++++++++++++++++++++++++++++++++- tests/test_svg_plot.py | 97 +++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 372 insertions(+), 64 deletions(-) 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 ```` 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'' + ) + + # 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) diff --git a/tests/test_svg_plot.py b/tests/test_svg_plot.py index 750433c..ea47b28 100644 --- a/tests/test_svg_plot.py +++ b/tests/test_svg_plot.py @@ -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 elements.""" + t, traces = two_traces + svg = plot_timeseries_multi(t, traces) + assert svg.startswith("" in svg + assert svg.count("= 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("= 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("