spicebook/backend/tests/test_ngspice.py
Ryan Malloy 8abd7719bf Initial SpiceBook MVP: notebook interface for circuit simulation
Phase 1 implementation with ngspice backend and Astro/React frontend:

Backend (FastAPI):
- ngspice subprocess engine with custom .raw file parser
- Notebook CRUD with .spicebook JSON format (filesystem storage)
- Simulation endpoints (standalone + cell-in-notebook)
- SVG waveform export endpoint
- 18 REST API routes, 5 passing tests

Frontend (Astro 5 + React 19):
- Notebook editor as React island with Zustand state management
- CodeMirror 6 with custom SPICE language mode (syntax highlighting
  for dot commands, components, engineering notation, comments)
- uPlot waveform viewer with transient and AC/Bode plot modes
- Markdown cells with edit/preview toggle
- Notebook list with card grid UI
- Dark theme, Tailwind CSS 4, Lucide icons

Infrastructure:
- Docker Compose with dev/prod targets
- Caddy-based frontend prod serving
- 3 example notebooks (RC filter, voltage divider, CE amplifier)
2026-02-13 01:44:38 -07:00

151 lines
4.5 KiB
Python

"""Integration test for NgspiceEngine with a simple RC circuit."""
import shutil
import tempfile
from pathlib import Path
import pytest
from spicebook.engine.ngspice import NgspiceEngine, parse_ngspice_raw
# Skip the entire module if ngspice is not installed
pytestmark = pytest.mark.skipif(
shutil.which("ngspice") is None,
reason="ngspice not found on PATH",
)
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 NgspiceEngine()
@pytest.fixture
def work_dir():
d = tempfile.mkdtemp(prefix="spicebook-test-")
yield Path(d)
# Cleanup handled by OS for temp dirs; explicit cleanup optional
import shutil as _shutil
_shutil.rmtree(d, ignore_errors=True)
@pytest.mark.asyncio
async def test_transient_simulation(engine, work_dir):
"""Run a transient analysis on an RC circuit 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 and verify complex waveform output with mag/phase."""
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
.op
"""
# .op (operating point) does not produce a .raw file with waveforms,
# but the simulation should still run without error from a missing .end.
result = await engine.run(netlist_no_end, work_dir)
# .op may or may not produce a raw file depending on ngspice version,
# but it should not crash with a "missing .end" error
assert result.error is None or ".end" not in (result.error or "").lower()
@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)
# Should either fail or produce empty output -- not crash
# ngspice may still exit 0 for some "invalid" netlists, so we just
# verify we get a response at all
assert isinstance(result.success, bool)
def test_raw_parser_on_transient(work_dir):
"""Verify the raw parser handles a real ngspice output file."""
cir = work_dir / "test.cir"
cir.write_text(RC_TRANSIENT_NETLIST)
raw_path = work_dir / "output.raw"
import subprocess
proc = subprocess.run(
["ngspice", "-b", "-r", str(raw_path), str(cir)],
capture_output=True,
timeout=30,
)
assert raw_path.exists(), f"ngspice did not produce raw file. stderr: {proc.stderr.decode()}"
raw = parse_ngspice_raw(raw_path)
assert raw.points > 0
assert len(raw.variables) > 0
assert raw.data.shape[0] == len(raw.variables)
assert raw.data.shape[1] == raw.points
# First variable should be time
assert "time" in raw.variables[0].name.lower()