Modified Nodal Analysis

Every SPICE simulator works the same way: it turns a circuit into a matrix equation , then solves for . The method for building that matrix is called Modified Nodal Analysis (MNA).

This chapter shows exactly how MNA works — how each component becomes entries in a matrix, and how solving that matrix gives you every voltage and current in the circuit.


The unknowns

The solution vector contains two kinds of unknowns:

  1. Node voltages — one for every node in the circuit (except ground, which is defined as 0V)
  2. Branch currents — one for every voltage source and every inductor

If a circuit has nodes (excluding ground) and voltage sources, the matrix is .

The "Modified" in MNA refers to the addition of branch currents. Plain Nodal Analysis only solves for node voltages, which makes it unable to handle voltage sources directly. MNA adds extra equations for each voltage source, making the system slightly larger but much more general.


Node voltages

The solution vector in MNA starts with one unknown per circuit node — except ground. Understanding why ground is special, and how nodes get numbered, clarifies the entire matrix structure.

Ground is the reference, not a variable

Voltage is always measured between two points. To get absolute numbers, SPICE defines one node as the reference and fixes it at 0V. This is ground — node 0 in SPICE netlists.

Ground does not appear in the solution vector. It has no row and no column in the matrix. Every other node voltage is implicitly measured with respect to it. When we write , we mean the potential difference between node and ground is 5V.

This is why every SPICE netlist must have a node 0. Without a reference, the system of equations is underdetermined — you can add any constant to all voltages and still satisfy KCL. Fixing ground removes that degree of freedom and makes the solution unique.

Node numbering

In a netlist, nodes have names: in, out, vdd, 0. Internally, the simulator assigns each non-ground node a sequential index starting at 1. This index is the row (and column) position in the MNA matrix.

Internal vs external nodes

Some devices create nodes that don't appear in the netlist. A MOSFET model, for example, adds internal nodes for the parasitic drain and source resistances. These nodes are real — they get indices, rows, and columns — but the user never names them.

In spice-rs (following ngspice), external nodes come first in the numbering, internal nodes are appended after. A circuit with 5 external nodes and 3 internal nodes has 8 node voltage unknowns, occupying rows 1 through 8 of the solution vector.

The solution vector

Putting it together, the full solution vector for a circuit with nodes and voltage sources is:

The first entries are node voltages — external then internal. The remaining entries are branch currents for voltage sources and inductors. The matrix is , and a single solve gives every voltage and current in the circuit.


Conductance stamps

Each component contributes entries to the matrix and the right-hand side . These contributions are called stamps — the component "stamps" its values into the matrix.

The resistor stamp is the atom of circuit simulation. Every other stamp — capacitors, diodes, transistors — is a variation on this pattern. Understand it once, and the rest follows.

Resistor

A resistor between nodes and has conductance . It carries current . Write KCL at both nodes:

Node : current leaves through the resistor, so is the resistor's contribution. Expanding: .

Node : current enters through the resistor, so . Expanding: .

These coefficients go into a 2x2 stamp pattern:

Positive on the diagonal, negative on the off-diagonal. Symmetric. This is the most important stamp in SPICE — it shows up everywhere because even nonlinear devices are linearized into an equivalent conductance at each iteration.

Here is how spice-rs implements it:

// From device/resistor.rs — the load() function
let g = self.conductance;
mna.stamp(self.pos_node, self.pos_node,  g);
mna.stamp(self.neg_node, self.neg_node,  g);
mna.stamp(self.pos_node, self.neg_node, -g);
mna.stamp(self.neg_node, self.pos_node, -g);

Four calls to stamp(). Each one adds a value to one matrix entry. That's all a resistor does.

Stamping two resistors

Take two resistors: between nodes 1 and 2 ( S), and between nodes 2 and ground ( S).

stamps its 2x2 block. connects node 2 to ground — ground has no row or column, so only the diagonal entry at node 2 survives. The final matrix is the sum of all stamps:

Row 1
Row 2

This is the key insight: each component stamps independently, and the stamps superpose by addition. The order doesn't matter. The components don't know about each other. The matrix assembles itself.

When one node is ground

When a resistor connects to ground (node 0), that node has no row or column. The 2x2 stamp degenerates: the row and column for ground are simply discarded. Only the diagonal entry at the other node survives. This is not a special case in the code — ground is just absent from the matrix, so stamps that reference it naturally contribute only their non-ground entries.

Current source

An independent current source from node to node (current flows from to ) stamps only the right-hand side:

No matrix entries — a current source doesn't depend on any node voltage, so it contributes no conductance.

Voltage source

A voltage source between nodes and (positive terminal at ) adds a new unknown: the branch current . If this is the -th branch variable, it stamps:

