diff --git a/src/mcltspice/plotting.py b/src/mcltspice/plotting.py new file mode 100644 index 0000000..f1c9869 --- /dev/null +++ b/src/mcltspice/plotting.py @@ -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}", + } diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py index 9e5cc7d..426998d 100644 --- a/src/mcltspice/server.py +++ b/src/mcltspice/server.py @@ -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"], }