Skip to content

Circuits

Three objects

Object Use it for Create with
Subcircuit A design block with ports: the thing you test ps.Subcircuit("name", ["port", ...])
Testbench Stimulus and analyses around one Subcircuit ps.Testbench(dut)
Circuit A standalone, flat circuit (the PySpice style) ps.Circuit("title")

Put devices in the Subcircuit and sources in the Testbench. A Testbench only takes V, I, R, C and the waveform sources; everything else (L, E, M, model, include, ...) lives on Subcircuit and Circuit.

import spicerack as ps

dut = ps.Subcircuit("amp", ["vin", "vout", "vdd"])
dut.R("load", "vdd", "vout", 10e3)
dut.M("1", "vout", "vin", dut.gnd, dut.gnd, model="nmos1", W=10e-6, L=1e-6)
dut.model("nmos1", "NMOS", LEVEL=1, VTO=0.5, KP=200e-6)

tb = ps.Testbench(dut)
tb.V("dd", "vdd", "0", 1.8)
tb.V("in", "vin", "0", 0.8, ac=1.0)

The flat style builds everything in one Circuit and gets a simulator from it:

c = ps.Circuit("divider")
c.V("in", "vin", c.gnd, 10.0)
c.R("1", "vin", "vout", 1e3)
c.R("2", "vout", c.gnd, 1e3)
op = c.simulator().operating_point()
print(op["vout"])   # 5.0
print(c)            # the netlist

Ground is dut.gnd (node "0"). In a Testbench, write "0".

Values and units

A value is a float in SI units, a unit value, or a SPICE expression string:

from spicerack.unit import u_kOhm, u_pF

r = 4.7 @ u_kOhm
print(r.value, r.str_spice())   # 4700.0 4.7k

dut.parameter("rl", "10k")
dut.R("x", "vout", dut.gnd, "rl*2")
dut.C("c1", "vout", dut.gnd, 2 @ u_pF)

Unit names follow u_<prefix><unit>: u_V, u_mA, u_kOhm, u_pF, u_uH, u_MHz, u_ns, u_mW, u_Degree. u_µ... and u_Ω aliases exist.

Elements

Every element takes its name and nodes first; the rest are keywords.

Method Element Arguments after name, nodes...
R, C, L passives value, then instance parameters (tc1=...)
K inductor coupling inductor1, inductor2, coupling=
V, I DC sources value, ac=, ac_phase=
E, G voltage-controlled sources control_positive=, control_negative=, voltage_gain= / transconductance=
F, H current-controlled sources vsense= (a V source name), current_gain= / transresistance=
POLY polynomial E/G/F/H kind, positive, negative, controls, coefficients
BV, BI behavioural sources expression="..."
D diode anode, cathode, model=
Q (BJT) bipolar collector, base, emitter, model=
M (MOSFET) MOSFET drain, gate, source, bulk, model=, then W=, L=, ...
J, Z JFET, MESFET drain, gate, source, model=
S, W switches control_positive=, control_negative=, model= / vcontrol=, model=
T transmission line four nodes, Z0=, TD=
X subcircuit instance by name "subckt_name", *nodes, **params
A XSPICE code model name=, connections=[...], model= (all keywords)
dut.E("buf", "vbuf", dut.gnd, control_positive="vout", control_negative=dut.gnd, voltage_gain=1.0)
dut.BI("leak", "vout", dut.gnd, expression="1e-9*V(vout)")
dut.V("sense", "vout", "vload", 0.0)                  # a 0 V source to sense current
dut.F("mirror", "vdd", "vcopy", vsense="Vsense", current_gain=2.0)

Waveform sources

Each comes as ...VoltageSource and ...CurrentSource, on all three objects:

Method Key arguments
SinusoidalVoltageSource offset, amplitude, frequency, delay, damping_factor, phase
PulseVoltageSource initial_value, pulsed_value, delay, rise_time, fall_time, pulse_width, period
PieceWiseLinearVoltageSource values=[(t, v), ...]
ExponentialVoltageSource initial_value, pulsed_value, rise_delay_time, rise_time_constant, fall_delay_time, fall_time_constant
SingleFrequencyFMVoltageSource offset, amplitude, carrier_frequency, modulation_index, signal_frequency
AmplitudeModulatedVoltageSource offset, amplitude, modulating_frequency, carrier_frequency, signal_delay

All take dc= (the DC value for .op; default: the waveform at t=0), ac= and ac_phase=.

tb.PulseVoltageSource("clk", "vclk", "0", initial_value=0.0, pulsed_value=1.8,
                      rise_time=1e-9, fall_time=1e-9, pulse_width=24e-9, period=50e-9)
tb.PieceWiseLinearVoltageSource("ramp", "vramp", "0", values=[(0, 0.0), (1e-6, 1.8)])

Models, libraries, raw lines

dut.model("D1N4148", "D", IS=2.52e-9, N=1.752)    # .model
dut.include("models.lib")                         # .include
dut.lib("sky130.lib.spice", "tt")                 # .lib file section
dut.parameter("vbias", "0.9")                     # .param (value is a string)
dut.raw_spice("* anything the builder does not cover")

Circuit also has options(**kw), temp(t), node(name), element(name) and set_source_value(name, value). For model libraries with corners, see Models and PDKs.

Hierarchy

Build a child Subcircuit, place it with instance (or X by name), and register its definition once with add_subcircuit (Circuit.subcircuit on a Circuit):

inv = ps.Subcircuit("half", ["a", "y"])
inv.R("1", "a", "y", 1e3)
inv.R("2", "y", inv.gnd, 1e3)

top = ps.Subcircuit("top", ["in", "out"])
top.instance(inv, "u1", "in", "mid")
top.X("u2", "half", "mid", "out")                 # by name, e.g. a PDK device
tb = ps.Testbench(top)
tb.add_subcircuit(inv)
tb.V("in", "in", "0", 1.0)
print(tb.operating_point()["out"])                # 0.2 (u2 loads the first divider)

Keyword arguments to X and instance become instance parameters (dut.X("m1", "sky130_fd_pr__nfet_01v8", "d", "g", "s", "b", W=1, L=0.15)). dut.name and dut.ports read a block back.

Saving circuits

Circuits round-trip through JSON, the same format any tool can write (schema: schema/circuit-ir.schema.json):

import tempfile, os

path = os.path.join(tempfile.mkdtemp(), "top.json")
top.save_json(path)
again = ps.Subcircuit.load_json(path)
print(again.name, again.ports)                    # top ['in', 'out']
text = top.to_json()                              # or keep it as a string
same = ps.Subcircuit.from_json(text)