The first two rows express KCL: the branch current enters node and leaves node . The last row enforces the voltage constraint: .

This is why voltage sources add a row and column to the matrix — they introduce both a new unknown (the branch current) and a new equation (the voltage constraint).

Why the current comes out negative

When you simulate a simple circuit with a voltage source supplying power, the branch current is typically negative. This is not a bug — it's a consequence of the sign convention. MNA defines as current flowing from the positive terminal through the source to the negative terminal (i.e., internally). In a source supplying current to the circuit, current flows out of the positive terminal externally, which is the opposite direction. Hence the negative sign.


Building the full matrix

Let's assemble a complete MNA matrix from scratch. Every stamp, every entry, laid out so you can trace each number back to the component that put it there.

The voltage divider from Chapter 1:

Nodes: in = 1, mid = 2, ground = 0 (excluded). Branch variables: at position 3. Matrix size: .

Stamp R1 (1k between nodes 1 and 2): S. Adds to (1,1) and (2,2), to (1,2) and (2,1).

Stamp R2 (1k between node 2 and ground): S. Ground has no row/column, so only the (2,2) diagonal entry survives. Node 2 diagonal is now .

Stamp V1 (10V source, positive at node 1, negative at ground): Branch current at index 3. Adds 1's coupling the current into KCL at node 1, and the constraint equation in row 3.

The complete system:

Each row tells a story:

Solving gives , , . The voltage divider divides, and the source supplies 10 mA.


Reactive stamp visualization

Here is a three-resistor network. Adjust the resistor values and voltage source — the MNA matrix entries update reactively, and the schematic re-renders with the simulated node voltages.

The MNA matrix for this circuit is 4x4 (3 nodes + 1 voltage source branch). Each resistor stamps its 2x2 conductance block; the voltage source adds its row and column.

Three stamps, three devices, one matrix. Each entry traces back to exactly one component (or in the case of shared-node diagonal entries, two components whose conductances add).


Solving the system

The matrix is built. Now what? SPICE needs to solve — and it needs to do it fast, because nonlinear circuits require solving this system dozens of times per operating point.

LU factorization

The workhorse is LU factorization: decompose into a lower-triangular matrix and an upper-triangular matrix such that . Then solving becomes two easy steps:

  1. Forward substitution: solve for (top to bottom, each equation has one new unknown)
  2. Back substitution: solve for (bottom to top, same idea)

For a dense matrix, LU factorization is . But circuit matrices are not dense — and that changes everything.

Sparsity

A 1000-node circuit has a conductance matrix — one million entries. But each component touches only 2 to 4 nodes, so the vast majority of entries are zero. A typical circuit matrix is 99% zeros.

The sparse solver stores only the nonzero entries and operates only on those. Instead of , the factorization runs in time roughly proportional to the number of nonzero entries — effectively for typical circuits. This is what makes SPICE practical for large designs.

spice-rs uses a Markowitz-ordered LU factorization, faithfully ported from the KLU algorithm in SuiteSparse. Chapter 19 covers the sparse solver in full detail — the pivot ordering strategies, fill-in minimization, and the data structures that make it efficient.

From solution to results

After the solve, the solution vector contains everything:

Node voltages occupy the first positions. These are the primary output — the voltage at every node in the circuit, measured with respect to ground.

Branch currents for voltage sources and inductors occupy positions through . These fall out of the solve for free — no extra computation needed.

Other currents (through resistors, into transistor terminals) are computed after the fact from the node voltages. For a resistor: . For a MOSFET: evaluate the device equations at the solved terminal voltages.

What happens next

For a linear .OP analysis, the solve is done — one factorization, one forward/back substitution, and the answer is ready. But for nonlinear circuits, this is just one iteration of Newton-Raphson:

  1. Linearize all devices at the current operating point
  2. Stamp the linearized values into the matrix
  3. Solve
  4. Update the operating point
  5. Check convergence — if not converged, go to step 1

The matrix is re-stamped and re-factored at every iteration. Factorization dominates the runtime, which is why the sparse solver matters so much — it's the innermost loop of the entire simulation.


The stamp pattern

Every component in SPICE reduces to the same operation: stamp values into the matrix and RHS. This is true for resistors, capacitors, diodes, MOSFETs, transmission lines — everything.

For linear components (resistors, linear capacitors at a given frequency), the stamps are constant. For nonlinear components (diodes, transistors), the stamps change at each Newton-Raphson iteration as the linearization point updates. But the mechanism is identical: call mna.stamp() with a row, column, and value.

This uniformity is the key insight of MNA — it reduces the entire problem of circuit simulation to: fill a matrix, solve it, repeat.

The next chapter shows what happens when the stamps aren't constant — when Newton-Raphson iteration is needed to handle nonlinear devices.