Add SpiceBook integration -- publish circuits as interactive notebooks
Bridge mcltspice to SpiceBook (spicebook.warehack.ing) with 5 new MCP tools (publish, list, get, delete, simulate), a status resource, and a guided publish prompt. Includes LTspice-to-ngspice netlist conversion (strips .backanno, Rser=, Windows paths) with structured warnings. Path traversal protection on file reads (extension allowlist + resolve), notebook ID validation, narrowed exception handling, and size limits. 35 unit tests + 4 live integration tests.
This commit is contained in:
parent
4c75c76284
commit
71dfdc8d94
@ -20,6 +20,23 @@ WINE_DEBUG = os.environ.get("WINE_DEBUG", "-all")
|
|||||||
DEFAULT_TIMEOUT = 300 # 5 minutes
|
DEFAULT_TIMEOUT = 300 # 5 minutes
|
||||||
MAX_RAW_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
MAX_RAW_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||||
|
|
||||||
|
# SpiceBook integration
|
||||||
|
SPICEBOOK_URL = os.environ.get("SPICEBOOK_URL", "https://spicebook.warehack.ing")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timeout(env_var: str, default: float) -> float:
|
||||||
|
raw = os.environ.get(env_var, "")
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
val = float(raw)
|
||||||
|
return val if val > 0 else default
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
SPICEBOOK_TIMEOUT = _parse_timeout("SPICEBOOK_TIMEOUT", 30.0)
|
||||||
|
|
||||||
|
|
||||||
def get_wine_env() -> dict[str, str]:
|
def get_wine_env() -> dict[str, str]:
|
||||||
"""Get environment variables for Wine execution."""
|
"""Get environment variables for Wine execution."""
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import math
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.prompts import Message
|
from fastmcp.prompts import Message
|
||||||
@ -73,6 +74,7 @@ from .batch import (
|
|||||||
from .config import (
|
from .config import (
|
||||||
LTSPICE_EXAMPLES,
|
LTSPICE_EXAMPLES,
|
||||||
LTSPICE_LIB,
|
LTSPICE_LIB,
|
||||||
|
SPICEBOOK_URL,
|
||||||
validate_installation,
|
validate_installation,
|
||||||
)
|
)
|
||||||
from .diff import diff_schematics as _diff_schematics
|
from .diff import diff_schematics as _diff_schematics
|
||||||
@ -113,6 +115,11 @@ from .power_analysis import compute_efficiency, compute_power_metrics
|
|||||||
from .raw_parser import parse_raw_file
|
from .raw_parser import parse_raw_file
|
||||||
from .runner import run_netlist, run_simulation
|
from .runner import run_netlist, run_simulation
|
||||||
from .schematic import modify_component_value, parse_schematic
|
from .schematic import modify_component_value, parse_schematic
|
||||||
|
from .spicebook import (
|
||||||
|
SpiceBookClient,
|
||||||
|
build_notebook_cells,
|
||||||
|
ltspice_to_ngspice,
|
||||||
|
)
|
||||||
from .stability import compute_stability_metrics
|
from .stability import compute_stability_metrics
|
||||||
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
|
from .svg_plot import plot_bode, plot_spectrum, plot_timeseries, plot_timeseries_multi
|
||||||
from .touchstone import parse_touchstone, s_param_to_db
|
from .touchstone import parse_touchstone, s_param_to_db
|
||||||
@ -155,9 +162,14 @@ mcp = FastMCP(
|
|||||||
- Run parameter sweeps, temperature sweeps, and Monte Carlo analysis
|
- Run parameter sweeps, temperature sweeps, and Monte Carlo analysis
|
||||||
- Handle stepped simulations: list runs, extract per-run data
|
- Handle stepped simulations: list runs, extract per-run data
|
||||||
- Parse Touchstone (.s2p) S-parameter files
|
- Parse Touchstone (.s2p) S-parameter files
|
||||||
|
- Publish circuits as interactive notebooks on SpiceBook
|
||||||
|
|
||||||
LTspice runs via Wine on Linux. Simulations execute in batch mode
|
LTspice runs via Wine on Linux. Simulations execute in batch mode
|
||||||
and results are parsed from binary .raw files.
|
and results are parsed from binary .raw files.
|
||||||
|
|
||||||
|
SpiceBook integration lets you share simulation results as runnable
|
||||||
|
notebooks at spicebook.warehack.ing. Use spicebook_publish to convert
|
||||||
|
an LTspice netlist to ngspice format and create a shareable notebook.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2842,6 +2854,266 @@ Common failure modes:
|
|||||||
""")]
|
""")]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SPICEBOOK INTEGRATION TOOLS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
_NETLIST_EXTENSIONS = {".cir", ".net", ".sp", ".spice", ".asc"}
|
||||||
|
_MAX_NETLIST_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
def _spicebook_error(e: Exception) -> dict:
|
||||||
|
"""Format a SpiceBook error into a consistent response dict."""
|
||||||
|
if isinstance(e, httpx.ConnectError):
|
||||||
|
return {
|
||||||
|
"error": f"SpiceBook not reachable at {SPICEBOOK_URL}",
|
||||||
|
"hint": "Check connectivity or set SPICEBOOK_URL env var",
|
||||||
|
}
|
||||||
|
if isinstance(e, httpx.TimeoutException):
|
||||||
|
return {"error": "SpiceBook request timed out"}
|
||||||
|
if isinstance(e, httpx.HTTPStatusError):
|
||||||
|
body = e.response.text[:500] if e.response else ""
|
||||||
|
return {"error": f"HTTP {e.response.status_code}", "detail": body}
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_netlist(
|
||||||
|
netlist_text: str | None, netlist_path: str | None
|
||||||
|
) -> tuple[str | None, dict | None]:
|
||||||
|
"""Resolve netlist from text or file path. Returns (text, error_dict)."""
|
||||||
|
if netlist_text and netlist_path:
|
||||||
|
return None, {"error": "Provide netlist_text or netlist_path, not both"}
|
||||||
|
if netlist_text:
|
||||||
|
if len(netlist_text) > _MAX_NETLIST_SIZE:
|
||||||
|
return None, {"error": f"Netlist text too large (max {_MAX_NETLIST_SIZE} bytes)"}
|
||||||
|
return netlist_text, None
|
||||||
|
if netlist_path:
|
||||||
|
p = Path(netlist_path).resolve()
|
||||||
|
if p.suffix.lower() not in _NETLIST_EXTENSIONS:
|
||||||
|
return None, {
|
||||||
|
"error": f"Unsupported file type '{p.suffix}'. "
|
||||||
|
f"Expected: {', '.join(sorted(_NETLIST_EXTENSIONS))}"
|
||||||
|
}
|
||||||
|
if not p.exists():
|
||||||
|
return None, {"error": f"File not found: {p}"}
|
||||||
|
try:
|
||||||
|
size = p.stat().st_size
|
||||||
|
if size > _MAX_NETLIST_SIZE:
|
||||||
|
return None, {"error": f"File too large ({size} bytes, max {_MAX_NETLIST_SIZE})"}
|
||||||
|
return p.read_text(encoding="utf-8"), None
|
||||||
|
except (OSError, UnicodeDecodeError) as e:
|
||||||
|
return None, {"error": f"Cannot read {p}: {e}"}
|
||||||
|
return None, {"error": "Provide netlist_text or netlist_path"}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def spicebook_publish(
|
||||||
|
netlist_text: str | None = None,
|
||||||
|
netlist_path: str | None = None,
|
||||||
|
title: str = "Untitled Circuit",
|
||||||
|
description: str | None = None,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Publish an LTspice netlist as an interactive SpiceBook notebook.
|
||||||
|
|
||||||
|
Converts the netlist from LTspice dialect to ngspice, builds
|
||||||
|
notebook cells, and creates the notebook on SpiceBook. Returns
|
||||||
|
the notebook ID and URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
|
||||||
|
netlist_path: Path to a .cir/.net file on disk
|
||||||
|
title: Notebook title
|
||||||
|
description: Optional description shown in the intro cell
|
||||||
|
tags: Optional list of tags for categorization
|
||||||
|
run: If True, SpiceBook runs the simulation on publish
|
||||||
|
"""
|
||||||
|
text, err = _read_netlist(netlist_text, netlist_path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert text is not None # _read_netlist guarantees text when err is None
|
||||||
|
|
||||||
|
converted, warnings = ltspice_to_ngspice(text)
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
netlist=converted,
|
||||||
|
warnings=warnings if warnings else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = SpiceBookClient()
|
||||||
|
try:
|
||||||
|
result = await client.compose(
|
||||||
|
title=title,
|
||||||
|
cells=cells,
|
||||||
|
tags=tags,
|
||||||
|
run=run,
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, OSError, ValueError) as e:
|
||||||
|
return _spicebook_error(e)
|
||||||
|
|
||||||
|
notebook_id = result.get("id", "")
|
||||||
|
return {
|
||||||
|
"notebook_id": notebook_id,
|
||||||
|
"url": f"{SPICEBOOK_URL}/notebooks/{notebook_id}",
|
||||||
|
"cell_count": len(cells),
|
||||||
|
"conversion_warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def spicebook_list() -> dict:
|
||||||
|
"""List all notebooks on SpiceBook.
|
||||||
|
|
||||||
|
Returns notebook summaries including id, title, tags, and modified date.
|
||||||
|
"""
|
||||||
|
client = SpiceBookClient()
|
||||||
|
try:
|
||||||
|
notebooks = await client.list_notebooks()
|
||||||
|
except (httpx.HTTPError, OSError) as e:
|
||||||
|
return _spicebook_error(e)
|
||||||
|
return {"notebooks": notebooks, "count": len(notebooks)}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def spicebook_get(
|
||||||
|
notebook_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Get a full SpiceBook notebook with all cells and outputs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID to retrieve
|
||||||
|
"""
|
||||||
|
client = SpiceBookClient()
|
||||||
|
try:
|
||||||
|
return await client.get_notebook(notebook_id)
|
||||||
|
except (httpx.HTTPError, OSError, ValueError) as e:
|
||||||
|
return _spicebook_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def spicebook_delete(
|
||||||
|
notebook_id: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a SpiceBook notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notebook_id: The notebook ID to delete
|
||||||
|
"""
|
||||||
|
client = SpiceBookClient()
|
||||||
|
try:
|
||||||
|
await client.delete_notebook(notebook_id)
|
||||||
|
except (httpx.HTTPError, OSError, ValueError) as e:
|
||||||
|
return _spicebook_error(e)
|
||||||
|
return {"deleted": True, "notebook_id": notebook_id}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def spicebook_simulate(
|
||||||
|
netlist_text: str | None = None,
|
||||||
|
netlist_path: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run a netlist via SpiceBook's ngspice engine without creating a notebook.
|
||||||
|
|
||||||
|
Useful for testing ngspice compatibility before publishing.
|
||||||
|
Converts the netlist from LTspice dialect to ngspice first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
netlist_text: Netlist as a string (mutually exclusive with netlist_path)
|
||||||
|
netlist_path: Path to a .cir/.net file on disk
|
||||||
|
"""
|
||||||
|
text, err = _read_netlist(netlist_text, netlist_path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert text is not None
|
||||||
|
|
||||||
|
converted, warnings = ltspice_to_ngspice(text)
|
||||||
|
|
||||||
|
client = SpiceBookClient()
|
||||||
|
try:
|
||||||
|
result = await client.simulate(converted)
|
||||||
|
except (httpx.HTTPError, OSError) as e:
|
||||||
|
return _spicebook_error(e)
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
result["conversion_warnings"] = warnings
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SPICEBOOK RESOURCE & PROMPT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("spicebook://status")
|
||||||
|
async def spicebook_status() -> str:
|
||||||
|
"""SpiceBook service status -- URL and reachability."""
|
||||||
|
client = SpiceBookClient()
|
||||||
|
ok = await client.health()
|
||||||
|
status = "reachable" if ok else "unreachable"
|
||||||
|
return f"SpiceBook URL: {SPICEBOOK_URL}\nStatus: {status}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def publish_to_spicebook(
|
||||||
|
circuit_description: str = "",
|
||||||
|
netlist_path: str = "",
|
||||||
|
) -> list:
|
||||||
|
"""Step-by-step guide for publishing a circuit to SpiceBook.
|
||||||
|
|
||||||
|
Walks through: verify locally in LTspice, test ngspice compatibility
|
||||||
|
via SpiceBook, then publish as an interactive notebook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
circuit_description: What circuit you want to publish
|
||||||
|
netlist_path: Path to the netlist or schematic
|
||||||
|
"""
|
||||||
|
path_note = (
|
||||||
|
f"Netlist/schematic: {netlist_path}"
|
||||||
|
if netlist_path
|
||||||
|
else "First, identify or create the netlist."
|
||||||
|
)
|
||||||
|
desc_note = (
|
||||||
|
f"Circuit: {circuit_description}"
|
||||||
|
if circuit_description
|
||||||
|
else "No description given -- describe the circuit for the notebook."
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Message(role="user", content=f"""Publish a circuit to SpiceBook as an interactive notebook.
|
||||||
|
|
||||||
|
{path_note}
|
||||||
|
{desc_note}
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. **Verify locally** -- simulate the circuit with LTspice:
|
||||||
|
- Use simulate or simulate_netlist to confirm it works
|
||||||
|
- Use get_waveform / analyze_waveform to check results
|
||||||
|
- Fix any issues before publishing
|
||||||
|
|
||||||
|
2. **Test ngspice compatibility** -- dry run on SpiceBook:
|
||||||
|
- Use spicebook_simulate with the netlist
|
||||||
|
- Check for conversion warnings (LTspice -> ngspice differences)
|
||||||
|
- If errors occur, adjust the netlist (remove LTspice-specific constructs)
|
||||||
|
|
||||||
|
3. **Publish** -- create the notebook:
|
||||||
|
- Use spicebook_publish with a descriptive title and tags
|
||||||
|
- Include a description explaining the circuit purpose and key specs
|
||||||
|
- Set run=True if you want SpiceBook to execute the simulation
|
||||||
|
|
||||||
|
4. **Verify** -- check the published notebook:
|
||||||
|
- Use spicebook_get to confirm the notebook was created
|
||||||
|
- Visit the URL to see the interactive version
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
- Add tags like "filter", "amplifier", "power" for discoverability
|
||||||
|
- The converter handles .backanno, Rser=, Windows paths automatically
|
||||||
|
- Use spicebook_list to see all published notebooks
|
||||||
|
- Use spicebook_delete to remove test notebooks
|
||||||
|
""")]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ENTRY POINT
|
# ENTRY POINT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
231
src/mcltspice/spicebook.py
Normal file
231
src/mcltspice/spicebook.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""SpiceBook integration -- publish LTspice circuits as interactive notebooks.
|
||||||
|
|
||||||
|
SpiceBook (spicebook.warehack.ing) is a web notebook service for SPICE circuits.
|
||||||
|
This module provides:
|
||||||
|
- SpiceBookClient: async HTTP client for the SpiceBook REST API
|
||||||
|
- ltspice_to_ngspice: best-effort netlist dialect conversion
|
||||||
|
- build_notebook_cells: assemble notebook cells for the compose endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import SPICEBOOK_TIMEOUT, SPICEBOOK_URL
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Netlist conversion: LTspice -> ngspice
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Directives that are LTspice-only and should be stripped (precomputed lowercase)
|
||||||
|
_LTSPICE_ONLY_DIRECTIVES_LOWER = {
|
||||||
|
".backanno",
|
||||||
|
".options plotwinsize=0",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Notebook IDs must be alphanumeric with dashes/underscores
|
||||||
|
_NOTEBOOK_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||||
|
|
||||||
|
# Regex for .lib/.include with Windows-style paths (backslashes or drive letters)
|
||||||
|
_WINDOWS_PATH_RE = re.compile(
|
||||||
|
r"^\s*\.(lib|include)\s+.*([A-Za-z]:\\|\\\\)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regex for Rser= on capacitor or inductor lines
|
||||||
|
_RSER_RE = re.compile(r"\s+Rser=\S+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def ltspice_to_ngspice(netlist_text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Convert an LTspice netlist to ngspice-compatible form.
|
||||||
|
|
||||||
|
Returns (cleaned_netlist, warnings) where warnings describe any
|
||||||
|
constructs that were modified or removed.
|
||||||
|
"""
|
||||||
|
lines = netlist_text.splitlines()
|
||||||
|
out: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Strip LTspice-only directives
|
||||||
|
if stripped.lower() in _LTSPICE_ONLY_DIRECTIVES_LOWER:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Comment out .lib/.include with Windows paths
|
||||||
|
if _WINDOWS_PATH_RE.match(line):
|
||||||
|
out.append(f"* {line} ; commented out -- Windows path")
|
||||||
|
warnings.append(f"Commented out Windows path: {stripped}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strip Rser= from component lines (C or L)
|
||||||
|
if stripped and stripped[0].upper() in ("C", "L") and "Rser=" in line:
|
||||||
|
cleaned = _RSER_RE.sub("", line)
|
||||||
|
out.append(cleaned)
|
||||||
|
warnings.append(f"Stripped Rser= from: {stripped}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
return "\n".join(out), warnings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cell builder for SpiceBook compose endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_notebook_cells(
|
||||||
|
title: str,
|
||||||
|
description: str | None,
|
||||||
|
netlist: str,
|
||||||
|
warnings: list[str] | None = None,
|
||||||
|
analysis: dict | None = None,
|
||||||
|
svgs: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build the cell list for SpiceBook's POST /api/notebooks/compose.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Notebook title (also used in the intro cell heading).
|
||||||
|
description: Optional circuit description.
|
||||||
|
netlist: ngspice-compatible netlist text.
|
||||||
|
warnings: Conversion warnings from ltspice_to_ngspice.
|
||||||
|
analysis: Optional dict of analysis results (rendered as a table).
|
||||||
|
svgs: Optional list of SVG strings to embed as images.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cell dicts ready for the compose endpoint.
|
||||||
|
"""
|
||||||
|
cells: list[dict] = []
|
||||||
|
|
||||||
|
# 1. Intro markdown cell
|
||||||
|
intro_parts = [f"# {title}"]
|
||||||
|
if description:
|
||||||
|
intro_parts.append(description)
|
||||||
|
if warnings:
|
||||||
|
intro_parts.append("### Conversion notes")
|
||||||
|
for w in warnings:
|
||||||
|
intro_parts.append(f"- {w}")
|
||||||
|
cells.append({"type": "markdown", "source": "\n\n".join(intro_parts)})
|
||||||
|
|
||||||
|
# 2. SPICE netlist cell
|
||||||
|
cells.append({"type": "spice", "source": netlist})
|
||||||
|
|
||||||
|
# 3. Optional analysis results as markdown table
|
||||||
|
if analysis:
|
||||||
|
rows = ["| Metric | Value |", "|--------|-------|"]
|
||||||
|
for key, val in analysis.items():
|
||||||
|
rows.append(f"| {key} | {val} |")
|
||||||
|
cells.append({"type": "markdown", "source": "\n".join(rows)})
|
||||||
|
|
||||||
|
# 4. Optional SVG plot cells
|
||||||
|
if svgs:
|
||||||
|
for i, svg in enumerate(svgs):
|
||||||
|
label = f"### Plot {i + 1}" if len(svgs) > 1 else "### Plot"
|
||||||
|
cells.append({"type": "markdown", "source": f"{label}\n\n{svg}"})
|
||||||
|
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SpiceBook async HTTP client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SpiceBookClient:
|
||||||
|
"""Thin async wrapper around the SpiceBook REST API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = SPICEBOOK_URL,
|
||||||
|
timeout: float = SPICEBOOK_TIMEOUT,
|
||||||
|
):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _client(self) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health(self) -> bool:
|
||||||
|
"""Check if SpiceBook is reachable. Returns False on any network error."""
|
||||||
|
try:
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.get("/health")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except (httpx.HTTPError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_notebook_id(notebook_id: str) -> str | None:
|
||||||
|
"""Return an error message if notebook_id is unsafe for URL interpolation."""
|
||||||
|
if not notebook_id or not _NOTEBOOK_ID_RE.match(notebook_id):
|
||||||
|
return f"Invalid notebook_id: expected alphanumeric/dash/underscore, got '{notebook_id[:80]}'"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def compose(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
cells: list[dict],
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
run: bool = False,
|
||||||
|
engine: str = "ngspice",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a notebook with cells in one atomic call."""
|
||||||
|
payload: dict = {
|
||||||
|
"title": title,
|
||||||
|
"cells": cells,
|
||||||
|
"engine": engine,
|
||||||
|
}
|
||||||
|
if tags:
|
||||||
|
payload["tags"] = tags
|
||||||
|
if run:
|
||||||
|
payload["run"] = True
|
||||||
|
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.post("/api/notebooks/compose", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def list_notebooks(self) -> list[dict]:
|
||||||
|
"""List all notebooks."""
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.get("/api/notebooks")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def get_notebook(self, notebook_id: str) -> dict:
|
||||||
|
"""Get full notebook by ID."""
|
||||||
|
err = self._validate_notebook_id(notebook_id)
|
||||||
|
if err:
|
||||||
|
raise ValueError(err)
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.get(f"/api/notebooks/{notebook_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def delete_notebook(self, notebook_id: str) -> bool:
|
||||||
|
"""Delete a notebook. Returns True on success."""
|
||||||
|
err = self._validate_notebook_id(notebook_id)
|
||||||
|
if err:
|
||||||
|
raise ValueError(err)
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.delete(f"/api/notebooks/{notebook_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def simulate(
|
||||||
|
self,
|
||||||
|
netlist: str,
|
||||||
|
engine: str = "ngspice",
|
||||||
|
) -> dict:
|
||||||
|
"""Run a stateless simulation without creating a notebook."""
|
||||||
|
async with self._client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/simulate",
|
||||||
|
json={"netlist": netlist, "engine": engine},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
361
tests/test_spicebook.py
Normal file
361
tests/test_spicebook.py
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
"""Tests for SpiceBook integration (converter, cell builder, and live API)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcltspice.server import _read_netlist
|
||||||
|
from mcltspice.spicebook import (
|
||||||
|
SpiceBookClient,
|
||||||
|
build_notebook_cells,
|
||||||
|
ltspice_to_ngspice,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ltspice_to_ngspice converter tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLtspiceToNgspice:
|
||||||
|
"""Unit tests for the netlist dialect converter."""
|
||||||
|
|
||||||
|
def test_strips_backanno(self):
|
||||||
|
netlist = "V1 in 0 AC 1\n.backanno\n.end\n"
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert ".backanno" not in result
|
||||||
|
assert ".end" in result
|
||||||
|
|
||||||
|
def test_strips_plotwinsize(self):
|
||||||
|
netlist = "R1 a b 1k\n.options plotwinsize=0\n.end\n"
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert "plotwinsize" not in result
|
||||||
|
|
||||||
|
def test_comments_out_windows_lib(self):
|
||||||
|
netlist = '.lib C:\\LTspice\\lib\\cmp\\standard.bjt\n.end\n'
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert result.startswith("* .lib")
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "Windows path" in warnings[0]
|
||||||
|
|
||||||
|
def test_comments_out_windows_include(self):
|
||||||
|
netlist = '.include C:\\Users\\test\\model.sub\n.end\n'
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert "* .include" in result
|
||||||
|
assert len(warnings) == 1
|
||||||
|
|
||||||
|
def test_strips_rser_from_capacitor(self):
|
||||||
|
netlist = "C1 out 0 100n Rser=0.1\n.end\n"
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert "Rser" not in result
|
||||||
|
assert "C1 out 0 100n" in result
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "Rser" in warnings[0]
|
||||||
|
|
||||||
|
def test_strips_rser_from_inductor(self):
|
||||||
|
netlist = "L1 a b 10u Rser=0.5\n.end\n"
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert "Rser" not in result
|
||||||
|
assert "L1 a b 10u" in result
|
||||||
|
|
||||||
|
def test_preserves_standard_spice(self):
|
||||||
|
netlist = (
|
||||||
|
"* RC Lowpass\n"
|
||||||
|
"V1 in 0 AC 1\n"
|
||||||
|
"R1 in out 1k\n"
|
||||||
|
"C1 out 0 100n\n"
|
||||||
|
".ac dec 100 1 1meg\n"
|
||||||
|
".end\n"
|
||||||
|
)
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert result == netlist.rstrip("\n")
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
def test_multiple_conversions(self):
|
||||||
|
netlist = (
|
||||||
|
"V1 in 0 AC 1\n"
|
||||||
|
"C1 out 0 100n Rser=0.1\n"
|
||||||
|
".backanno\n"
|
||||||
|
".lib C:\\LTspice\\lib\\standard.bjt\n"
|
||||||
|
".options plotwinsize=0\n"
|
||||||
|
".end\n"
|
||||||
|
)
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert ".backanno" not in result
|
||||||
|
assert "plotwinsize" not in result
|
||||||
|
assert "Rser" not in result
|
||||||
|
assert result.count("*") >= 1 # at least the .lib line commented
|
||||||
|
assert len(warnings) == 2 # Rser + Windows path (backanno/plotwinsize stripped silently)
|
||||||
|
|
||||||
|
def test_empty_netlist(self):
|
||||||
|
result, warnings = ltspice_to_ngspice("")
|
||||||
|
assert result == ""
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
def test_case_insensitive_backanno(self):
|
||||||
|
netlist = ".BACKANNO\n.end\n"
|
||||||
|
result, warnings = ltspice_to_ngspice(netlist)
|
||||||
|
assert "BACKANNO" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_notebook_cells tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildNotebookCells:
|
||||||
|
"""Unit tests for the cell builder."""
|
||||||
|
|
||||||
|
def test_minimal_cells(self):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Test",
|
||||||
|
description=None,
|
||||||
|
netlist="V1 in 0 AC 1\n.end",
|
||||||
|
)
|
||||||
|
assert len(cells) == 2
|
||||||
|
assert cells[0]["type"] == "markdown"
|
||||||
|
assert "# Test" in cells[0]["source"]
|
||||||
|
assert cells[1]["type"] == "spice"
|
||||||
|
assert "V1 in 0 AC 1" in cells[1]["source"]
|
||||||
|
|
||||||
|
def test_with_description(self):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="RC Filter",
|
||||||
|
description="A simple RC lowpass filter.",
|
||||||
|
netlist=".end",
|
||||||
|
)
|
||||||
|
assert "A simple RC lowpass filter." in cells[0]["source"]
|
||||||
|
|
||||||
|
def test_with_warnings(self):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Test",
|
||||||
|
description=None,
|
||||||
|
netlist=".end",
|
||||||
|
warnings=["Stripped Rser= from C1", "Commented out Windows path"],
|
||||||
|
)
|
||||||
|
intro = cells[0]["source"]
|
||||||
|
assert "Conversion notes" in intro
|
||||||
|
assert "Stripped Rser" in intro
|
||||||
|
assert "Windows path" in intro
|
||||||
|
|
||||||
|
def test_with_analysis(self):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Test",
|
||||||
|
description=None,
|
||||||
|
netlist=".end",
|
||||||
|
analysis={"Bandwidth": "1.59 kHz", "Gain": "-20 dB"},
|
||||||
|
)
|
||||||
|
assert len(cells) == 3
|
||||||
|
table_cell = cells[2]
|
||||||
|
assert table_cell["type"] == "markdown"
|
||||||
|
assert "Bandwidth" in table_cell["source"]
|
||||||
|
assert "1.59 kHz" in table_cell["source"]
|
||||||
|
|
||||||
|
def test_with_svgs(self):
|
||||||
|
svg1 = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||||
|
svg2 = '<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>'
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Test",
|
||||||
|
description=None,
|
||||||
|
netlist=".end",
|
||||||
|
svgs=[svg1, svg2],
|
||||||
|
)
|
||||||
|
# intro + spice + 2 svg cells
|
||||||
|
assert len(cells) == 4
|
||||||
|
assert "Plot 1" in cells[2]["source"]
|
||||||
|
assert "Plot 2" in cells[3]["source"]
|
||||||
|
assert "<rect/>" in cells[2]["source"]
|
||||||
|
|
||||||
|
def test_single_svg_no_number(self):
|
||||||
|
svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Test",
|
||||||
|
description=None,
|
||||||
|
netlist=".end",
|
||||||
|
svgs=[svg],
|
||||||
|
)
|
||||||
|
assert len(cells) == 3
|
||||||
|
assert "### Plot\n" in cells[2]["source"]
|
||||||
|
assert "Plot 1" not in cells[2]["source"]
|
||||||
|
|
||||||
|
def test_full_build(self):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="RC Lowpass Filter",
|
||||||
|
description="First-order RC filter with fc = 1.59 kHz",
|
||||||
|
netlist="V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 100n\n.ac dec 100 1 1meg\n.end",
|
||||||
|
warnings=["Stripped Rser= from C1"],
|
||||||
|
analysis={"Bandwidth (-3dB)": "1.59 kHz"},
|
||||||
|
svgs=["<svg>...</svg>"],
|
||||||
|
)
|
||||||
|
# intro + spice + analysis + 1 svg = 4 cells
|
||||||
|
assert len(cells) == 4
|
||||||
|
assert cells[0]["type"] == "markdown"
|
||||||
|
assert cells[1]["type"] == "spice"
|
||||||
|
assert cells[2]["type"] == "markdown"
|
||||||
|
assert cells[3]["type"] == "markdown"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SpiceBookClient unit tests (no network)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpiceBookClient:
|
||||||
|
"""Test client construction, defaults, and input validation."""
|
||||||
|
|
||||||
|
def test_default_url(self):
|
||||||
|
client = SpiceBookClient()
|
||||||
|
assert client.base_url == "https://spicebook.warehack.ing"
|
||||||
|
|
||||||
|
def test_custom_url(self):
|
||||||
|
client = SpiceBookClient(base_url="http://localhost:8000/")
|
||||||
|
assert client.base_url == "http://localhost:8000"
|
||||||
|
|
||||||
|
def test_custom_timeout(self):
|
||||||
|
client = SpiceBookClient(timeout=60)
|
||||||
|
assert client.timeout == 60
|
||||||
|
|
||||||
|
def test_validate_notebook_id_valid(self):
|
||||||
|
assert SpiceBookClient._validate_notebook_id("rc-lowpass-abc123") is None
|
||||||
|
assert SpiceBookClient._validate_notebook_id("test_notebook") is None
|
||||||
|
assert SpiceBookClient._validate_notebook_id("a") is None
|
||||||
|
|
||||||
|
def test_validate_notebook_id_rejects_traversal(self):
|
||||||
|
err = SpiceBookClient._validate_notebook_id("../admin")
|
||||||
|
assert err is not None
|
||||||
|
assert "Invalid" in err
|
||||||
|
|
||||||
|
def test_validate_notebook_id_rejects_slashes(self):
|
||||||
|
err = SpiceBookClient._validate_notebook_id("foo/../../bar")
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
def test_validate_notebook_id_rejects_empty(self):
|
||||||
|
err = SpiceBookClient._validate_notebook_id("")
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
async def test_get_notebook_rejects_bad_id(self):
|
||||||
|
client = SpiceBookClient()
|
||||||
|
with pytest.raises(ValueError, match="Invalid notebook_id"):
|
||||||
|
await client.get_notebook("../admin")
|
||||||
|
|
||||||
|
async def test_delete_notebook_rejects_bad_id(self):
|
||||||
|
client = SpiceBookClient()
|
||||||
|
with pytest.raises(ValueError, match="Invalid notebook_id"):
|
||||||
|
await client.delete_notebook("foo/bar")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _read_netlist validation tests (server-level helper)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadNetlist:
|
||||||
|
"""Tests for the _read_netlist path validation."""
|
||||||
|
|
||||||
|
def test_rejects_both_text_and_path(self):
|
||||||
|
_, err = _read_netlist("some text", "/some/path.cir")
|
||||||
|
assert err is not None
|
||||||
|
assert "not both" in err["error"]
|
||||||
|
|
||||||
|
def test_rejects_neither(self):
|
||||||
|
_, err = _read_netlist(None, None)
|
||||||
|
assert err is not None
|
||||||
|
|
||||||
|
def test_accepts_text(self):
|
||||||
|
text, err = _read_netlist("V1 in 0 AC 1\n.end", None)
|
||||||
|
assert err is None
|
||||||
|
assert "V1" in text
|
||||||
|
|
||||||
|
def test_rejects_bad_extension(self):
|
||||||
|
_, err = _read_netlist(None, "/etc/passwd")
|
||||||
|
assert err is not None
|
||||||
|
assert "Unsupported file type" in err["error"]
|
||||||
|
|
||||||
|
def test_rejects_shadow_file(self):
|
||||||
|
_, err = _read_netlist(None, "/etc/shadow")
|
||||||
|
assert err is not None
|
||||||
|
assert "Unsupported file type" in err["error"]
|
||||||
|
|
||||||
|
def test_rejects_nonexistent_file(self, tmp_path):
|
||||||
|
path = str(tmp_path / "missing.cir")
|
||||||
|
_, err = _read_netlist(None, path)
|
||||||
|
assert err is not None
|
||||||
|
assert "not found" in err["error"]
|
||||||
|
|
||||||
|
def test_reads_valid_cir_file(self, tmp_path):
|
||||||
|
cir = tmp_path / "test.cir"
|
||||||
|
cir.write_text("V1 in 0 AC 1\n.end\n")
|
||||||
|
text, err = _read_netlist(None, str(cir))
|
||||||
|
assert err is None
|
||||||
|
assert "V1" in text
|
||||||
|
|
||||||
|
def test_reads_valid_net_file(self, tmp_path):
|
||||||
|
net = tmp_path / "test.net"
|
||||||
|
net.write_text("R1 a b 1k\n.end\n")
|
||||||
|
text, err = _read_netlist(None, str(net))
|
||||||
|
assert err is None
|
||||||
|
assert "R1" in text
|
||||||
|
|
||||||
|
def test_rejects_traversal_with_bad_extension(self):
|
||||||
|
_, err = _read_netlist(None, "/tmp/../../etc/passwd")
|
||||||
|
assert err is not None
|
||||||
|
assert "Unsupported file type" in err["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests (require live SpiceBook)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSpiceBookIntegration:
|
||||||
|
"""Live API tests against spicebook.warehack.ing.
|
||||||
|
|
||||||
|
Run with: uv run pytest tests/test_spicebook.py -m integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return SpiceBookClient()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rc_netlist(self):
|
||||||
|
return (
|
||||||
|
"* RC Lowpass Filter\n"
|
||||||
|
"V1 in 0 AC 1\n"
|
||||||
|
"R1 in out 1k\n"
|
||||||
|
"C1 out 0 100n\n"
|
||||||
|
".ac dec 100 1 1meg\n"
|
||||||
|
".end\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_health(self, client):
|
||||||
|
assert await client.health() is True
|
||||||
|
|
||||||
|
async def test_compose_and_get_and_delete(self, client, rc_netlist):
|
||||||
|
cells = build_notebook_cells(
|
||||||
|
title="Integration Test - RC Lowpass",
|
||||||
|
description="Auto-generated by mcltspice test suite",
|
||||||
|
netlist=rc_netlist,
|
||||||
|
)
|
||||||
|
result = await client.compose(
|
||||||
|
title="Integration Test - RC Lowpass",
|
||||||
|
cells=cells,
|
||||||
|
tags=["test", "mcltspice", "auto"],
|
||||||
|
)
|
||||||
|
notebook_id = result.get("id")
|
||||||
|
if not notebook_id:
|
||||||
|
pytest.fail(f"compose returned no 'id': {result}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify we can retrieve it (title is under metadata)
|
||||||
|
notebook = await client.get_notebook(notebook_id)
|
||||||
|
assert notebook["metadata"]["title"] == "Integration Test - RC Lowpass"
|
||||||
|
assert len(notebook["cells"]) >= 2
|
||||||
|
finally:
|
||||||
|
await client.delete_notebook(notebook_id)
|
||||||
|
|
||||||
|
async def test_list_notebooks(self, client):
|
||||||
|
notebooks = await client.list_notebooks()
|
||||||
|
assert isinstance(notebooks, list)
|
||||||
|
|
||||||
|
async def test_simulate(self, client, rc_netlist):
|
||||||
|
result = await client.simulate(rc_netlist)
|
||||||
|
assert isinstance(result, dict)
|
||||||
2
uv.lock
generated
2
uv.lock
generated
@ -1028,7 +1028,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcltspice"
|
name = "mcltspice"
|
||||||
version = "2026.2.14"
|
version = "2026.2.14.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user