Compare commits

..

7 Commits

Author SHA1 Message Date
b0db898ab4 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.
2026-02-14 20:16:10 -07:00
8f0b9ad46a Add axis control, point limits, and dimension params to plot_waveform
Expose x_min/x_max/y_min/y_max, max_points, width/height, and title
override through the MCP tool. Data is clipped to X range before
stride-based downsampling for better zoomed resolution. All params
default to None/current behavior for backward compatibility.
2026-02-14 18:04:40 -07:00
59677a6a7d Add signal attenuation knob to oscilloscope control panel
8-position volume control (5%-40%, default 20%) between Source
and Vertical sections. Persists via localStorage, keyboard
accessible, speech volume scales proportionally.
2026-02-13 09:12:52 -07:00
80a01a7326 Add animated rainbow gradient to Outer Limits easter egg text
Opening narration cycles through a 9-stop gradient (red → amber →
yellow → green → teal → blue → violet → pink → red) at 6s per
loop. Closing narration stays teal for visual hierarchy.
2026-02-13 07:03:36 -07:00
29fc250c73 Rework Outer Limits plug as narrative continuation
Replace disconnected bottom link with the show's closing narration
subverted: "We now return control of your television set to
oscilloscopemusic.com" — fades in after typewriter finishes with
a dramatic 1.2s delay. Also add oscilloscopemusic.com to the
always-visible attribution bar for N-Spheres tracks.
2026-02-13 06:55:08 -07:00
cdd5a923a6 Add oscilloscopemusic.com plug to Outer Limits easter egg
After the typewriter finishes, a subtle link fades in at the bottom
of the CRT: "tell 'em warehack.ing sent 'cha" → oscilloscopemusic.com
2026-02-13 06:52:08 -07:00
ad630ff471 Add Outer Limits easter egg to oscilloscope knobs
Clicking the Vertical or Horizontal knobs triggers The Outer Limits
(1963) opening narration as a typewriter overlay on the CRT display,
with Web Speech API narration. Dismiss via click, Escape, or knob
re-click. Works on both 465 and 545A skins.
2026-02-13 06:46:46 -07:00
7 changed files with 824 additions and 57 deletions

View File

