Compare commits
7 Commits
be88ea53b7
...
b0db898ab4
| Author | SHA1 | Date | |
|---|---|---|---|
| b0db898ab4 | |||
| 8f0b9ad46a | |||
| 59677a6a7d | |||
| 80a01a7326 | |||
| 29fc250c73 | |||
| cdd5a923a6 | |||
| ad630ff471 |
@ -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 = `“<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>” 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 = `“${signal.name}” from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} ${visualCredit}`;
|
||||
el.innerHTML = `“${signal.name}” 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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user