User Guide
Installation
python3 -m venv .venv
source .venv/bin/activate
pip install maturin numpy pytest
maturin develop
Or, with Nix: nix develop.
External tools, by feature:
| Tool | Needed for |
|---|---|
ngspice | Default backend; most features (XSPICE, OSDI, control blocks) |
xyce / ltspice / spectre / vacask | Alternative backends |
openvaf | Compiling Verilog-A to OSDI (veriloga(), compile_veriloga()) |
iverilog | Digital Verilog co-simulation |
ciel + PDK_ROOT | Installing open PDKs (sky130, gf180mcu) |
Core concepts
Three objects do most of the work:
| Class | Role |
|---|---|
Subcircuit(name, ports) | The design under test (DUT). Holds elements, models, parameters. Reusable and nestable. |
Testbench(dut) | Wraps a DUT with stimulus sources, simulator options, backend selection, and analysis methods. |
Circuit(title) | Flat, PySpice-style alternative: build a whole circuit, then call .simulator() for a CircuitSimulator with the same analysis methods. |
import pyspice_rs as ps
from pyspice_rs.unit import u_V, u_kOhm
dut = ps.Subcircuit("divider", ["vin", "vout"]) # ports
dut.R(name="top", positive="vin", negative="vout", value=2 @ u_kOhm)
dut.R(name="bot", positive="vout", negative=dut.gnd, value=1 @ u_kOhm)
tb = ps.Testbench(dut)
tb.V(name="supply", positive="vin", negative=dut.gnd, value=10 @ u_V)
tb.with_backend("ngspice")
op = tb.operating_point()
Ground is dut.gnd / circuit.gnd (node "0").
All element methods take keyword arguments. str(circuit)
returns the generated netlist.
Units
PySpice-style units live in pyspice_rs.unit and combine with
the @ operator:
from pyspice_rs.unit import u_V, u_kOhm, u_pF, u_uH
r = 10 @ u_kOhm # UnitValue, 10000.0 in SI
c = 100 @ u_pF
r.value # 10000.0 (SI base units)
r.str_spice() # "10k" (netlist form)
float(r) # 10000.0
Available: voltage (u_V, u_mV, u_uV), current
(u_A, u_mA, u_uA, u_nA), resistance
(u_Ohm, u_kOhm, u_MOhm), capacitance
(u_F … u_fF), inductance (u_H … u_nH), frequency
(u_Hz … u_GHz), time (u_s … u_ps), power
(u_W, u_mW, u_uW), u_Degree. Unicode aliases for
PySpice compatibility: u_Ω, u_kΩ, u_MΩ,
u_µF, u_µH, u_µA, u_µV,
u_µW, u_µs.
Plain floats are accepted everywhere a UnitValue is:
value=10000.0 means 10 kΩ.
Adding elements
Element methods mirror SPICE element letters and exist on both
Circuit and Subcircuit (and stimulus methods on
Testbench). All arguments are keyword-only.
Passives
dut.R(name="1", positive="a", negative="b", value=1 @ u_kOhm)
dut.C(name="1", positive="a", negative="b", value=10 @ u_pF)
dut.L(name="1", positive="a", negative="b", value=10 @ u_uH)
dut.K(name="xfmr", inductor1="Lpri", inductor2="Lsec", coupling=0.9) # mutual inductance
Independent sources
tb.V(name="dd", positive="vdd", negative=dut.gnd, value=3.3 @ u_V)
tb.I(name="bias", positive="n1", negative=dut.gnd, value=200e-6)
# AC magnitude for small-signal analyses:
tb.V(name="in", positive="vin", negative=dut.gnd, value=0.0, ac=1.0)
# Waveform builders:
tb.SinusoidalVoltageSource(name="sig", positive="vin", negative=dut.gnd,
amplitude=0.01, frequency=1e3, dc_offset=0.0, offset=0.0)
tb.PulseVoltageSource(name="clk", positive="clk", negative=dut.gnd,
initial_value=0.0, pulsed_value=3.3, pulse_width=25e-9, period=50e-9,
rise_time=0.5e-9, fall_time=0.5e-9)
tb.PieceWiseLinearVoltageSource(name="dac", positive="dac", negative=dut.gnd,
values=[(0.0, 0.0), (1e-6, 0.4125), (2e-6, 0.825)]) # (time, value) pairs
# Current-source variants: SinusoidalCurrentSource, PulseCurrentSource
Behavioral sources
# Expression syntax follows SPICE B-sources: V(node), I(Vname), arithmetic
dut.BV(name="opamp", positive="out", negative=dut.gnd,
expression="1e6 * (V(inp) - V(inn))")
dut.BI(name="isq", positive="a", negative="b", expression="V(c)*V(c)*1e-3")
Controlled sources
dut.E(name="1", positive="out", negative=dut.gnd, # VCVS
control_positive="in", control_negative=dut.gnd, voltage_gain=5.0)
dut.G(name="1", positive="out", negative=dut.gnd, # VCCS
control_positive="in", control_negative=dut.gnd, transconductance=1e-3)
dut.F(name="1", positive="out", negative=dut.gnd, # CCCS
vsense="Vsense", current_gain=3.0)
dut.H(name="1", positive="out", negative=dut.gnd, # CCVS
vsense="Vsense", transresistance=2000.0)
Current-controlled sources sense the current through a named voltage
source — add a 0 V source in the branch to measure:
dut.V(name="sense", positive="a", negative="b", value=0.0).
Semiconductors
dut.D(name="1", anode="a", cathode="k", model="1N4148")
dut.Q(name="1", collector="c", base="b", emitter="e", model="2N2222") # alias: BJT
dut.M(name="1", drain="d", gate="g", source="s", bulk="b", model="NMOS_3V3") # alias: MOSFET
dut.J(name="1", drain="d", gate="g", source="s", model="2N5457") # JFET
dut.Z(name="1", drain="d", gate="g", source="s", model="MESFET1") # MESFET
Switches, transmission lines
dut.S(name="1", positive="a", negative="b", # voltage-controlled
control_positive="ctl", control_negative=dut.gnd, model="SW_VCTL")
dut.W(name="1", positive="a", negative="b", # current-controlled
vcontrol="Vsense", model="SW_ICTL")
dut.T(name="line", input_positive="in", input_negative=dut.gnd,
output_positive="out", output_negative=dut.gnd, Z0=50.0, TD=1e-9)
Device models
dut.model("2N2222", "NPN", IS=14.34e-15, BF=255.9, VAF=74.03,
RB=10.0, RC=1.0, CJE=22.01e-12, CJC=7.306e-12, TF=0.4111e-9)
dut.model("NMOS_3V3", "NMOS", LEVEL=1, VTO=0.7, KP=110e-6, LAMBDA=0.04)
dut.model("SW_VCTL", "SW", VT=2.5, VH=0.5, RON=1.0, ROFF=1e6)
Other circuit-level directives:
dut.include("models.lib") # .include
dut.lib("sky130.lib.spice", "tt") # .lib with section
dut.parameter("rload", "10k") # .param
dut.options(scale="1e-6") # .options
dut.temp(85.0) # .temp
dut.raw_spice("R99 a b 1k") # raw netlist pass-through
Subcircuits and hierarchy
inverter = ps.Subcircuit("inverter", ["vdd", "in", "out"])
inverter.model("NMOS_INV", "NMOS", LEVEL=1, VTO=0.7, KP=110e-6)
inverter.R(name="rup", positive="vdd", negative="out", value=10 @ u_kOhm)
inverter.M(name="n1", drain="out", gate="in", source=inverter.gnd,
bulk=inverter.gnd, model="NMOS_INV")
dut = ps.Subcircuit("buffer", ["vdd", "a", "output"])
dut.X("inv1", "inverter", "vdd", "a", "mid") # instantiate
dut.X("inv2", "inverter", "vdd", "mid", "output")
tb = ps.Testbench(dut)
tb.add_subcircuit(inverter) # register the definition
# ... sources, backend, analysis
On a flat Circuit, use circuit.subcircuit(subckt)
to add the definition and circuit.X(...) to instantiate.
Running analyses
Analysis methods are identical on Testbench and
CircuitSimulator.
Operating point
op = tb.operating_point()
vout = op["vout"] # node voltage as float
DC sweep
dc = tb.dc(Vin=slice(0, 5, 0.1)) # sweep source Vin from 0 to 5 V in 0.1 V steps
x = dc.sweep # sweep axis (numpy)
y = dc["vout"] # swept node voltage (numpy)
AC
# Source needs an AC magnitude: tb.V(..., value=0.0, ac=1.0)
ac = tb.ac(variation="dec", number_of_points=100,
start_frequency=1.0, stop_frequency=1e6)
freqs = ac.frequency
mag = ac["vout"]
Transient
tran = tb.transient(step_time=10e-6, end_time=5e-3)
t = tran.time
v = tran["vout"]
# Optional: start_time, max_time, use_initial_condition=True
Small-signal and specialty
tb.noise(output_node="vout", ref_node="0", src="Vin",
variation="dec", points=10, start_frequency=1e3, stop_frequency=1e8)
tb.transfer_function("V(vout)", "Vin") # alias: tf()
tb.dc_sensitivity("V(vout)")
tb.ac_sensitivity("V(vout)", variation="dec", number_of_points=10,
start_frequency=100.0, stop_frequency=1e5)
tb.polezero("in", "0", "vout", "0", "vol", "pz")
tb.distortion(variation="dec", points=10,
start_frequency=100.0, stop_frequency=1e8)
RF / periodic steady-state
tb.pss(fundamental_frequency=1e6, stabilization_time=10e-6,
observe_node="vout", points_per_period=128, harmonics=10)
tb.s_param(variation="dec", number_of_points=10,
start_frequency=1e6, stop_frequency=1e10)
tb.harmonic_balance(fundamental_frequencies=[1e9], num_harmonics=7)
tb.stability(probe="Vprobe", variation="dec", number_of_points=10,
start_frequency=1.0, stop_frequency=1e10)
tb.transient_noise(step_time=1e-9, end_time=1e-6)
Backend-specific analyses
Spectre: spectre_sweep, spectre_montecarlo,
spectre_pac, spectre_pnoise,
spectre_pxf, spectre_pstb.
Xyce: xyce_sampling, xyce_embedded_sampling,
xyce_pce, xyce_fft. See the
API reference for signatures.
# Xyce Monte Carlo over device parameters
res = tb.xyce_sampling(num_samples=100,
param_distributions=[("R1", "normal(1000,50)"),
("R2", "uniform(900,1100)")])
# Xyce FFT with signal-quality metrics
fft = tb.xyce_fft(signal="V(vout)", np=1024, start=0.0, stop=1e-3,
window="HANN")
fft.enob, fft.sfdr_db, fft.snr_db, fft.thd_db
Reading results
All result objects share one access pattern:
res["vout"] # numpy array (or float for operating_point)
res.vout # attribute access works too
res.nodes # list of available node names
res.branches # list of available branch names
res.measures # dict of .measure results, name -> float
Axis properties per analysis: dc.sweep,
ac.frequency, tran.time. Branch currents are saved
when tb.save_currents = True or saved explicitly via
tb.save("I(Vsupply)").
Simulator configuration
tb.temperature = 27.0 # .temp
tb.nominal_temperature = 25.0 # .options tnom
tb.options(RELTOL="1e-4", ABSTOL="1e-12", VNTOL="1e-6", GMIN="1e-12")
tb.save("V(output)") # explicit save list
tb.save_currents = True # save all branch currents
tb.measure("TRAN", "v_peak", "MAX", "V(output)") # .meas → res.measures["v_peak"]
tb.initial_condition(output=0.0) # .ic for transient
tb.node_set(output=2.5) # .nodeset hint for DC convergence
tb.step("R1", 500, 2000, 500) # .step param sweep
tb.step_sweep("R1", 100, 10000, 10, "dec") # lin | oct | dec
Backends
ps.CircuitSimulator.available_backends() # what's installed locally
tb.with_backend("vacask") # force a backend
sim = circuit.simulator(simulator="xyce") # Circuit-style override
With no override, DeSpice picks a backend automatically based on the circuit's features (XSPICE elements, OSDI models, measures, …) and the requested analysis. Capability matrix:
| Feature | ngspice | xyce | ltspice | vacask | spectre |
|---|---|---|---|---|---|
| XSPICE (A-elements) | Yes | No | No | Yes | No |
| OSDI (Verilog-A) | Yes | No | No | Yes | Yes |
| .measure | Yes | Yes | Yes | Yes | No |
| .step parameters | Yes | Yes | Yes | Yes | Yes |
| Control blocks | Yes | No | No | No | No |
| Laplace sources | No | No | Yes | No | No |
| Verilog co-sim | Yes | No | No | Yes | Yes |
Hot-swap pattern — same DUT, loop over backends:
for backend in ["ngspice", "vacask"]:
tb = ps.Testbench(dut)
tb.V(name="supply", positive="vin", negative=dut.gnd, value=10 @ u_V)
tb.with_backend(backend)
op = tb.operating_point()
PDKs and model libraries
import os
PDK_ROOT = os.environ["PDK_ROOT"] # e.g. installed via: ciel install sky130
lib = ps.ModelLibrary(
f"{PDK_ROOT}/sky130A/libs.tech/ngspice/sky130.lib.spice",
corner="tt")
dut = ps.Subcircuit("cs_amp", ["vdd", "vin", "vout"])
dut.M(name="1", drain="vout", gate="vin", source=dut.gnd, bulk=dut.gnd,
model="sky130_fd_pr__nfet_01v8", L=0.5, W=5)
tb = ps.Testbench(dut)
tb.use_pdk(lib)
tb.with_backend("ngspice")
op = tb.operating_point()
ModelLibrary(path, corner=None, setup_includes=None,
**backend_paths) also accepts per-backend model paths as keyword
arguments so one library object can serve several simulators. Swapping PDKs
means swapping the library and the model names — topology stays the same
(see examples 20–21).
Linting and backend checks
# Check a testbench against a backend before running
issues = tb.check_backend("ngspice") # list of issue strings
for issue in issues:
print(issue)
# Lint a raw netlist
report = ps.lint(netlist_text, backend="ngspice")
report["errors"] # [{"line": int, "message": str}, ...]
report["warnings"] # adds "suggestion" and "backends_affected"
Catches missing models, dangling nodes, backend-incompatible constructs, and similar problems before they become cryptic simulator errors.
Verilog-A
openvaf on $PATH and
a backend with OSDI support (ngspice, vacask, spectre). The OSDI version
must match what your ngspice build expects.VERILOGA_RESISTOR = r"""
`include "disciplines.vams"
module myres(a, b);
inout a, b;
electrical a, b;
parameter real r = 1000.0;
analog V(a,b) <+ r * I(a,b);
endmodule
"""
osdi_path = dut.veriloga(VERILOGA_RESISTOR) # compiles source (or a .va path)
dut.raw_spice("Nmyres1 mid 0 myres r=1000") # instantiate the OSDI model
# Alternatives:
dut.osdi("model.osdi") # load a pre-compiled binary
path = ps.compile_veriloga("model.va") # module-level compile helper
Digital Verilog co-simulation
iverilog
on $PATH.# Analog <-> digital bridges
dut.model("adc_bridge_model", "adc_bridge", in_low=0.8, in_high=2.0)
dut.model("dac_bridge_model", "dac_bridge", out_low=0.0, out_high=3.3)
dut.A(name="adc1", connections=["[clk rst]", "[dclk drst]"],
model="adc_bridge_model")
dut.A(name="dac1", connections=["[d0 d1 d2 d3]", "[a0 a1 a2 a3]"],
model="dac_bridge_model")
There is also a high-level circuit.verilog(source=..., mode="simulate"
| "synthesize", instance_name=..., connections={...}, pdk=..., liberty=...,
spice_models=...) that wires a Verilog module in directly. See
example 18.
Design testbench recipes
The testbenches library (example 22) ships reusable recipes
for analog blocks — amplifier gain, ADC ramp, DAC static linearity, PLL
lock, bandgap reference — plus a validation framework:
from testbenches import (
CornerCase, MetricSpec, MonteCarloPlan, ValidationRule,
amplifier_voltage_gain, bandgap_reference,
corner_netlists, extract_metrics, validate_metrics)
bench = amplifier_voltage_gain(ps, dut, load_resistance=10e3)
netlist = bench.netlist("ngspice") # generate without simulating
corners = [CornerCase("tt_27", temperature=27,
parameters={"vdd_nom": 3.3})]
cornered = corner_netlists(lambda: bandgap_reference(...), corners)
metrics = extract_metrics(waveforms,
[MetricSpec("vout_final", "vout", "final")])
report = validate_metrics(metrics, bench.validation)