Extract plot_waveform core into plotting.py

plot_waveform was the largest tool at 207 lines, interleaving signal
resolution, auto plot-type detection, X-range clipping, downsampling, and
svg_plot dispatch with the .raw read and SVG write. Move the RawFile -> SVG
transform into plotting.build_waveform_svg (returns the SVG string + metadata,
no filesystem); the tool keeps only the parse and write I/O.

server.py: 2799 -> 2677 lines. Output verified byte-identical (SVG sha256 +
metadata) to the prior version across bode/spectrum/time, x-range+downsample,
custom dimensions, and both error paths.
This commit is contained in:
Ryan Malloy 2026-06-20 21:06:02 -06:00
parent 181d1cb925
commit b634eb9cd1
2 changed files with 197 additions and 143 deletions

176
src/mcltspice/plotting.py Normal file
View File

@ -0,0 +1,176 @@
"""Waveform-plot construction, separated from file I/O.
build_waveform_svg() turns a parsed RawFile into an SVG string plus metadata.
It does no filesystem access, so it can be unit-tested with a hand-built
RawFile -- the plot_waveform MCP tool wraps it with the .raw read and SVG write.
"""
import numpy as np
from .raw_parser import RawFile
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
def build_waveform_svg(
raw_data: RawFile,
signal_list: list[str],
plot_type: str = "auto",
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:
"""Render one or more signals from a RawFile to an SVG string.
Returns {"svg", "plot_type", "signal", "points", "dimensions"} on success,
or {"error", ...} if a signal is missing or a multi-signal overlay is
requested for a non-time-domain plot. plot_type "auto" picks bode (complex
frequency data), spectrum (real frequency data), or time (everything else).
"""
is_multi = len(signal_list) > 1
# Resolve all signal names (exact, case-insensitive)
sig_names = [v.name for v in raw_data.variables]
sig_lower = {s.lower(): s for s in sig_names}
resolved: list[tuple[str, int]] = [] # (actual_name, index)
for s in signal_list:
idx = raw_data.resolve_index(s)
if idx is None:
return {
"error": f"Signal '{s}' not found",
"available_signals": sig_names,
}
resolved.append((sig_lower[s.lower()], idx))
# Get x-axis data (time or frequency)
x_var = raw_data.variables[0]
x_data = raw_data.data[0]
is_freq = x_var.name.lower() == "frequency"
# For single-signal: check complex data (AC analysis)
first_values = raw_data.data[resolved[0][1]]
is_complex = bool(np.iscomplexobj(first_values))
# Determine plot type
if plot_type == "auto":
if is_freq and is_complex:
plot_type = "bode"
elif is_freq:
plot_type = "spectrum"
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:
mask &= real_x >= x_min
if x_max is not None:
mask &= real_x <= x_max
x_data = x_data[mask]
real_x = real_x[mask]
# Downsample (stride-based)
step = None
if max_points and len(x_data) > max_points:
step = max(1, len(x_data) // max_points)
x_data = x_data[::step]
real_x = real_x[::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 — {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
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":
title = f"Bode Plot — {actual_name}"
elif plot_type == "spectrum":
title = f"Spectrum — {actual_name}"
else:
title = f"Time Domain — {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,
)
return {
"svg": svg,
"plot_type": plot_type,
"signal": actual_name,
"points": len(real_x),
"dimensions": f"{width}x{height}",
}

View File

@ -49,6 +49,7 @@ from .optimizer import (
format_engineering,
optimize_component_values,
)
from .plotting import build_waveform_svg
from .power_analysis import compute_efficiency, compute_power_metrics
from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation
@ -59,7 +60,6 @@ from .spicebook import (
ltspice_to_ngspice,
)
from .stability import compute_stability_metrics
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
from .templates import (
ASC_TEMPLATES,
NETLIST_TEMPLATES,
@ -1296,158 +1296,36 @@ async def plot_waveform(
# 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 (exact, case-insensitive)
sig_names = [v.name for v in raw_data.variables]
sig_lower = {s.lower(): s for s in sig_names}
resolved: list[tuple[str, int]] = [] # (actual_name, index)
for s in signal_list:
idx = raw_data.resolve_index(s)
if idx is None:
return {
"error": f"Signal '{s}' not found",
"available_signals": sig_names,
}
resolved.append((sig_lower[s.lower()], idx))
# Get x-axis data (time or frequency)
x_var = raw_data.variables[0]
x_data = raw_data.data[0]
is_freq = x_var.name.lower() == "frequency"
# 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
# Determine plot type
if plot_type == "auto":
if is_freq and is_complex:
plot_type = "bode"
elif is_freq:
plot_type = "spectrum"
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:
mask &= real_x >= x_min
if x_max is not None:
mask &= real_x <= x_max
x_data = x_data[mask]
real_x = real_x[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]
real_x = real_x[::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":
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,
)
result = build_waveform_svg(
raw_data,
signal_list,
plot_type=plot_type,
max_points=max_points,
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
width=width,
height=height,
title=title,
)
if "error" in result:
return result
# Save SVG
if output_path is None:
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
else:
out = Path(output_path)
out.write_text(svg)
out.write_text(result["svg"])
return {
"svg_path": str(out),
"plot_type": plot_type,
"signal": actual_name,
"points": len(real_x),
"dimensions": f"{width}x{height}",
"plot_type": result["plot_type"],
"signal": result["signal"],
"points": result["points"],
"dimensions": result["dimensions"],
}