@ -35,6 +35,15 @@
></canvas>
<div class="scope-graticule"></div>
<div class="scope-scanlines"></div>
<!-- Outer Limits easter egg overlay -->
<div class="scope-outer-limits" id="scope-outer-limits" aria-hidden="true">
<p class="scope-ol-text" id="scope-ol-text"></p>
<p class="scope-ol-closing" id="scope-ol-closing" data-visible="false">
We now return control of your television set to
<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener"
class="scope-ol-link" id="scope-ol-link">oscilloscopemusic.com</a>
</p>
</div>
</div>
</div>
@ -49,17 +58,27 @@
<span class="scope-knob-label scope-source-name" id="scope-source-name">Spirals</span>
</div>
<!-- LEVEL / ATTENUATION section -->
<div class="scope-section">
<span class="scope-section-label">Level</span>
<div class="scope-knob scope-volume-knob" id="scope-volume-knob"
role="button" tabindex="0" aria-label="Signal attenuation level"></div>
<span class="scope-knob-label scope-volume-label" id="scope-volume-label">20%</span>
</div>
<!-- VERTICAL section -->
<div class="scope-section">
<span class="scope-section-label">Vertical</span>
<div class="scope-knob" aria-hidden="true"></div>
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Vertical control"></div>
<span class="scope-knob-label">Volts/Div</span>
</div>
<!-- HORIZONTAL section -->
<div class="scope-section">
<span class="scope-section-label">Horizontal</span>
<div class="scope-knob" aria-hidden="true"></div>
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Horizontal control"></div>
<span class="scope-knob-label">Time/Div</span>
</div>
@ -115,8 +134,8 @@
// ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Source', 'Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Vert Ampl', 'Sweep', 'Trigger'] },
'465': { brand: 'Tektronix', model: 'mcltspice', sub: '', sections: ['Source', 'Level', 'Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Atten', 'Vert Ampl', 'Sweep', 'Trigger'] },
};
const SKIN_ORDER = Object.keys(SKINS);
@ -254,12 +273,13 @@
const el = document.getElementById('scope-attribution');
if (!el) return;
const audioSite = '<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener">oscilloscopemusic.com</a>';
const visualCredit = '· Visual: <a href="https://codepen.io/2Mogs" target="_blank" rel="noopener">Nick Watton</a> · <a href="https://gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53" target="_blank" rel="noopener">rsp2k</a>';
if ('license' in signal && signal.license) {
el.innerHTML = `&ldquo;<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>&rdquo; by ${signal.artist} (<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>) ${visualCredit}`;
} else {
el.innerHTML = `&ldquo;${signal.name}&rdquo; from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} ${visualCredit}`;
el.innerHTML = `&ldquo;${signal.name}&rdquo; from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} · ${audioSite} ${visualCredit}`;
}
}
@ -429,9 +449,153 @@
ctx.fill();
}
// ── Volume / attenuation control ─────────────────────────
function initVolume() {
const knob = document.getElementById('scope-volume-knob');
const label = document.getElementById('scope-volume-label');
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
if (!knob || !label || !audio) return;
if (knob.dataset.volumeInit) return;
knob.dataset.volumeInit = 'true';
const LEVELS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40];
let levelIndex = parseInt(localStorage.getItem('scope-volume') || '3', 10);
if (levelIndex < 0 || levelIndex >= LEVELS.length) levelIndex = 3;
function applyLevel(idx: number) {
levelIndex = idx;
const vol = LEVELS[idx];
audio!.volume = vol;
label!.textContent = `${Math.round(vol * 100)}%`;
const rotation = (idx / LEVELS.length) * 270 - 135;
knob!.style.setProperty('--knob-rotation', `${rotation}deg`);
localStorage.setItem('scope-volume', String(idx));
}
knob.addEventListener('click', () => {
applyLevel((levelIndex + 1) % LEVELS.length);
});
knob.addEventListener('keydown', (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' || ke.key === ' ') {
ke.preventDefault();
applyLevel((levelIndex + 1) % LEVELS.length);
}
});
applyLevel(levelIndex);
}
// ── Outer Limits easter egg ──────────────────────────────
function initOuterLimits() {
const knobs = document.querySelectorAll('.scope-easter-knob');
const overlay = document.getElementById('scope-outer-limits');
const textEl = document.getElementById('scope-ol-text');
const closingEl = document.getElementById('scope-ol-closing');
const linkEl = document.getElementById('scope-ol-link');
if (!knobs.length || !overlay || !textEl) return;
// Guard against double-init
if (overlay.dataset.olInit) return;
overlay.dataset.olInit = 'true';
const quote = 'There is nothing wrong with your television set. Do not attempt to adjust the picture. We are controlling transmission. We will control the horizontal. We will control the vertical. For the next hour, sit quietly and we will control all that you see and hear.';
let typeTimer: number | null = null;
let utterance: SpeechSynthesisUtterance | null = null;
function show() {
overlay!.dataset.active = 'true';
overlay!.setAttribute('aria-hidden', 'false');
textEl!.textContent = '';
if (closingEl) closingEl.dataset.visible = 'false';
typeText(quote, 0);
speak(quote);
}
function dismiss() {
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
overlay!.dataset.active = 'false';
overlay!.setAttribute('aria-hidden', 'true');
if (utterance && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
}
function typeText(text: string, i: number) {
if (i >= text.length) {
// Quote done — fade in the closing narration after a dramatic beat
if (closingEl) setTimeout(() => { closingEl.dataset.visible = 'true'; }, 1200);
return;
}
textEl!.textContent += text[i];
typeTimer = window.setTimeout(() => typeText(text, i + 1), 30);
}
function speak(text: string) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.85;
utterance.pitch = 0.7;
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
utterance.volume = Math.min(0.8, (audio?.volume ?? 0.2) * 2);
// Prefer a deep English voice if available
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v =>
v.lang.startsWith('en') && /male|daniel|david|james|google uk/i.test(v.name)
) || voices.find(v => v.lang.startsWith('en'));
if (preferred) utterance.voice = preferred;
window.speechSynthesis.speak(utterance);
}
// Ensure voices are loaded (some browsers load async)
if ('speechSynthesis' in window) {
window.speechSynthesis.getVoices();
window.speechSynthesis.addEventListener('voiceschanged', () => {});
}
// Knob clicks toggle the overlay
knobs.forEach(knob => {
knob.addEventListener('click', () => {
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
});
knob.addEventListener('keydown', (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' || ke.key === ' ') {
ke.preventDefault();
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
}
});
});
// Let the closing link open without dismissing the overlay
if (linkEl) linkEl.addEventListener('click', (e: Event) => e.stopPropagation());
// Dismiss on overlay click or Escape
overlay.addEventListener('click', dismiss);
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && overlay!.dataset.active === 'true') dismiss();
});
}
function initAll() {
initSkin();
initScope();
initVolume();
initOuterLimits();
}
// Init when DOM ready (works with Astro's page lifecycle)

