Extract plot_waveform core into plotting.py
plot_waveform was the largest tool at 207 lines, interleaving signal resolution, auto plot-type detection, X-range clipping, downsampling, and svg_plot dispatch with the .raw read and SVG write. Move the RawFile -> SVG transform into plotting.build_waveform_svg (returns the SVG string + metadata, no filesystem); the tool keeps only the parse and write I/O. server.py: 2799 -> 2677 lines. Output verified byte-identical (SVG sha256 + metadata) to the prior version across bode/spectrum/time, x-range+downsample, custom dimensions, and both error paths.
This commit is contained in:
parent
181d1cb925
commit
b634eb9cd1
176
src/mcltspice/plotting.py
Normal file
176
src/mcltspice/plotting.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Waveform-plot construction, separated from file I/O.
|
||||
|
||||
build_waveform_svg() turns a parsed RawFile into an SVG string plus metadata.
|
||||
It does no filesystem access, so it can be unit-tested with a hand-built
|
||||
RawFile -- the plot_waveform MCP tool wraps it with the .raw read and SVG write.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .raw_parser import RawFile
|
||||
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
|
||||
|
||||
|
||||
def build_waveform_svg(
|
||||
raw_data: RawFile,
|
||||
signal_list: list[str],
|
||||
plot_type: str = "auto",
|
||||
max_points: int = 10000,
|
||||
x_min: float | None = None,
|
||||
x_max: float | None = None,
|
||||
y_min: float | None = None,
|
||||
y_max: float | None = None,
|
||||
width: int = 800,
|
||||
height: int | None = None,
|
||||
title: str | None = None,
|
||||
) -> dict:
|
||||
"""Render one or more signals from a RawFile to an SVG string.
|
||||
|
||||
Returns {"svg", "plot_type", "signal", "points", "dimensions"} on success,
|
||||
or {"error", ...} if a signal is missing or a multi-signal overlay is
|
||||
requested for a non-time-domain plot. plot_type "auto" picks bode (complex
|
||||
frequency data), spectrum (real frequency data), or time (everything else).
|
||||
"""
|
||||
is_multi = len(signal_list) > 1
|
||||
|
||||
# Resolve all signal names (exact, case-insensitive)
|
||||
sig_names = [v.name for v in raw_data.variables]
|
||||
sig_lower = {s.lower(): s for s in sig_names}
|
||||
resolved: list[tuple[str, int]] = [] # (actual_name, index)
|
||||
for s in signal_list:
|
||||
idx = raw_data.resolve_index(s)
|
||||
if idx is None:
|
||||
return {
|
||||
"error": f"Signal '{s}' not found",
|
||||
"available_signals": sig_names,
|
||||
}
|
||||
resolved.append((sig_lower[s.lower()], idx))
|
||||
|
||||
# Get x-axis data (time or frequency)
|
||||
x_var = raw_data.variables[0]
|
||||
x_data = raw_data.data[0]
|
||||
is_freq = x_var.name.lower() == "frequency"
|
||||
|
||||
# For single-signal: check complex data (AC analysis)
|
||||
first_values = raw_data.data[resolved[0][1]]
|
||||
is_complex = bool(np.iscomplexobj(first_values))
|
||||
|
||||
# Determine plot type
|
||||
if plot_type == "auto":
|
||||
if is_freq and is_complex:
|
||||
plot_type = "bode"
|
||||
elif is_freq:
|
||||
plot_type = "spectrum"
|
||||
else:
|
||||
plot_type = "time"
|
||||
|
||||
# Multi-signal only supported for time-domain
|
||||
if is_multi and plot_type != "time":
|
||||
return {
|
||||
"error": "Multi-signal overlay is only supported for time-domain plots.",
|
||||
"plot_type": plot_type,
|
||||
"hint": "Use single signal for bode/spectrum, or set plot_type='time'.",
|
||||
}
|
||||
|
||||
# Clip to X range (before downsampling for efficiency)
|
||||
real_x = np.real(x_data)
|
||||
mask = None
|
||||
if x_min is not None or x_max is not None:
|
||||
mask = np.ones(len(real_x), dtype=bool)
|
||||
if x_min is not None:
|
||||
mask &= real_x >= x_min
|
||||
if x_max is not None:
|
||||
mask &= real_x <= x_max
|
||||
x_data = x_data[mask]
|
||||
real_x = real_x[mask]
|
||||
|
||||
# Downsample (stride-based)
|
||||
step = None
|
||||
if max_points and len(x_data) > max_points:
|
||||
step = max(1, len(x_data) // max_points)
|
||||
x_data = x_data[::step]
|
||||
real_x = real_x[::step]
|
||||
|
||||
# Height default
|
||||
if height is None:
|
||||
height = 500 if plot_type == "bode" else 400
|
||||
|
||||
if is_multi:
|
||||
# Multi-signal time-domain overlay
|
||||
traces: list[tuple[str, np.ndarray]] = []
|
||||
for name, idx in resolved:
|
||||
v = raw_data.data[idx]
|
||||
if mask is not None:
|
||||
v = v[mask]
|
||||
if step is not None:
|
||||
v = v[::step]
|
||||
traces.append((name, np.real(v)))
|
||||
|
||||
if title is None:
|
||||
names_str = ", ".join(n for n, _ in resolved)
|
||||
title = f"Time Domain — {names_str}"
|
||||
|
||||
svg = plot_timeseries_multi(
|
||||
time=real_x, traces=traces,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
actual_name = ", ".join(n for n, _ in resolved)
|
||||
else:
|
||||
# Single-signal path
|
||||
actual_name, sig_idx = resolved[0]
|
||||
values = raw_data.data[sig_idx]
|
||||
if mask is not None:
|
||||
values = values[mask]
|
||||
if step is not None:
|
||||
values = values[::step]
|
||||
|
||||
if np.iscomplexobj(values):
|
||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
|
||||
phase_deg = np.degrees(np.angle(values))
|
||||
else:
|
||||
mag_db = None
|
||||
phase_deg = None
|
||||
|
||||
# Title default
|
||||
if title is None:
|
||||
if plot_type == "bode":
|
||||
title = f"Bode Plot — {actual_name}"
|
||||
elif plot_type == "spectrum":
|
||||
title = f"Spectrum — {actual_name}"
|
||||
else:
|
||||
title = f"Time Domain — {actual_name}"
|
||||
|
||||
# Generate SVG
|
||||
if plot_type == "bode":
|
||||
if mag_db is None:
|
||||
mag_db = np.real(values)
|
||||
phase_deg = None
|
||||
svg = plot_bode(
|
||||
freq=real_x, mag_db=mag_db, phase_deg=phase_deg,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
elif plot_type == "spectrum":
|
||||
if mag_db is None:
|
||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
|
||||
svg = plot_spectrum(
|
||||
freq=real_x, mag_db=mag_db,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
else: # time
|
||||
svg = plot_timeseries(
|
||||
time=real_x, values=np.real(values),
|
||||
title=title, ylabel=actual_name,
|
||||
width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
|
||||
return {
|
||||
"svg": svg,
|
||||
"plot_type": plot_type,
|
||||
"signal": actual_name,
|
||||
"points": len(real_x),
|
||||
"dimensions": f"{width}x{height}",
|
||||
}
|
||||
@ -49,6 +49,7 @@ from .optimizer import (
|
||||
format_engineering,
|
||||
optimize_component_values,
|
||||
)
|
||||
from .plotting import build_waveform_svg
|
||||
from .power_analysis import compute_efficiency, compute_power_metrics
|
||||
from .raw_parser import parse_raw_file
|
||||
from .runner import run_netlist, run_simulation
|
||||
@ -59,7 +60,6 @@ from .spicebook import (
|
||||
ltspice_to_ngspice,
|
||||
)
|
||||
from .stability import compute_stability_metrics
|
||||
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
|
||||
from .templates import (
|
||||
ASC_TEMPLATES,
|
||||
NETLIST_TEMPLATES,
|
||||
@ -1296,158 +1296,36 @@ async def plot_waveform(
|
||||
|
||||
# Build signal list (signals overrides signal when set)
|
||||
signal_list = signals if signals else [signal]
|
||||
is_multi = len(signal_list) > 1
|
||||
|
||||
# Resolve all signal names (exact, case-insensitive)
|
||||
sig_names = [v.name for v in raw_data.variables]
|
||||
sig_lower = {s.lower(): s for s in sig_names}
|
||||
resolved: list[tuple[str, int]] = [] # (actual_name, index)
|
||||
for s in signal_list:
|
||||
idx = raw_data.resolve_index(s)
|
||||
if idx is None:
|
||||
return {
|
||||
"error": f"Signal '{s}' not found",
|
||||
"available_signals": sig_names,
|
||||
}
|
||||
resolved.append((sig_lower[s.lower()], idx))
|
||||
|
||||
# Get x-axis data (time or frequency)
|
||||
x_var = raw_data.variables[0]
|
||||
x_data = raw_data.data[0]
|
||||
is_freq = x_var.name.lower() == "frequency"
|
||||
|
||||
# For single-signal: check complex data (AC analysis)
|
||||
first_values = raw_data.data[resolved[0][1]]
|
||||
if np.iscomplexobj(first_values):
|
||||
is_complex = True
|
||||
else:
|
||||
is_complex = False
|
||||
|
||||
# Determine plot type
|
||||
if plot_type == "auto":
|
||||
if is_freq and is_complex:
|
||||
plot_type = "bode"
|
||||
elif is_freq:
|
||||
plot_type = "spectrum"
|
||||
else:
|
||||
plot_type = "time"
|
||||
|
||||
# Multi-signal only supported for time-domain
|
||||
if is_multi and plot_type != "time":
|
||||
return {
|
||||
"error": "Multi-signal overlay is only supported for time-domain plots.",
|
||||
"plot_type": plot_type,
|
||||
"hint": "Use single signal for bode/spectrum, or set plot_type='time'.",
|
||||
}
|
||||
|
||||
# Clip to X range (before downsampling for efficiency)
|
||||
real_x = np.real(x_data)
|
||||
mask = None
|
||||
if x_min is not None or x_max is not None:
|
||||
mask = np.ones(len(real_x), dtype=bool)
|
||||
if x_min is not None:
|
||||
mask &= real_x >= x_min
|
||||
if x_max is not None:
|
||||
mask &= real_x <= x_max
|
||||
x_data = x_data[mask]
|
||||
real_x = real_x[mask]
|
||||
|
||||
# Downsample (stride-based, same pattern as get_waveform)
|
||||
step = None
|
||||
if max_points and len(x_data) > max_points:
|
||||
step = max(1, len(x_data) // max_points)
|
||||
x_data = x_data[::step]
|
||||
real_x = real_x[::step]
|
||||
|
||||
# Height default
|
||||
if height is None:
|
||||
height = 500 if plot_type == "bode" else 400
|
||||
|
||||
if is_multi:
|
||||
# Multi-signal time-domain overlay
|
||||
traces: list[tuple[str, np.ndarray]] = []
|
||||
for name, idx in resolved:
|
||||
v = raw_data.data[idx]
|
||||
if mask is not None:
|
||||
v = v[mask]
|
||||
if step is not None:
|
||||
v = v[::step]
|
||||
traces.append((name, np.real(v)))
|
||||
|
||||
if title is None:
|
||||
names_str = ", ".join(n for n, _ in resolved)
|
||||
title = f"Time Domain \u2014 {names_str}"
|
||||
|
||||
svg = plot_timeseries_multi(
|
||||
time=real_x, traces=traces,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
actual_name = ", ".join(n for n, _ in resolved)
|
||||
else:
|
||||
# Single-signal path (original logic)
|
||||
actual_name, sig_idx = resolved[0]
|
||||
values = raw_data.data[sig_idx]
|
||||
if mask is not None:
|
||||
values = values[mask]
|
||||
if step is not None:
|
||||
values = values[::step]
|
||||
|
||||
if np.iscomplexobj(values):
|
||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(values), 1e-30))
|
||||
phase_deg = np.degrees(np.angle(values))
|
||||
else:
|
||||
mag_db = None
|
||||
phase_deg = None
|
||||
|
||||
# Title default
|
||||
if title is None:
|
||||
if plot_type == "bode":
|
||||
title = f"Bode Plot \u2014 {actual_name}"
|
||||
elif plot_type == "spectrum":
|
||||
title = f"Spectrum \u2014 {actual_name}"
|
||||
else:
|
||||
title = f"Time Domain \u2014 {actual_name}"
|
||||
|
||||
# Generate SVG
|
||||
if plot_type == "bode":
|
||||
if mag_db is None:
|
||||
mag_db = np.real(values)
|
||||
phase_deg = None
|
||||
svg = plot_bode(
|
||||
freq=real_x, mag_db=mag_db, phase_deg=phase_deg,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
elif plot_type == "spectrum":
|
||||
if mag_db is None:
|
||||
mag_db = 20.0 * np.log10(np.maximum(np.abs(np.real(values)), 1e-30))
|
||||
svg = plot_spectrum(
|
||||
freq=real_x, mag_db=mag_db,
|
||||
title=title, width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
else: # time
|
||||
svg = plot_timeseries(
|
||||
time=real_x, values=np.real(values),
|
||||
title=title, ylabel=actual_name,
|
||||
width=width, height=height,
|
||||
x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max,
|
||||
)
|
||||
result = build_waveform_svg(
|
||||
raw_data,
|
||||
signal_list,
|
||||
plot_type=plot_type,
|
||||
max_points=max_points,
|
||||
x_min=x_min,
|
||||
x_max=x_max,
|
||||
y_min=y_min,
|
||||
y_max=y_max,
|
||||
width=width,
|
||||
height=height,
|
||||
title=title,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
# Save SVG
|
||||
if output_path is None:
|
||||
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
|
||||
else:
|
||||
out = Path(output_path)
|
||||
out.write_text(svg)
|
||||
out.write_text(result["svg"])
|
||||
|
||||
return {
|
||||
"svg_path": str(out),
|
||||
"plot_type": plot_type,
|
||||
"signal": actual_name,
|
||||
"points": len(real_x),
|
||||
"dimensions": f"{width}x{height}",
|
||||
"plot_type": result["plot_type"],
|
||||
"signal": result["signal"],
|
||||
"points": result["points"],
|
||||
"dimensions": result["dimensions"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user