From 896a8535cf7343d38fbbcf3275528ec5c19a34f1 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 5 Mar 2026 15:06:41 -0700 Subject: [PATCH] Add LTspice simulation engine via mcltspice Wire up LTspice as a second simulation engine using mcltspice (Wine-based LTspice runner). The backend architecture already had the ABC + factory pattern; this connects the ltspice branch. - Extract shared raw_to_waveform() into raw_convert.py with Protocol typing so both ngspice and mcltspice RawFile objects work without coupling - Add LtspiceEngine with deferred mcltspice import for graceful degradation - Register "ltspice" in get_engine() with availability check (mcltspice + Wine + LTspice.exe must all be present) - Add startup validation log for LTspice availability - Add mcltspice as optional dependency: pip install spicebook[ltspice] - Integration tests auto-skip when LTspice is unavailable (Docker/CI) --- backend/Dockerfile | 3 + backend/pyproject.toml | 1 + backend/src/spicebook/engine/__init__.py | 30 +++++ backend/src/spicebook/engine/ltspice.py | 98 +++++++++++++++ backend/src/spicebook/engine/ngspice.py | 70 +---------- backend/src/spicebook/engine/raw_convert.py | 96 +++++++++++++++ backend/src/spicebook/main.py | 15 +++ backend/tests/test_ltspice.py | 127 ++++++++++++++++++++ backend/uv.lock | 24 +++- 9 files changed, 396 insertions(+), 68 deletions(-) create mode 100644 backend/src/spicebook/engine/ltspice.py create mode 100644 backend/src/spicebook/engine/raw_convert.py create mode 100644 backend/tests/test_ltspice.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 061e541..e5ecf8e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,9 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base # System dependencies -- ngspice is required for simulation +# NOTE: LTspice requires Wine + a Windows LTspice install, which is only +# available on the dev host (not in this container). The backend gracefully +# degrades -- LTspice engine returns UnsupportedEngineError in Docker. RUN apt-get update && \ apt-get install -y --no-install-recommends ngspice && \ rm -rf /var/lib/apt/lists/* diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8ba4455..3c55b47 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ ] [project.optional-dependencies] +ltspice = ["mcltspice>=2026.02.14"] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/backend/src/spicebook/engine/__init__.py b/backend/src/spicebook/engine/__init__.py index f8b0776..8829fda 100644 --- a/backend/src/spicebook/engine/__init__.py +++ b/backend/src/spicebook/engine/__init__.py @@ -1,15 +1,45 @@ """SPICE simulation engine registry.""" +import logging + from spicebook.engine.base import SpiceEngine from spicebook.engine.ngspice import NgspiceEngine +logger = logging.getLogger(__name__) + class UnsupportedEngineError(ValueError): """Raised when an unknown engine name is requested.""" +def _ltspice_available() -> bool: + """Check whether mcltspice + Wine + LTspice.exe are all present.""" + try: + from mcltspice.config import validate_installation + + ok, msg = validate_installation() + if not ok: + logger.debug("LTspice not available: %s", msg) + return ok + except ImportError: + return False + except Exception as exc: + logger.warning("LTspice availability check failed: %s", exc) + return False + + def get_engine(engine_name: str) -> SpiceEngine: """Resolve a simulation engine by name.""" if engine_name == "ngspice": return NgspiceEngine() + + if engine_name == "ltspice": + if not _ltspice_available(): + raise UnsupportedEngineError( + "LTspice engine is not available (requires mcltspice + Wine + LTspice.exe)" + ) + from spicebook.engine.ltspice import LtspiceEngine + + return LtspiceEngine() + raise UnsupportedEngineError(f"Unsupported engine: '{engine_name}'") diff --git a/backend/src/spicebook/engine/ltspice.py b/backend/src/spicebook/engine/ltspice.py new file mode 100644 index 0000000..9b84c10 --- /dev/null +++ b/backend/src/spicebook/engine/ltspice.py @@ -0,0 +1,98 @@ +"""LTspice simulation engine -- delegates to mcltspice via Wine.""" + +import logging +import time +from pathlib import Path + +from spicebook.engine.base import SpiceEngine +from spicebook.engine.raw_convert import raw_to_waveform +from spicebook.models.simulation import SimulationResponse + +logger = logging.getLogger(__name__) + +# Wine overhead means LTspice needs more headroom than native ngspice +LTSPICE_TIMEOUT_SECONDS = 120 + + +class LtspiceEngine(SpiceEngine): + """Run simulations via LTspice through Wine using mcltspice.""" + + async def run(self, netlist: str, work_dir: Path) -> SimulationResponse: + t0 = time.monotonic() + + # Deferred import so the module loads even without mcltspice installed + try: + from mcltspice.runner import run_netlist + except ImportError as exc: + logger.error("mcltspice.runner import failed: %s", exc) + return SimulationResponse( + success=False, + error=f"mcltspice runner unavailable: {exc}", + elapsed_seconds=time.monotonic() - t0, + ) + + # Ensure netlist ends with .end + netlist_stripped = netlist.rstrip() + if not netlist_stripped.lower().endswith(".end"): + netlist_stripped += "\n.end\n" + else: + netlist_stripped += "\n" + + cir_path = work_dir / "input.cir" + try: + cir_path.write_text(netlist_stripped) + except OSError as exc: + return SimulationResponse( + success=False, + error=f"Failed to write netlist to disk: {exc}", + elapsed_seconds=time.monotonic() - t0, + ) + + try: + result = await run_netlist( + netlist_path=cir_path, + timeout=LTSPICE_TIMEOUT_SECONDS, + ) + except Exception: + logger.exception("LTspice execution failed unexpectedly") + return SimulationResponse( + success=False, + error="LTspice execution failed (see server logs)", + elapsed_seconds=time.monotonic() - t0, + ) + + log_text = result.stdout + result.stderr + + if not result.success: + return SimulationResponse( + success=False, + log=log_text, + error=result.error or "LTspice simulation failed", + elapsed_seconds=result.elapsed_seconds, + ) + + if result.raw_data is None: + return SimulationResponse( + success=False, + log=log_text, + error=result.error or "No waveform data produced", + elapsed_seconds=result.elapsed_seconds, + ) + + try: + waveform = raw_to_waveform(result.raw_data) + except Exception: + logger.exception("Failed to convert LTspice raw data to waveform") + return SimulationResponse( + success=False, + log=log_text, + error="Failed to parse LTspice output (see server logs)", + elapsed_seconds=result.elapsed_seconds, + ) + + return SimulationResponse( + success=True, + waveform=waveform, + log=log_text, + elapsed_seconds=result.elapsed_seconds, + ) diff --git a/backend/src/spicebook/engine/ngspice.py b/backend/src/spicebook/engine/ngspice.py index d063bbe..6c33e39 100644 --- a/backend/src/spicebook/engine/ngspice.py +++ b/backend/src/spicebook/engine/ngspice.py @@ -11,11 +11,8 @@ import numpy as np from spicebook.config import settings from spicebook.engine.base import SpiceEngine -from spicebook.models.simulation import ( - SimulationResponse, - WaveformData, - WaveformVariable, -) +from spicebook.engine.raw_convert import raw_to_waveform +from spicebook.models.simulation import SimulationResponse logger = logging.getLogger(__name__) @@ -300,7 +297,7 @@ class NgspiceEngine(SpiceEngine): elapsed_seconds=time.monotonic() - t0, ) - waveform = _raw_to_waveform(raw) + waveform = raw_to_waveform(raw) return SimulationResponse( success=True, @@ -318,64 +315,3 @@ def _extract_error_lines(log: str) -> str: if "error" in lower or "fatal" in lower or "cannot" in lower: error_lines.append(line.strip()) return "\n".join(error_lines) if error_lines else "" - - -def _raw_to_waveform(raw: RawFile) -> WaveformData: - """Convert parsed RawFile into a WaveformData response model.""" - is_complex = "complex" in raw.flags - - # Determine x-axis variable (first variable is always the sweep) - x_var = raw.variables[0] - x_type = "frequency" if "frequency" in x_var.type.lower() else "time" - - # Build variable list (skip x-axis variable) - variables = [] - for v in raw.variables[1:]: - variables.append(WaveformVariable(name=v.name, type=v.type)) - - # Convert x data - x_array = raw.data[0] - if is_complex: - x_data = np.real(x_array).tolist() - else: - x_data = x_array.tolist() - - # Convert y data - y_data: dict[str, list[float]] = {} - y_magnitude_db: dict[str, list[float]] | None = None - y_phase_deg: dict[str, list[float]] | None = None - - if is_complex: - y_magnitude_db = {} - y_phase_deg = {} - - for v in raw.variables[1:]: - arr = raw.data[v.index] - # Real part as the default y_data - y_data[v.name] = np.real(arr).tolist() - - # Magnitude in dB - magnitude = np.abs(arr) - # Avoid log10(0) -- clamp to a very small value - magnitude = np.where(magnitude > 0, magnitude, 1e-30) - mag_db = 20.0 * np.log10(magnitude) - y_magnitude_db[v.name] = mag_db.tolist() - - # Phase in degrees - phase = np.degrees(np.angle(arr)) - y_phase_deg[v.name] = phase.tolist() - else: - for v in raw.variables[1:]: - arr = raw.data[v.index] - y_data[v.name] = arr.tolist() - - return WaveformData( - variables=variables, - points=raw.points, - x_data=x_data, - y_data=y_data, - x_type=x_type, - is_complex=is_complex, - y_magnitude_db=y_magnitude_db, - y_phase_deg=y_phase_deg, - ) diff --git a/backend/src/spicebook/engine/raw_convert.py b/backend/src/spicebook/engine/raw_convert.py new file mode 100644 index 0000000..9cef962 --- /dev/null +++ b/backend/src/spicebook/engine/raw_convert.py @@ -0,0 +1,96 @@ +"""Shared raw-file-to-waveform conversion for all SPICE engines. + +Both ngspice and mcltspice produce RawFile objects with the same structural +shape (data ndarray, variables list, flags, points). This module provides +a single converter that works with either via Protocol typing. +""" + +from collections.abc import Sequence +from typing import Protocol + +import numpy as np + +from spicebook.models.simulation import WaveformData, WaveformVariable + + +class RawVariable(Protocol): + @property + def index(self) -> int: ... + @property + def name(self) -> str: ... + @property + def type(self) -> str: ... + + +class RawFilelike(Protocol): + @property + def flags(self) -> Sequence[str]: ... + @property + def variables(self) -> Sequence[RawVariable]: ... + @property + def points(self) -> int: ... + @property + def data(self) -> np.ndarray: ... + + +def raw_to_waveform(raw: RawFilelike) -> WaveformData: + """Convert a parsed RawFile into a WaveformData response model. + + Works with any raw-file object that satisfies the RawFilelike protocol + (ngspice RawFile, mcltspice RawFile, etc.). + """ + if not raw.variables: + raise ValueError("Raw file contains no variables -- simulation produced no output") + if raw.data.shape[0] == 0 or (raw.data.ndim > 1 and raw.data.shape[1] == 0): + raise ValueError(f"Raw file data is empty (shape {raw.data.shape})") + + n_rows = raw.data.shape[0] + for v in raw.variables: + if v.index >= n_rows: + raise ValueError( + f"Variable '{v.name}' index {v.index} exceeds data rows ({n_rows})" + ) + + is_complex = "complex" in raw.flags + + # First variable is always the sweep axis + x_var = raw.variables[0] + x_type = "frequency" if "frequency" in x_var.type.lower() else "time" + + variables = [ + WaveformVariable(name=v.name, type=v.type) for v in raw.variables[1:] + ] + + x_array = raw.data[0] + x_data = np.real(x_array).tolist() if is_complex else x_array.tolist() + + y_data: dict[str, list[float]] = {} + y_magnitude_db: dict[str, list[float]] | None = None + y_phase_deg: dict[str, list[float]] | None = None + + if is_complex: + y_magnitude_db = {} + y_phase_deg = {} + + for v in raw.variables[1:]: + arr = raw.data[v.index] + y_data[v.name] = np.real(arr).tolist() + + magnitude = np.abs(arr) + magnitude = np.where(magnitude > 0, magnitude, 1e-30) + y_magnitude_db[v.name] = (20.0 * np.log10(magnitude)).tolist() + y_phase_deg[v.name] = np.degrees(np.angle(arr)).tolist() + else: + for v in raw.variables[1:]: + y_data[v.name] = raw.data[v.index].tolist() + + return WaveformData( + variables=variables, + points=raw.points, + x_data=x_data, + y_data=y_data, + x_type=x_type, + is_complex=is_complex, + y_magnitude_db=y_magnitude_db, + y_phase_deg=y_phase_deg, + ) diff --git a/backend/src/spicebook/main.py b/backend/src/spicebook/main.py index 6a6faa1..b9c48e6 100644 --- a/backend/src/spicebook/main.py +++ b/backend/src/spicebook/main.py @@ -23,6 +23,7 @@ logger = logging.getLogger("spicebook") async def lifespan(app: FastAPI): _configure_logging() _validate_ngspice() + _validate_ltspice() _ensure_directories() yield await close_client() @@ -119,6 +120,20 @@ def _validate_ngspice() -> None: ) +def _validate_ltspice() -> None: + """Check whether LTspice is available (never blocks startup).""" + try: + from mcltspice.config import validate_installation + + ok, msg = validate_installation() + if ok: + logger.info("LTspice engine available: %s", msg) + else: + logger.info("LTspice engine not available: %s", msg) + except ImportError: + logger.info("LTspice engine not available: mcltspice not installed") + + def _ensure_directories() -> None: """Create notebook directories if they don't exist.""" settings.notebook_dir.mkdir(parents=True, exist_ok=True) diff --git a/backend/tests/test_ltspice.py b/backend/tests/test_ltspice.py new file mode 100644 index 0000000..84f74fe --- /dev/null +++ b/backend/tests/test_ltspice.py @@ -0,0 +1,127 @@ +"""Integration tests for LtspiceEngine with RC circuits. + +Skipped automatically when mcltspice + Wine + LTspice aren't available +(e.g., Docker production, CI). +""" + +import tempfile +from pathlib import Path + +import pytest + +# Determine availability at import time for skip markers +_ltspice_available = False +try: + from mcltspice.config import validate_installation + + _ltspice_available, _ = validate_installation() +except ImportError: + pass + +pytestmark = pytest.mark.skipif( + not _ltspice_available, + reason="LTspice not available (mcltspice + Wine + LTspice.exe required)", +) + +from spicebook.engine.ltspice import LtspiceEngine # noqa: E402 + +RC_TRANSIENT_NETLIST = """\ +RC Low-Pass Filter - Transient +V1 in 0 PULSE(0 5 0 1n 1n 5m 10m) +R1 in out 1k +C1 out 0 1u +.tran 100u 20m +.end +""" + +RC_AC_NETLIST = """\ +RC Low-Pass Filter - AC +V1 in 0 AC 1 +R1 in out 1k +C1 out 0 1u +.ac dec 50 1 1Meg +.end +""" + + +@pytest.fixture +def engine(): + return LtspiceEngine() + + +@pytest.fixture +def work_dir(): + d = tempfile.mkdtemp(prefix="spicebook-ltspice-test-") + yield Path(d) + import shutil + + shutil.rmtree(d, ignore_errors=True) + + +@pytest.mark.asyncio +async def test_transient_simulation(engine, work_dir): + """Run a transient analysis via LTspice and verify waveform output.""" + result = await engine.run(RC_TRANSIENT_NETLIST, work_dir) + + assert result.success, f"Simulation failed: {result.error}\n{result.log}" + assert result.waveform is not None + assert result.waveform.x_type == "time" + assert result.waveform.points > 0 + assert len(result.waveform.x_data) == result.waveform.points + assert result.elapsed_seconds > 0 + + # Should have at least one signal in y_data + assert len(result.waveform.y_data) > 0 + + # Check that V(out) or similar exists + signal_names = list(result.waveform.y_data.keys()) + has_out = any("out" in name.lower() for name in signal_names) + assert has_out, f"Expected 'out' signal in {signal_names}" + + # Verify data length matches + for name, values in result.waveform.y_data.items(): + assert len(values) == result.waveform.points, f"Length mismatch for {name}" + + +@pytest.mark.asyncio +async def test_ac_simulation(engine, work_dir): + """Run an AC analysis via LTspice and verify complex waveform output.""" + result = await engine.run(RC_AC_NETLIST, work_dir) + + assert result.success, f"Simulation failed: {result.error}\n{result.log}" + assert result.waveform is not None + assert result.waveform.x_type == "frequency" + assert result.waveform.is_complex + assert result.waveform.points > 0 + + # AC analysis should produce magnitude and phase data + assert result.waveform.y_magnitude_db is not None + assert result.waveform.y_phase_deg is not None + assert len(result.waveform.y_magnitude_db) > 0 + assert len(result.waveform.y_phase_deg) > 0 + + +@pytest.mark.asyncio +async def test_netlist_without_end_directive(engine, work_dir): + """Engine should auto-append .end if missing.""" + netlist_no_end = """\ +Simple Resistor Divider +V1 in 0 DC 10 +R1 in out 1k +R2 out 0 1k +.tran 1u 1m +""" + result = await engine.run(netlist_no_end, work_dir) + assert result.success, f"Simulation failed: {result.error}\n{result.log}" + + +@pytest.mark.asyncio +async def test_invalid_netlist(engine, work_dir): + """Malformed netlist should return success=False with error info.""" + bad_netlist = """\ +This is not a valid SPICE netlist +.tran 1m 10m +.end +""" + result = await engine.run(bad_netlist, work_dir) + assert isinstance(result.success, bool) diff --git a/backend/uv.lock b/backend/uv.lock index 743de0c..8a5ecbf 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -92,10 +92,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -579,6 +585,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "mcltspice" +version = "2026.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp" }, + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/ef/2cadafe520e8b87a74c976dbc45c9a90a04d8b5364ded50b274d5c783232/mcltspice-2026.3.5-py3-none-any.whl", hash = "sha256:35107b8f98455805dbb10aaf5ee30441d9700268c018f487f4da79c61c8c5217", size = 111326, upload-time = "2026-03-05T21:38:32.895Z" }, +] + [[package]] name = "mcp" version = "1.26.0" @@ -1223,6 +1241,9 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, ] +ltspice = [ + { name = "mcltspice" }, +] [package.metadata] requires-dist = [ @@ -1230,6 +1251,7 @@ requires-dist = [ { name = "fastmcp", specifier = ">=3.0.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "mcltspice", marker = "extra == 'ltspice'", specifier = ">=2026.2.14" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, @@ -1239,7 +1261,7 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "websockets", specifier = ">=12.0" }, ] -provides-extras = ["dev"] +provides-extras = ["ltspice", "dev"] [[package]] name = "sse-starlette"