View File

@ -261,6 +261,35 @@
outline-offset: 2px;
}
/* ── Volume / attenuation knob (interactive) ──────────── */
.scope-volume-knob {
cursor: pointer;
transition: transform 0.15s;
transform: rotate(var(--knob-rotation, 0deg));
}
.scope-volume-knob:hover {
transform: rotate(var(--knob-rotation, 0deg)) scale(1.08);
}
.scope-volume-knob:active {
transform: rotate(var(--knob-rotation, 0deg)) scale(0.95);
}
.scope-volume-knob:focus-visible {
outline: 2px solid var(--scope-teal);
outline-offset: 2px;
}
/* ── Volume label ─────────────────────────────────────── */
.scope-volume-label {
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
/* ── Signal name label ──────────────────────────────────── */
.scope-source-name {
max-width: 60px;
@ -359,6 +388,103 @@
color: #1a1610;
}
/* ── Outer Limits easter egg overlay ─────────────────────── */
.scope-outer-limits {
position: absolute;
inset: 0;
background: rgba(10, 10, 10, 0.92);
z-index: 10;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10% 12%;
cursor: pointer;
}
.scope-outer-limits[data-active="true"] {
display: flex;
}
.scope-ol-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 0.65rem;
line-height: 1.6;
text-align: center;
margin: 0;
/* Animated rainbow gradient text */
background: linear-gradient(
90deg,
#ff6b6b, #ffb347, #ffd93d, #a3ff6b,
#2dd4bf, #6bb5ff, #c084fc, #ff6b9d, #ff6b6b
);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: scope-rainbow 6s linear infinite;
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.15));
}
@keyframes scope-rainbow {
0% { background-position: 0% center; }
100% { background-position: 300% center; }
}
/* Closing narration + plug — fades in after typewriter finishes */
.scope-ol-closing {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 0.55rem;
line-height: 1.5;
color: var(--scope-teal);
text-align: center;
text-shadow: 0 0 8px var(--scope-teal-glow);
margin: 1em 0 0;
opacity: 0;
transform: translateY(6px);
transition: opacity 1.5s ease-in, transform 1.5s ease-out;
pointer-events: none;
}
.scope-ol-closing[data-visible="true"] {
opacity: 0.85;
transform: translateY(0);
pointer-events: auto;
}
.scope-ol-link {
color: var(--scope-teal);
text-decoration: none;
font-style: italic;
font-size: 0.65rem;
text-shadow: 0 0 12px var(--scope-teal-glow), 0 0 4px var(--scope-teal-glow);
transition: text-shadow 0.2s;
}
.scope-ol-link:hover {
text-decoration: underline;
text-shadow: 0 0 16px var(--scope-teal), 0 0 6px var(--scope-teal-glow);
}
/* ── Easter egg knobs (Vertical / Horizontal) ────────────── */
.scope-easter-knob {
cursor: pointer;
transition: transform 0.15s;
}
.scope-easter-knob:hover {
transform: scale(1.08);
}
.scope-easter-knob:active {
transform: scale(0.95);
}
.scope-easter-knob:focus-visible {
outline: 2px solid var(--scope-teal);
outline-offset: 2px;
}
/* ── Idle state ──────────────────────────────────────────── */
.scope-screen[data-idle="true"] .scope-canvas {
opacity: 0.6;

View File

@ -1,6 +1,6 @@
[project]
name = "mcltspice"
version = "2026.02.10"
version = "2026.02.14.1"
description = "MCP server for LTspice circuit simulation automation"
readme = "README.md"
requires-python = ">=3.11"

View File

@ -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,8 +1319,17 @@ 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,
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:
"""Generate an SVG plot from simulation results.
@ -1330,8 +1339,17 @@ 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)
x_min: Left X-axis bound (seconds for time, Hz for frequency). None = auto
x_max: Right X-axis bound. None = auto
y_min: Lower Y-axis bound (volts for time, dB for bode/spectrum). None = auto
y_max: Upper Y-axis bound. None = auto
width: SVG width in pixels
height: SVG height in pixels (None = 500 for bode, 400 otherwise)
title: Plot title (None = auto-generated from signal name and plot type)
"""
raw_path = Path(raw_file)
if not raw_path.exists():
@ -1342,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:
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 '{signal}' not found",
"error": f"Signal '{s}' 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]
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":
@ -1379,29 +1399,107 @@ 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:
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
freq = np.real(x_data)
svg = plot_bode(
freq=freq, mag_db=mag_db, phase_deg=phase_deg,
title=f"Bode Plot — {actual_name}",
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":
freq = np.real(x_data)
if mag_db is None:
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
svg = plot_spectrum(
freq=freq, mag_db=mag_db,
title=f"Spectrum — {actual_name}",
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=np.real(x_data), values=np.real(values),
title=f"Time Domain — {actual_name}",
ylabel=actual_name,
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
@ -1415,7 +1513,8 @@ 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}",
}

View File

@ -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'<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(
xs: np.ndarray,
@ -334,6 +392,10 @@ def plot_timeseries(
ylabel: str = "Voltage (V)",
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 a time-domain signal. Linear X axis (time), linear Y axis.
@ -350,8 +412,23 @@ def plot_timeseries(
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)
auto_xmin, auto_xmax = _data_extent(t)
auto_ymin, auto_ymax = _data_extent(v)
x_min = x_min if x_min is not None else auto_xmin
x_max = x_max if x_max is not None else auto_xmax
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,
@ -365,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",
@ -380,12 +457,18 @@ def plot_bode(
title: str = "Bode Plot",
width: int = 800,
height: int = 500,
x_min: float | None = None,
x_max: float | None = None,
y_min: float | None = None,
y_max: float | None = None,
) -> 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.
*y_min*/*y_max* apply to the magnitude subplot only; phase auto-ranges.
Returns a complete ``<svg>`` XML string.
"""
f = np.asarray(freq, dtype=float).ravel()
@ -404,7 +487,11 @@ def plot_bode(
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)
auto_fmin, auto_fmax = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if auto_fmin <= 0:
auto_fmin = 1.0
f_min = x_min if x_min is not None else auto_fmin
f_max = x_max if x_max is not None else auto_fmax
if f_min <= 0:
f_min = 1.0
freq_ticks = _log_ticks(f_min, f_max)
@ -418,7 +505,9 @@ def plot_bode(
mag_y = float(margin_t)
phase_y = float(margin_t + mag_h + gap)
m_min, m_max = _data_extent(m)
auto_mmin, auto_mmax = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
p_min, p_max = _data_extent(p)
mag_svg = _render_subplot(
@ -463,7 +552,9 @@ def plot_bode(
else:
plot_y = float(margin_t)
plot_h = float(height - margin_t - margin_b)
m_min, m_max = _data_extent(m)
auto_mmin, auto_mmax = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
inner = _render_subplot(
xs=f,
@ -492,6 +583,10 @@ def plot_spectrum(
title: str = "FFT Spectrum",
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 an FFT spectrum. Log10 X axis, linear Y axis (dB).
@ -508,10 +603,16 @@ def plot_spectrum(
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)
auto_fmin, auto_fmax = _data_extent(f[f > 0] if np.any(f > 0) else f, pad_frac=0.0)
if auto_fmin <= 0:
auto_fmin = 1.0
auto_mmin, auto_mmax = _data_extent(m)
f_min = x_min if x_min is not None else auto_fmin
f_max = x_max if x_max is not None else auto_fmax
if f_min <= 0:
f_min = 1.0
m_min, m_max = _data_extent(m)
m_min = y_min if y_min is not None else auto_mmin
m_max = y_max if y_max is not None else auto_mmax
inner = _render_subplot(
xs=f,
@ -531,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 ``<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)

View File

@ -9,6 +9,7 @@ from mcltspice.svg_plot import (
plot_bode,
plot_spectrum,
plot_timeseries,
plot_timeseries_multi,
)
# ---------------------------------------------------------------------------
@ -197,3 +198,178 @@ class TestSvgDimensions:
svg = plot_spectrum(freq, mag, width=1000, height=500)
assert 'width="1000"' in svg
assert 'height="500"' in svg
# ---------------------------------------------------------------------------
# Axis range overrides
# ---------------------------------------------------------------------------
class TestAxisRangeOverrides:
def test_timeseries_custom_axis_range(self, sine_wave):
"""Explicit x/y ranges produce valid SVG without crashing."""
t, v = sine_wave
svg = plot_timeseries(
t, v, x_min=0.002, x_max=0.005, y_min=-0.5, y_max=0.5,
)
assert svg.startswith("<svg")
assert "<path" in svg
assert "</svg>" in svg
def test_bode_custom_freq_range(self, bode_data):
"""Bode plot with explicit frequency range includes ticks in range."""
freq, mag_db, phase_deg = bode_data
svg = plot_bode(
freq, mag_db, phase_deg,
x_min=100.0, x_max=100_000.0,
y_min=-40.0, y_max=0.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
# Tick labels should include values within 100 Hz 100 kHz
found = sum(1 for lbl in ("1k", "10k") if lbl in svg)
assert found >= 1, f"Expected freq tick labels in range; found {found}"
def test_spectrum_custom_range(self, spectrum_data):
"""Spectrum with explicit ranges produces valid SVG."""
freq, mag_db = spectrum_data
svg = plot_spectrum(
freq, mag_db,
x_min=100.0, x_max=50_000.0,
y_min=-80.0, y_max=10.0,
)
assert svg.startswith("<svg")
assert "<path" in svg
def test_partial_range_override(self, sine_wave):
"""Setting only y_min while leaving others auto should work."""
t, v = sine_wave
svg = plot_timeseries(t, v, y_min=-2.0)
assert svg.startswith("<svg")
assert "<path" in svg
def test_none_defaults_match_current(self, sine_wave):
"""Passing None for all range params produces identical output."""
t, v = sine_wave
svg_default = plot_timeseries(t, v)
svg_explicit_none = plot_timeseries(
t, v,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_bode_none_defaults_match_current(self, bode_data):
"""Bode with explicit None params matches default output."""
freq, mag_db, phase_deg = bode_data
svg_default = plot_bode(freq, mag_db, phase_deg)
svg_explicit_none = plot_bode(
freq, mag_db, phase_deg,
x_min=None, x_max=None, y_min=None, y_max=None,
)
assert svg_default == svg_explicit_none
def test_spectrum_none_defaults_match_current(self, spectrum_data):
"""Spectrum with explicit None params matches default output."""
freq, mag_db = spectrum_data
svg_default = plot_spectrum(freq, mag_db)
svg_explicit_none = plot_spectrum(
freq, mag_db,
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 <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
View File

@ -1028,7 +1028,7 @@ wheels = [
[[package]]
name = "mcltspice"
version = "2026.2.10"
version = "2026.2.14"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },