mcltspice/src/mcp_ltspice/svg_plot.py
Ryan Malloy 9b418a06c5 Add SVG plotting, circuit tuning, 5 new templates, fix prompts
- SVG waveform plots (svg_plot.py): pure-SVG timeseries, Bode, spectrum
  generation with plot_waveform MCP tool — no matplotlib dependency
- Circuit tuning tool (tune_circuit): single-shot simulate → measure →
  compare targets → suggest adjustments workflow for iterative design
- 5 new circuit templates: Sallen-Key lowpass, boost converter,
  instrumentation amplifier, current mirror, transimpedance amplifier
  (both netlist and .asc schematic generators, 15 total templates)
- Fix all 6 prompts to return list[Message] per FastMCP 2.x spec
- Add ltspice://templates and ltspice://template/{name} resources
- Add troubleshoot_simulation prompt
- Integration tests for RC lowpass and non-inverting amp (2/4 pass;
  CE amp and Colpitts oscillator have pre-existing schematic bugs)
- 360 unit tests passing, ruff clean
2026-02-11 05:13:50 -07:00

534 lines
15 KiB
Python

"""Pure SVG waveform plot generation -- no matplotlib dependency.
Generates complete <svg> XML strings for time-domain, Bode, and spectrum plots
suitable for embedding in HTML or saving as standalone .svg files.
"""
from __future__ import annotations
import math
from html import escape as _html_escape
import numpy as np
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ENG_PREFIXES = [
(1e18, "E"),
(1e15, "P"),
(1e12, "T"),
(1e9, "G"),
(1e6, "M"),
(1e3, "k"),
(1e0, ""),
(1e-3, "m"),
(1e-6, "\u00b5"), # micro sign
(1e-9, "n"),
(1e-12, "p"),
(1e-15, "f"),
(1e-18, "a"),
]
_FREQ_PREFIXES = [
(1e9, "G"),
(1e6, "M"),
(1e3, "k"),
(1e0, ""),
(1e-3, "m"),
]
def _svg_escape(text: str) -> str:
"""Escape special characters for embedding inside SVG XML."""
return _html_escape(str(text), quote=True)
def _format_eng(value: float, unit: str = "") -> str:
"""Format *value* with an engineering prefix and optional *unit*."""
if value == 0:
return f"0{unit}"
abs_val = abs(value)
for threshold, prefix in _ENG_PREFIXES:
if abs_val >= threshold * 0.9999:
scaled = value / threshold
# Trim trailing zeros but keep at least one digit
txt = f"{scaled:.3g}"
return f"{txt}{prefix}{unit}"
# Extremely small -- fall back to scientific
return f"{value:.2e}{unit}"
def _format_freq(hz: float) -> str:
"""Format a frequency value for axis labels (1, 10, 1k, 1M, etc.)."""
if hz == 0:
return "0"
abs_hz = abs(hz)
for threshold, prefix in _FREQ_PREFIXES:
if abs_hz >= threshold * 0.9999:
scaled = hz / threshold
txt = f"{scaled:.4g}"
# Strip unnecessary trailing zeros after decimal
if "." in txt:
txt = txt.rstrip("0").rstrip(".")
return f"{txt}{prefix}"
return f"{hz:.2e}"
def _nice_ticks(vmin: float, vmax: float, n_ticks: int = 5) -> list[float]:
"""Compute human-friendly tick values spanning [vmin, vmax].
Returns a list of round numbers that cover the data range.
"""
if vmin == vmax:
return [vmin]
if not math.isfinite(vmin) or not math.isfinite(vmax):
return [0.0]
raw_step = (vmax - vmin) / max(n_ticks - 1, 1)
if raw_step == 0:
return [vmin]
magnitude = 10 ** math.floor(math.log10(abs(raw_step)))
residual = raw_step / magnitude
# Snap to a "nice" step: 1, 2, 2.5, 5, 10
if residual <= 1.0:
nice_step = magnitude
elif residual <= 2.0:
nice_step = 2 * magnitude
elif residual <= 2.5:
nice_step = 2.5 * magnitude
elif residual <= 5.0:
nice_step = 5 * magnitude
else:
nice_step = 10 * magnitude
tick_min = math.floor(vmin / nice_step) * nice_step
tick_max = math.ceil(vmax / nice_step) * nice_step
ticks: list[float] = []
t = tick_min
while t <= tick_max + nice_step * 0.001:
ticks.append(round(t, 12))
t += nice_step
return ticks
def _log_ticks(vmin: float, vmax: float) -> list[float]:
"""Generate tick values at powers of 10 spanning [vmin, vmax] (linear values)."""
if vmin <= 0:
vmin = 1.0
if vmax <= vmin:
vmax = vmin * 10
low = math.floor(math.log10(vmin))
high = math.ceil(math.log10(vmax))
ticks = [10.0**i for i in range(low, high + 1)]
# Filter to range
return [t for t in ticks if vmin * 0.9999 <= t <= vmax * 1.0001]
def _data_extent(arr: np.ndarray, pad_frac: float = 0.05) -> tuple[float, float]:
"""Return (min, max) of *arr* with *pad_frac* padding on each side."""
if len(arr) == 0:
return (0.0, 1.0)
lo, hi = float(np.nanmin(arr)), float(np.nanmax(arr))
if lo == hi:
lo -= 1.0
hi += 1.0
span = hi - lo
return (lo - span * pad_frac, hi + span * pad_frac)
# ---------------------------------------------------------------------------
# Core SVG building blocks
# ---------------------------------------------------------------------------
_FONT = "system-ui, -apple-system, sans-serif"
def _build_path_d(
xs: np.ndarray,
ys: np.ndarray,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
plot_x: float,
plot_y: float,
plot_w: float,
plot_h: float,
log_x: bool = False,
) -> str:
"""Convert data arrays into an SVG path *d* attribute string."""
if len(xs) == 0:
return ""
# Map data -> pixel coords
if log_x:
safe_xs = np.clip(xs, max(x_min, 1e-30), None)
lx = np.log10(safe_xs)
lx_min = math.log10(max(x_min, 1e-30))
lx_max = math.log10(max(x_max, 1e-30))
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
px = plot_x + (lx - lx_min) / denom_x * plot_w
else:
denom_x = x_max - x_min if x_max != x_min else 1.0
px = plot_x + (xs - x_min) / denom_x * plot_w
denom_y = y_max - y_min if y_max != y_min else 1.0
# Y axis is inverted in SVG (top = 0)
py = plot_y + plot_h - (ys - y_min) / denom_y * plot_h
parts = [f"M{px[0]:.2f},{py[0]:.2f}"]
for i in range(1, len(px)):
parts.append(f"L{px[i]:.2f},{py[i]:.2f}")
return "".join(parts)
def _render_subplot(
*,
xs: np.ndarray,
ys: np.ndarray,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
plot_x: float,
plot_y: float,
plot_w: float,
plot_h: float,
log_x: bool,
xlabel: str,
ylabel: str,
title: str | None,
stroke: str,
x_ticks: list[float] | None = None,
y_ticks: list[float] | None = None,
show_x_labels: bool = True,
) -> str:
"""Render a single subplot region as a block of SVG elements."""
lines: list[str] = []
# Compute ticks
if y_ticks is None:
y_ticks = _nice_ticks(y_min, y_max, n_ticks=6)
if x_ticks is None:
if log_x:
x_ticks = _log_ticks(x_min, x_max)
else:
x_ticks = _nice_ticks(x_min, x_max, n_ticks=6)
# Background
lines.append(
f'<rect x="{plot_x}" y="{plot_y}" width="{plot_w}" height="{plot_h}" '
f'fill="white" stroke="#ccc" stroke-width="1"/>'
)
# Grid + Y tick labels
denom_y = y_max - y_min if y_max != y_min else 1.0
for tv in y_ticks:
if tv < y_min or tv > y_max:
continue
py = plot_y + plot_h - (tv - y_min) / denom_y * plot_h
lines.append(
f'<line x1="{plot_x}" y1="{py:.1f}" x2="{plot_x + plot_w}" y2="{py:.1f}" '
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
)
label = _format_eng(tv)
lines.append(
f'<text x="{plot_x - 8}" y="{py:.1f}" text-anchor="end" '
f'dominant-baseline="middle" font-size="11" font-family="{_FONT}" '
f'fill="#444">{_svg_escape(label)}</text>'
)
# Grid + X tick labels
if log_x:
lx_min = math.log10(max(x_min, 1e-30))
lx_max = math.log10(max(x_max, 1e-30))
denom_x = lx_max - lx_min if lx_max != lx_min else 1.0
else:
denom_x = x_max - x_min if x_max != x_min else 1.0
for tv in x_ticks:
if log_x:
if tv <= 0:
continue
frac = (math.log10(tv) - lx_min) / denom_x
else:
frac = (tv - x_min) / denom_x
if frac < -0.001 or frac > 1.001:
continue
px = plot_x + frac * plot_w
lines.append(
f'<line x1="{px:.1f}" y1="{plot_y}" x2="{px:.1f}" y2="{plot_y + plot_h}" '
f'stroke="#ddd" stroke-width="0.5" stroke-dasharray="4,3"/>'
)
if show_x_labels:
if log_x:
label = _format_freq(tv)
else:
label = _format_eng(tv)
lines.append(
f'<text x="{px:.1f}" y="{plot_y + plot_h + 16}" text-anchor="middle" '
f'font-size="11" font-family="{_FONT}" fill="#444">'
f'{_svg_escape(label)}</text>'
)
# Data path
d = _build_path_d(xs, ys, x_min, x_max, y_min, y_max, plot_x, plot_y, plot_w, plot_h, log_x)
if d:
lines.append(
f'<path d="{d}" fill="none" stroke="{stroke}" stroke-width="1.5" '
f'stroke-linejoin="round" stroke-linecap="round"/>'
)
# Title
if title:
lines.append(
f'<text x="{plot_x + plot_w / 2}" y="{plot_y - 12}" text-anchor="middle" '
f'font-size="14" font-weight="600" font-family="{_FONT}" fill="#111">'
f'{_svg_escape(title)}</text>'
)
# Axis labels
if ylabel:
mid_y = plot_y + plot_h / 2
lines.append(
f'<text x="{plot_x - 55}" y="{mid_y}" text-anchor="middle" '
f'font-size="12" font-family="{_FONT}" fill="#333" '
f'transform="rotate(-90, {plot_x - 55}, {mid_y})">'
f'{_svg_escape(ylabel)}</text>'
)
if xlabel and show_x_labels:
lines.append(
f'<text x="{plot_x + plot_w / 2}" y="{plot_y + plot_h + 42}" '
f'text-anchor="middle" font-size="12" font-family="{_FONT}" fill="#333">'
f'{_svg_escape(xlabel)}</text>'
)
return "\n".join(lines)
def _wrap_svg(inner: str, width: int, height: int) -> str:
"""Wrap inner SVG elements in a root <svg> tag with white background."""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}">\n'
f'<rect width="{width}" height="{height}" fill="white"/>\n'
f'{inner}\n'
f'</svg>'
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def plot_timeseries(
time: list[float] | np.ndarray,
values: list[float] | np.ndarray,
title: str = "Time Domain",
ylabel: str = "Voltage (V)",
width: int = 800,
height: int = 400,
) -> str:
"""Plot a time-domain signal. Linear X axis (time), linear Y axis.
Returns a complete ``<svg>`` XML string.
"""
t = np.asarray(time, dtype=float).ravel()
v = np.asarray(values, dtype=float).ravel()
n = min(len(t), len(v))
t, v = t[:n], v[:n]
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)
x_min, x_max = _data_extent(t)
y_min, y_max = _data_extent(v)
inner = _render_subplot(
xs=t,
ys=v,
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=False,
xlabel="Time (s)",
ylabel=ylabel,
title=title,
stroke="#2563eb",
)
return _wrap_svg(inner, width, height)
def plot_bode(
freq: list[float] | np.ndarray,
mag_db: list[float] | np.ndarray,
phase_deg: list[float] | np.ndarray | None = None,
title: str = "Bode Plot",
width: int = 800,
height: int = 500,
) -> str:
"""Plot frequency response (Bode plot). Log10 X, linear Y (dB).
If *phase_deg* is provided, the SVG contains two stacked subplots:
magnitude on top, phase on the bottom.
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
m = np.asarray(mag_db, dtype=float).ravel()
n = min(len(f), len(m))
f, m = f[:n], m[:n]
has_phase = phase_deg is not None
if has_phase:
p = np.asarray(phase_deg, dtype=float).ravel()
n = min(len(f), len(p))
f, m, p = f[:n], m[:n], p[:n]
margin_l, margin_r, margin_t, margin_b = 80, 20, 40, 60
plot_x = float(margin_l)
plot_w = float(width - margin_l - margin_r)
# Frequency range (shared)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if f_min <= 0:
f_min = 1.0
freq_ticks = _log_ticks(f_min, f_max)
if has_phase:
# Split into two subplots with a gap
gap = 30
available_h = height - margin_t - margin_b - gap
mag_h = available_h * 0.55
phase_h = available_h * 0.45
mag_y = float(margin_t)
phase_y = float(margin_t + mag_h + gap)
m_min, m_max = _data_extent(m)
p_min, p_max = _data_extent(p)
mag_svg = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=mag_y,
plot_w=plot_w,
plot_h=mag_h,
log_x=True,
xlabel="",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
x_ticks=freq_ticks,
show_x_labels=False,
)
phase_svg = _render_subplot(
xs=f,
ys=p,
x_min=f_min,
x_max=f_max,
y_min=p_min,
y_max=p_max,
plot_x=plot_x,
plot_y=phase_y,
plot_w=plot_w,
plot_h=phase_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Phase (deg)",
title=None,
stroke="#dc2626",
x_ticks=freq_ticks,
)
return _wrap_svg(mag_svg + "\n" + phase_svg, width, height)
else:
plot_y = float(margin_t)
plot_h = float(height - margin_t - margin_b)
m_min, m_max = _data_extent(m)
inner = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
x_ticks=freq_ticks,
)
return _wrap_svg(inner, width, height)
def plot_spectrum(
freq: list[float] | np.ndarray,
mag_db: list[float] | np.ndarray,
title: str = "FFT Spectrum",
width: int = 800,
height: int = 400,
) -> str:
"""Plot an FFT spectrum. Log10 X axis, linear Y axis (dB).
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
m = np.asarray(mag_db, dtype=float).ravel()
n = min(len(f), len(m))
f, m = f[:n], m[:n]
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)
f_min, f_max = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if f_min <= 0:
f_min = 1.0
m_min, m_max = _data_extent(m)
inner = _render_subplot(
xs=f,
ys=m,
x_min=f_min,
x_max=f_max,
y_min=m_min,
y_max=m_max,
plot_x=plot_x,
plot_y=plot_y,
plot_w=plot_w,
plot_h=plot_h,
log_x=True,
xlabel="Frequency (Hz)",
ylabel="Magnitude (dB)",
title=title,
stroke="#2563eb",
)
return _wrap_svg(inner, width, height)