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:
parent
8f0b9ad46a
commit
b0db898ab4
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcltspice"
|
name = "mcltspice"
|
||||||
version = "2026.02.14"
|
version = "2026.02.14.1"
|
||||||
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"
|
||||||
|
|||||||
@ -114,7 +114,7 @@ from .raw_parser import parse_raw_file
|
|||||||
from .runner import run_netlist, run_simulation
|
from .runner import run_netlist, run_simulation
|
||||||
from .schematic import modify_component_value, parse_schematic
|
from .schematic import modify_component_value, parse_schematic
|
||||||
from .stability import compute_stability_metrics
|
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 .touchstone import parse_touchstone, s_param_to_db
|
||||||
from .waveform_expr import WaveformCalculator
|
from .waveform_expr import WaveformCalculator
|
||||||
from .waveform_math import (
|
from .waveform_math import (
|
||||||
@ -1319,6 +1319,7 @@ async def tune_circuit(
|
|||||||
async def plot_waveform(
|
async def plot_waveform(
|
||||||
raw_file: str,
|
raw_file: str,
|
||||||
signal: str = "V(out)",
|
signal: str = "V(out)",
|
||||||
|
signals: list[str] | None = None,
|
||||||
plot_type: str = "auto",
|
plot_type: str = "auto",
|
||||||
output_path: str | None = None,
|
output_path: str | None = None,
|
||||||
max_points: int = 10000,
|
max_points: int = 10000,
|
||||||
@ -1338,6 +1339,7 @@ async def plot_waveform(
|
|||||||
Args:
|
Args:
|
||||||
raw_file: Path to the LTspice .raw binary file
|
raw_file: Path to the LTspice .raw binary file
|
||||||
signal: Signal name to plot (e.g. "V(out)", "I(R1)")
|
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"
|
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)
|
max_points: Maximum data points to plot (stride-sampled if exceeded)
|
||||||
@ -1358,33 +1360,35 @@ async def plot_waveform(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Failed to parse raw file: {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_names = [v.name for v in raw_data.variables]
|
||||||
sig_lower = {s.lower(): s for s in sig_names}
|
sig_lower = {s.lower(): s for s in sig_names}
|
||||||
actual_name = sig_lower.get(signal.lower())
|
resolved: list[tuple[str, int]] = [] # (actual_name, index)
|
||||||
if actual_name is None:
|
for s in signal_list:
|
||||||
|
actual = sig_lower.get(s.lower())
|
||||||
|
if actual is None:
|
||||||
return {
|
return {
|
||||||
"error": f"Signal '{signal}' not found",
|
"error": f"Signal '{s}' not found",
|
||||||
"available_signals": sig_names,
|
"available_signals": sig_names,
|
||||||
}
|
}
|
||||||
|
idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual)
|
||||||
sig_idx = next(i for i, v in enumerate(raw_data.variables) if v.name == actual_name)
|
resolved.append((actual, idx))
|
||||||
values = raw_data.data[:, sig_idx]
|
|
||||||
|
|
||||||
# Get x-axis data (time or frequency)
|
# Get x-axis data (time or frequency)
|
||||||
x_var = raw_data.variables[0]
|
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"
|
is_freq = x_var.name.lower() == "frequency"
|
||||||
|
|
||||||
# Handle complex data (AC analysis produces complex values)
|
# For single-signal: check complex data (AC analysis)
|
||||||
if np.iscomplexobj(values):
|
first_values = raw_data.data[resolved[0][1]]
|
||||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
|
if np.iscomplexobj(first_values):
|
||||||
phase_deg = np.degrees(np.angle(values))
|
|
||||||
is_complex = True
|
is_complex = True
|
||||||
else:
|
else:
|
||||||
is_complex = False
|
is_complex = False
|
||||||
mag_db = None
|
|
||||||
phase_deg = None
|
|
||||||
|
|
||||||
# Determine plot type
|
# Determine plot type
|
||||||
if plot_type == "auto":
|
if plot_type == "auto":
|
||||||
@ -1395,8 +1399,17 @@ async def plot_waveform(
|
|||||||
else:
|
else:
|
||||||
plot_type = "time"
|
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)
|
# Clip to X range (before downsampling for efficiency)
|
||||||
real_x = np.real(x_data)
|
real_x = np.real(x_data)
|
||||||
|
mask = None
|
||||||
if x_min is not None or x_max is not None:
|
if x_min is not None or x_max is not None:
|
||||||
mask = np.ones(len(real_x), dtype=bool)
|
mask = np.ones(len(real_x), dtype=bool)
|
||||||
if x_min is not None:
|
if x_min is not None:
|
||||||
@ -1404,28 +1417,56 @@ async def plot_waveform(
|
|||||||
if x_max is not None:
|
if x_max is not None:
|
||||||
mask &= real_x <= x_max
|
mask &= real_x <= x_max
|
||||||
x_data = x_data[mask]
|
x_data = x_data[mask]
|
||||||
values = values[mask]
|
|
||||||
real_x = real_x[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)
|
# Downsample (stride-based, same pattern as get_waveform)
|
||||||
|
step = None
|
||||||
if max_points and len(x_data) > max_points:
|
if max_points and len(x_data) > max_points:
|
||||||
step = max(1, len(x_data) // max_points)
|
step = max(1, len(x_data) // max_points)
|
||||||
x_data = x_data[::step]
|
x_data = x_data[::step]
|
||||||
values = values[::step]
|
|
||||||
real_x = real_x[::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
|
# Height default
|
||||||
if height is None:
|
if height is None:
|
||||||
height = 500 if plot_type == "bode" else 400
|
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
|
# Title default
|
||||||
if title is None:
|
if title is None:
|
||||||
if plot_type == "bode":
|
if plot_type == "bode":
|
||||||
@ -1472,7 +1513,7 @@ async def plot_waveform(
|
|||||||
"svg_path": str(out),
|
"svg_path": str(out),
|
||||||
"plot_type": plot_type,
|
"plot_type": plot_type,
|
||||||
"signal": actual_name,
|
"signal": actual_name,
|
||||||
"points": len(x_data),
|
"points": len(real_x),
|
||||||
"dimensions": f"{width}x{height}",
|
"dimensions": f"{width}x{height}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
_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(
|
def _build_path_d(
|
||||||
xs: np.ndarray,
|
xs: np.ndarray,
|
||||||
@ -361,6 +419,17 @@ def plot_timeseries(
|
|||||||
y_min = y_min if y_min is not None else auto_ymin
|
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
|
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(
|
inner = _render_subplot(
|
||||||
xs=t,
|
xs=t,
|
||||||
ys=v,
|
ys=v,
|
||||||
@ -373,7 +442,7 @@ def plot_timeseries(
|
|||||||
plot_w=plot_w,
|
plot_w=plot_w,
|
||||||
plot_h=plot_h,
|
plot_h=plot_h,
|
||||||
log_x=False,
|
log_x=False,
|
||||||
xlabel="Time (s)",
|
xlabel=xlabel,
|
||||||
ylabel=ylabel,
|
ylabel=ylabel,
|
||||||
title=title,
|
title=title,
|
||||||
stroke="#2563eb",
|
stroke="#2563eb",
|
||||||
@ -563,3 +632,104 @@ def plot_spectrum(
|
|||||||
stroke="#2563eb",
|
stroke="#2563eb",
|
||||||
)
|
)
|
||||||
return _wrap_svg(inner, width, height)
|
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)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from mcltspice.svg_plot import (
|
|||||||
plot_bode,
|
plot_bode,
|
||||||
plot_spectrum,
|
plot_spectrum,
|
||||||
plot_timeseries,
|
plot_timeseries,
|
||||||
|
plot_timeseries_multi,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -276,3 +277,99 @@ class TestAxisRangeOverrides:
|
|||||||
x_min=None, x_max=None, y_min=None, y_max=None,
|
x_min=None, x_max=None, y_min=None, y_max=None,
|
||||||
)
|
)
|
||||||
assert svg_default == svg_explicit_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
2
uv.lock
generated
@ -1028,7 +1028,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcltspice"
|
name = "mcltspice"
|
||||||
version = "2026.2.10"
|
version = "2026.2.14"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user