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)
This commit is contained in:
Ryan Malloy 2026-03-05 15:06:41 -07:00
parent aafcff62b0
commit 896a8535cf
9 changed files with 396 additions and 68 deletions

View File

@ -1,6 +1,9 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
# System dependencies -- ngspice is required for simulation # 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 && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ngspice && \ apt-get install -y --no-install-recommends ngspice && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View File

@ -17,6 +17,7 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
ltspice = ["mcltspice>=2026.02.14"]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",

View File

@ -1,15 +1,45 @@
"""SPICE simulation engine registry.""" """SPICE simulation engine registry."""
import logging
from spicebook.engine.base import SpiceEngine from spicebook.engine.base import SpiceEngine
from spicebook.engine.ngspice import NgspiceEngine from spicebook.engine.ngspice import NgspiceEngine
logger = logging.getLogger(__name__)
class UnsupportedEngineError(ValueError): class UnsupportedEngineError(ValueError):
"""Raised when an unknown engine name is requested.""" """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: def get_engine(engine_name: str) -> SpiceEngine:
"""Resolve a simulation engine by name.""" """Resolve a simulation engine by name."""
if engine_name == "ngspice": if engine_name == "ngspice":
return NgspiceEngine() 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}'") raise UnsupportedEngineError(f"Unsupported engine: '{engine_name}'")

View File

@ -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,
)

View File

@ -11,11 +11,8 @@ import numpy as np
from spicebook.config import settings from spicebook.config import settings
from spicebook.engine.base import SpiceEngine from spicebook.engine.base import SpiceEngine
from spicebook.models.simulation import ( from spicebook.engine.raw_convert import raw_to_waveform
SimulationResponse, from spicebook.models.simulation import SimulationResponse
WaveformData,
WaveformVariable,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -300,7 +297,7 @@ class NgspiceEngine(SpiceEngine):
elapsed_seconds=time.monotonic() - t0, elapsed_seconds=time.monotonic() - t0,
) )
waveform = _raw_to_waveform(raw) waveform = raw_to_waveform(raw)
return SimulationResponse( return SimulationResponse(
success=True, 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: if "error" in lower or "fatal" in lower or "cannot" in lower:
error_lines.append(line.strip()) error_lines.append(line.strip())
return "\n".join(error_lines) if error_lines else "" 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,
)

View File

@ -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,
)

View File

@ -23,6 +23,7 @@ logger = logging.getLogger("spicebook")
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
_configure_logging() _configure_logging()
_validate_ngspice() _validate_ngspice()
_validate_ltspice()
_ensure_directories() _ensure_directories()
yield yield
await close_client() 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: def _ensure_directories() -> None:
"""Create notebook directories if they don't exist.""" """Create notebook directories if they don't exist."""
settings.notebook_dir.mkdir(parents=True, exist_ok=True) settings.notebook_dir.mkdir(parents=True, exist_ok=True)

View File

@ -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)

24
backend/uv.lock generated
View File

@ -92,10 +92,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db
wheels = [ 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/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/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/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/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/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/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" }, { 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" }, { 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]] [[package]]
name = "mcp" name = "mcp"
version = "1.26.0" version = "1.26.0"
@ -1223,6 +1241,9 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
] ]
ltspice = [
{ name = "mcltspice" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
@ -1230,6 +1251,7 @@ requires-dist = [
{ name = "fastmcp", specifier = ">=3.0.0" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "mcltspice", marker = "extra == 'ltspice'", specifier = ">=2026.2.14" },
{ name = "numpy", specifier = ">=1.24.0" }, { name = "numpy", specifier = ">=1.24.0" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@ -1239,7 +1261,7 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
{ name = "websockets", specifier = ">=12.0" }, { name = "websockets", specifier = ">=12.0" },
] ]
provides-extras = ["dev"] provides-extras = ["ltspice", "dev"]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"