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)
151 lines
4.5 KiB
Python
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()
|