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:
parent
aafcff62b0
commit
896a8535cf
@ -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/*
|
||||
|
||||
@ -17,6 +17,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ltspice = ["mcltspice>=2026.02.14"]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
|
||||
@ -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}'")
|
||||
|
||||
98
backend/src/spicebook/engine/ltspice.py
Normal file
98
backend/src/spicebook/engine/ltspice.py
Normal 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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
96
backend/src/spicebook/engine/raw_convert.py
Normal file
96
backend/src/spicebook/engine/raw_convert.py
Normal 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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
127
backend/tests/test_ltspice.py
Normal file
127
backend/tests/test_ltspice.py
Normal 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
24
backend/uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user