User Guide

Contents

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:

ToolNeeded for
ngspiceDefault backend; most features (XSPICE, OSDI, control blocks)
xyce / ltspice / spectre / vacaskAlternative backends
openvafCompiling Verilog-A to OSDI (veriloga(), compile_veriloga())
iverilogDigital Verilog co-simulation
ciel + PDK_ROOTInstalling open PDKs (sky130, gf180mcu)

Core concepts

Three objects do most of the work:

ClassRole
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:

Featurengspicexyceltspicevacaskspectre
XSPICE (A-elements)YesNoNoYesNo
OSDI (Verilog-A)YesNoNoYesYes
.measureYesYesYesYesNo
.step parametersYesYesYesYesYes
Control blocksYesNoNoNoNo
Laplace sourcesNoNoYesNoNo
Verilog co-simYesNoNoYesYes

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

Requires 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

Requires ngspice with XSPICE and 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)