diff --git a/pyproject.toml b/pyproject.toml index 6f14dbf..b33b9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcltspice" -version = "2026.02.10" +version = "2026.02.14" description = "MCP server for LTspice circuit simulation automation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/mcltspice/server.py b/src/mcltspice/server.py index 8d6cf90..1ad966e 100644 --- a/src/mcltspice/server.py +++ b/src/mcltspice/server.py @@ -1321,6 +1321,14 @@ async def plot_waveform( signal: str = "V(out)", 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. @@ -1332,6 +1340,14 @@ async def plot_waveform( signal: Signal name to plot (e.g. "V(out)", "I(R1)") 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(): @@ -1379,29 +1395,70 @@ async def plot_waveform( else: plot_type = "time" + # Clip to X range (before downsampling for efficiency) + real_x = np.real(x_data) + 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] + values = values[mask] + real_x = real_x[mask] + if mag_db is not None: + mag_db = mag_db[mask] + if phase_deg is not None: + phase_deg = phase_deg[mask] + + # Downsample (stride-based, same pattern as get_waveform) + if max_points and len(x_data) > max_points: + step = max(1, len(x_data) // max_points) + x_data = x_data[::step] + values = values[::step] + real_x = real_x[::step] + if mag_db is not None: + mag_db = mag_db[::step] + if phase_deg is not None: + phase_deg = phase_deg[::step] + + # Height default + if height is None: + height = 500 if plot_type == "bode" else 400 + + # 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 @@ -1416,6 +1473,7 @@ async def plot_waveform( "plot_type": plot_type, "signal": actual_name, "points": len(x_data), + "dimensions": f"{width}x{height}", } diff --git a/src/mcltspice/svg_plot.py b/src/mcltspice/svg_plot.py index e6de066..7d9fdd1 100644 --- a/src/mcltspice/svg_plot.py +++ b/src/mcltspice/svg_plot.py @@ -334,6 +334,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 +354,12 @@ 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 inner = _render_subplot( xs=t, @@ -380,12 +388,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 ```` XML string. """ f = np.asarray(freq, dtype=float).ravel() @@ -404,7 +418,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 +436,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 +483,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 +514,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 +534,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, diff --git a/tests/test_svg_plot.py b/tests/test_svg_plot.py index fd69fd0..750433c 100644 --- a/tests/test_svg_plot.py +++ b/tests/test_svg_plot.py @@ -197,3 +197,82 @@ 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("" 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("= 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("