- 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
534 lines
15 KiB
Python
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)
|