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,
|
format_engineering,
|
||||||
optimize_component_values,
|
optimize_component_values,
|
||||||
)
|
)
|
||||||
|
from .plotting import build_waveform_svg
|
||||||
from .power_analysis import compute_efficiency, compute_power_metrics
|
from .power_analysis import compute_efficiency, compute_power_metrics
|
||||||
from .raw_parser import parse_raw_file
|
from .raw_parser import parse_raw_file
|
||||||
from .runner import run_netlist, run_simulation
|
from .runner import run_netlist, run_simulation
|
||||||
@ -59,7 +60,6 @@ from .spicebook import (
|
|||||||
ltspice_to_ngspice,
|
ltspice_to_ngspice,
|
||||||
)
|
)
|
||||||
from .stability import compute_stability_metrics
|
from .stability import compute_stability_metrics
|
||||||
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
|
|
||||||
from .templates import (
|
from .templates import (
|
||||||
ASC_TEMPLATES,
|
ASC_TEMPLATES,
|
||||||
NETLIST_TEMPLATES,
|
NETLIST_TEMPLATES,
|
||||||
@ -1296,158 +1296,36 @@ async def plot_waveform(
|
|||||||
|
|
||||||
# Build signal list (signals overrides signal when set)
|
# Build signal list (signals overrides signal when set)
|
||||||
signal_list = signals if signals else [signal]
|
signal_list = signals if signals else [signal]
|
||||||
is_multi = len(signal_list) > 1
|
|
||||||
|
|
||||||
# Resolve all signal names (exact, case-insensitive)
|
result = build_waveform_svg(
|
||||||
sig_names = [v.name for v in raw_data.variables]
|
raw_data,
|
||||||
sig_lower = {s.lower(): s for s in sig_names}
|
signal_list,
|
||||||
resolved: list[tuple[str, int]] = [] # (actual_name, index)
|
plot_type=plot_type,
|
||||||
for s in signal_list:
|
max_points=max_points,
|
||||||
idx = raw_data.resolve_index(s)
|
x_min=x_min,
|
||||||
if idx is None:
|
x_max=x_max,
|
||||||
return {
|
y_min=y_min,
|
||||||
"error": f"Signal '{s}' not found",
|
y_max=y_max,
|
||||||
"available_signals": sig_names,
|
width=width,
|
||||||
}
|
height=height,
|
||||||
resolved.append((sig_lower[s.lower()], idx))
|
title=title,
|
||||||
|
)
|
||||||
# Get x-axis data (time or frequency)
|
if "error" in result:
|
||||||
x_var = raw_data.variables[0]
|
return result
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save SVG
|
# Save SVG
|
||||||
if output_path is None:
|
if output_path is None:
|
||||||
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
|
out = Path(tempfile.mktemp(suffix=".svg", prefix="ltspice_plot_"))
|
||||||
else:
|
else:
|
||||||
out = Path(output_path)
|
out = Path(output_path)
|
||||||
out.write_text(svg)
|
out.write_text(result["svg"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"svg_path": str(out),
|
"svg_path": str(out),
|
||||||
"plot_type": plot_type,
|
"plot_type": result["plot_type"],
|
||||||
"signal": actual_name,
|
"signal": result["signal"],
|
||||||
"points": len(real_x),
|
"points": result["points"],
|
||||||
"dimensions": f"{width}x{height}",
|
"dimensions": result["dimensions"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user