Circuit Analysis & Differentiable Numerical Integration Program
Cadnip is an analog circuit simulator written in Julia, focused on simplicity, maintainability, and robustness.
- Import of circuits as netlists, in several dialects of SPICE and Spectre
- Definition of new devices in Verilog-A
- DC, transient, AC small-signal, and noise analyses
- Full differentiability with respect to parameter values via ForwardDiff (for sensitivities, optimization, ML, etc.)
- Parameter sweeps with
CircuitSweep - Works with standard Julia releases (1.11+)
Cadnip is a fork of the now-inactive CedarSim that replaces the DAECompiler backend with a straightforward Modified Nodal Analysis (MNA) implementation. Internally a circuit is compiled to an ordinary Julia function that stamps the MNA matrices, which is what makes it both fast and differentiable by the rest of the Julia ecosystem.
Cadnip is in the General registry:
using Pkg
Pkg.add("Cadnip")Or clone and develop locally:
git clone https://github.com/NyanCAD/Cadnip.jl
cd Cadnip.jl
julia --project=. -e 'using Pkg; Pkg.instantiate()'A resistive voltage divider is the circuit-level "hello world". The sp"..."
string macro inlines a SPICE netlist:
using Cadnip
using Cadnip.MNA: MNACircuit
circuit = MNACircuit(sp"""
* Voltage divider
V1 vcc 0 DC 5
R1 vcc out 1k
R2 out 0 1k
""")
sol = dc!(circuit)
println("Vout = ", sol[:out], " V") # 2.5Mind the title line. A SPICE deck's first line is its title, so sp"..."
treats it as a comment — that is what the * Voltage divider line is for. Drop
it and the V1 card is swallowed as the title, which costs you the source
without any error:
julia> sol = dc!(MNACircuit(sp"""
V1 vcc 0 DC 5
R1 vcc out 1k
R2 out 0 1k
""")) # no title line: V1 is eaten
DC Solution:
Node Voltages:
V(out) = 0 V
V(vcc) = 0 V
Device Terminal Currents:
i_r1_p = 0 A
...There is no source left in the circuit, so everything sits at zero — note that
the Branch Currents section is missing entirely, because V1 never made it in.
Add the i (inline) flag when a snippet has no title line of its own:
julia> sol = dc!(MNACircuit(sp"""
V1 vcc 0 DC 5
R1 vcc out 1k
R2 out 0 1k
"""i)) # `i` = no title expected
DC Solution:
Node Voltages:
V(out) = 2.5 V
V(vcc) = 5 V
Branch Currents:
I_v1 = -0.0025 A
Device Terminal Currents:
i_r1_p = 0.0025 A
...Spectre syntax works the same way, via spc"..." (which has no title line, so
no flag):
circuit = MNACircuit(spc"""
v1 (vcc 0) vsource type=dc dc=5
r1 (vcc out) resistor r=1k
r2 (out 0) resistor r=1k
""")
println("Vout = ", dc!(circuit)[:out], " V") # 2.5For anything larger than a snippet, keep the netlist in a file. The extension
picks the language: .scs is Spectre, anything else is SPICE.
circuit = MNACircuit("amp.sp") # extension → .scs Spectre, else SPICE
sol = dc!(circuit)
println("Output voltage: ", sol[:out])| Input | Loader |
|---|---|
| SPICE file | MNACircuit("amp.sp") |
| Spectre file | MNACircuit("amp.scs") |
| Top-level include in module | Base.include(@__MODULE__, SpiceFile("amp.sp")) |
| SPICE string | sp"""...""" or MNACircuit(code; lang=:spice) |
| Spectre string | spc"""...""" or MNACircuit(code; lang=:spectre) |
| Verilog-A string | va"""...""" |
| Already-compiled builder | MNACircuit(my_builder_fn; R=1e3) |
| PDK package | .lib "jlpkg://MyPDK/..." typical in the netlist |
Runtime parsing defines a builder, so mind the world age. MNACircuit("path")
and MNACircuit(code; lang=...) parse the netlist and Base.eval a builder
function for it. Julia freezes the world age of a top-level statement while it
runs, so the fresh builder can only be called from a later statement:
circuit = MNACircuit("amp.sp") # statement 1: defines the builder
sol = dc!(circuit) # statement 2: calls it — fine
dc!(MNACircuit("amp.sp")) # ✗ same statement: MethodError, "method too new"A function body freezes its world age at entry the same way, so building and solving inside one call fails for the same reason. Bring the circuit into scope at top level first and the function is free to build and solve as it likes:
Base.include(@__MODULE__, SpiceFile("amp.sp")) # top level: defines `amp`
function run_sim()
c = MNACircuit(amp; R1=1e3) # no eval, no world-age tax
dc!(c)
endThe string macros have no such restriction. They compile their deck when the
macro expands — before the enclosing statement runs, and before an enclosing
function is even defined — so the builder always predates any call to it, and
what lands at the call site is just the builder. sp"...", spc"..." and
va"..." are as good inside a function body as at top level:
function run_sim()
dc!(MNACircuit(sp"""
* divider
V1 vcc 0 DC 5
R1 vcc out 1k
R2 out 0 1k
"""))[:out] # 2.5
endSo a netlist read at runtime — from a path or a string — is loaded at top level and functions take it from there; a netlist written in the source can go wherever it reads best.
A netlist .param is the knob a design is parameterized on: bias point, device
size, source amplitude. Override one by name when the circuit is built, re-bind
it with alter, or make it a sweep axis — the same name in every case:
Base.include(@__MODULE__, SpiceFile("amp.sp")) # .param vbias=1.1472, .param rd=10k
c = MNACircuit(amp; vbias=1.2) # override at construction
c = alter(c; rd=20e3) # re-bind (introduces the knob if absent)
for (p, sol) in dc!(CircuitSweep(amp, Sweep(vbias = 1.05:0.05:1.30)))
@show p.vbias, sol[:drain] # DC transfer curve
endSubcircuit parameters are keyed by instance name, and an override outranks the
value the instance line spells out (X1 in out divider r1val=2k):
c = MNACircuit(top; x1=(r1val=1e3,)) # or var"x1.r1val" as a sweep axisParameters and instances share a namespace, and the shape of the override tells
them apart: a leaf is a parameter, a group is an instance. So with a
.param x1 next to an X1 instance, x1=2.0 sets the parameter and
x1=(r1val=1e3,) addresses the instance; params=(x1=2.0,) names the parameter
explicitly when you need both at once.
A .model card reads .params too, so a process corner is an ordinary sweep
axis rather than a second netlist:
.param vt0=0.7
.model nch nmos level=1 vto=vt0 kp=100u
dc!(CircuitSweep(amp, Sweep(vt0 = [0.6, 0.7, 0.8])))Override names are checked against what the netlist actually declares, so a typo is an error at construction rather than a sweep that quietly returns the default at every point:
julia> MNACircuit(amp; vbais=1.2)
ERROR: ArgumentError: unknown parameter override `vbais` — the top level
declares no parameter `vbais`. It declares: rd, vbias.The same check runs for alter and for sweep axes, and for a netlist that
declares nothing at all — there the message ends "It declares no parameters at
all." Two limits worth knowing: device instance parameters are not reachable
this way (r1=(r=2e3,) throws — give the netlist a .param and use that
instead), and a hand-written builder is not checked, since only it knows what
its parameters mean.
sol = dc!(circuit) # DC operating point
sol = tran!(circuit, (0.0, 1e-3)) # Transient
ac = ac!(circuit) # AC small-signal (linearized DSS)
ns = noise!(circuit, :out; freqs=acdec(10, 1, 1e6)) # Noise
result = dc!(CircuitSweep(circuit, sweep)) # Parameter sweepdc!(cs::CircuitSweep) returns a SweepResult that iterates (params, sol)
pairs. Solutions support name-based access via sol[:node] / sol[:I_vsrc],
and sol.converged says whether Newton actually reached tolerance.
A DC sweep continues: each point starts Newton from the previous point's
solution rather than from zeros, like a SPICE .dc sweep. Adjacent points are a
small perturbation of each other, so this costs far fewer Newton iterations on
nonlinear circuits (a 40-junction ladder over 60 points: 877 iterations cold vs
477 continued). A point that fails to converge is never used as a starting
guess. Pass continuation=false for independent cold solves, and
dc!(circuit; u0=sol.x) to warm-start a single operating point by hand:
result = dc!(CircuitSweep(circuit, sweep)) # continued (default)
result = dc!(CircuitSweep(circuit, sweep); continuation=false) # independent pointsA DC operating point also reports device terminal currents — the drain current of a MOSFET, the current through a resistor — which the solution vector itself cannot give you, because KCL has already summed every device that meets at a node. Devices report them as they stamp the converged point, so builtin devices and every Verilog-A model (VADistiller, PDK, your own) are covered alike. Positive is into the device:
op = dc!(circuit)
op[:i_m1_d] # drain current of M1 — not inferred from a supply branch
op[:i_r1_p] # current into R1's first terminal
terminal_currents(op) # every device terminal, as name => currentAlongside them it reports device operating-point variables — the
small-signal numbers the models compute (gm, gds, vth, vdsat), which
no amount of reading the solution vector can recover. A Verilog-A model
contributes every variable it declares with a desc/units attribute, which
is how the VADistiller and PDK models already spell their .op output, so this
needs nothing configured per model:
op[:m1_gm] # transconductance of M1, from the model itself
op[:m1_vds] > op[:m1_vdsat] # in saturation, in the model's own numbers
op_vars(op) # every device variable, as name => valueAnd it is enumerable, so you can introspect or export the whole thing without knowing the names up front:
sol = dc!(circuit)
keys(sol) # nodes, branch currents, terminal currents, device variables: [:in, :out, :I_v1, :i_r1_p, …]
Dict(pairs(sol)) # the whole operating point as name => value
get(sol, :out, NaN) # non-throwing lookup (sol[:out] throws if absent)
haskey(sol, :out) # trueThat list is flat, so two accessors say which name is which — no parsing of the
I_ spelling required. They read the same on a DC, transient or AC solution
(and on the MNAData/MNAContext a circuit was stamped into), so a readout
written for one analysis walks the same nodes in the others:
node_names(sol) # [:in, :out] — ground names no column
branch_names(sol) # [:I_v1] — SPICE names are lowercasedac!(circuit, freqs) returns an ACSol — a linearized descriptor state-space
system about the DC operating point, carrying the Hz frequency grid you asked
for. Name-based access is the SPICE-native readout (same sol[:name] meaning as
DC/transient), and Hz helpers need no manual 2π conversion:
f = acdec(20, 1, 1e6) # 1 Hz … 1 MHz, in Hz (SPICE .ac dec)
ac = ac!(circuit, f)
resp = ac[:vout] # complex response over the grid
mag = magnitude_db(ac, :vout) # magnitude in dB
phs = phase_deg(ac, :vout) # phase in degreesfreqresp evaluates at arbitrary angular frequencies (ω in rad/s — the
ControlSystems convention), and needs no stored grid:
ac = ac!(circuit)
H = freqresp(ac, :vout, 2π .* f) # raw complex responseFor the ControlSystems / DescriptorSystems interop, subsystem(ac, :name)
returns the SISO descriptor system for ss, bode, poles/zeros:
using RobustAndOptimalControl # ss(::DescriptorStateSpace)
mag, phase, w = bode(ss(subsystem(ac, :vout)), 2π .* f)Device names resolve via two tiers:
- Tier 1 (builtins). R, C, L, D, level-dispatched MOSFETs/BJTs. Just
using VADistillerModels/using BSIM4and.model nmosfet nmos level=1resolves automatically. - Tier 2 (netlist scope). PDKs and custom VA devices via netlist directives:
.hdl "file.va",.include "lib.sp",.lib "lib.sp" section, andjlpkg://Package/path. Most-recent include wins.
PDK authors expose content via Cadnip.precompile_pdk(@__MODULE__, "pdk.spice")
and Cadnip.precompile_va(@__MODULE__, "device.va") at package build time.
Run the test suite:
julia --project=. -e 'using Pkg; Pkg.test()'Or run specific test groups:
julia --project=. -e 'using Pkg; Pkg.test(test_args=["mna"])'This package is available under the MIT license (see LICENSE). You may also use it under CERN-OHL-S v2 if that better suits your project.
Contributions are welcome! Please open an issue or pull request on GitHub.
- SpiceArmyKnife.jl - Tool for parsing and converting between netlist languages
- NyanVerilogAParser.jl - Verilog-A parser
- NyanSpectreNetlistParser.jl - Spectre netlist parser