netlist_parser.py — Layer 0

Read a flat SPICE .subckt block, one line at a time, and hand back a tidy ParsedNetlist. The simplest station on the line.

Part 1 · Why does this file exist?

The one job: "I have a wall of SPICE text. Turn it into Python objects I can reason about — without losing any wiring."

SPICE is just lines of text. A machine can't ask a string "which transistors share the tail node?"

So before any recognition can happen, someone has to read the text and build a graph of devices and the nets between them.

That someone is parse().

Think of it like a customs officer reading a packing list. Each line declares one item and where its connectors go. The officer's job is not to judge the shipment — only to log every item accurately into a ledger. That ledger is the ParsedNetlist.

The clean idea: this file is the structural inverse of the synthesizer's to_flat_spice(). One function writes devices out to text; this one reads them back in. That symmetry is what makes round-trip testing possible.

Part 2 · The shape of one line

Every SPICE device line is a sequence of whitespace-separated tokens. The meaning of each position depends on the device kind:

MOSFET (6 tokens) m1 d g s b nmos | pmos W= L= … resistor (3–4 tokens) r1 t1 t2 1k capacitor (3–4 tokens) c1 p m 1p
Solid orange = the ref (name). Solid boxes = terminals → nets. Dashed = optional trailing values / params.

All of this is wrapped in a .subckt <name> <ports…>.ends block. The parser also tolerates the common typos .suckt and .end.

Part 3 · Dispatch by first letter — the heart of the loop

The one job: "Given an unknown device line, how do I know whether it's a transistor, a resistor, or a capacitor?"

SPICE has a lovely convention: the first character of the ref name tells you the device kind. m1 is a MOSFET, r5 a resistor, c2 a capacitor. The parser leans entirely on this:

ref   = tokens[0]
first = ref[0].lower()
first char? m / x c r else MOSFET 5 positionals: d g s b model capacitor 2–3 positionals resistor 2–3 positionals raise ValueError x accepted only if its model token names a known MOSFET
One switch on ref[0] routes every line. Unknown first letters fail loud, not silent.

The x case is the subtle one. Real design flows (SKY130, gf180) instantiate transistors as subcircuits, so a MOSFET may appear as x1 … instead of m1 …. The parser accepts x only when its model token resolves to a MOSFET in the model map; a genuine hierarchical subcircuit is out of scope and raises.

Why is the design map called DEFAULT_MODEL_MAP? I like this name.

It maps a process's real model name onto the bare type the recognizer cares about:

"sky130_fd_pr__nfet_01v8": "nmos",
"pmos_3p3":               "pmos",
"nmos":                  "nmos",  # bare token still works

The recognizer only ever wants to know "is this n or p?" The map is the one place that translates a whole zoo of vendor model names down to that single question. Callers can extend it, and their entries win.

Part 4 · Splitting positionals from params

The one job: "The tokens after the ref are a mix of net names and key=value settings. Sort them into two piles."

A sized MOSFET line looks like:

m1 net1 in1 tail vdd! sky130_fd_pr__pfet_01v8 W=4u L=0.5u nf=2 m=1

The bare tokens (net1 in1 tail vdd! sky130…) are positional — their meaning is their position. The W=4u-style tokens are named params. _split_positional_params walks left to right and files each token:

for tok in tokens:
    if "=" in tok:
        key, _, val = tok.partition("=")
        params[key] = val
    elif params:                    # a bare token AFTER a param = malformed
        raise ValueError(...)
    else:
        positionals.append(tok)

The elif params: line is the single most important guard here.

It enforces the SPICE rule that positionals always come before params. Once you've seen one key=value, a bare token is a mistake — so it fails loudly rather than silently mis-assigning a net.

Part 5 · A whole line, traced by hand

Let's parse one MOSFET line end to end. Input token list (after .split()):

["m1", "net1", "in1", "tail", "vdd!", "pmos_3p3", "W=4u"]
StepValue
ref = tokens[0]"m1"
first = ref[0].lower()"m" → MOSFET branch
split tokens[1:]positionals=["net1","in1","tail","vdd!","pmos_3p3"], params={"W":"4u"}
len(positionals) == 5?yes ✓ (else raise)
model = positionals[4].lower()"pmos_3p3" → in map → dev_type = "pmos"
terminals{d:"net1", g:"in1", s:"tail", b:"vdd!"}

Result: one Device(ref="m1", type="pmos", terminals={…}, params={"W":"4u"}) appended, and its four nets folded into referenced_nets.

m1 net1 in1 tail vdd! pmos_3p3 W=4u parse Device(type="pmos", terminals={…}, params={W:4u})
One text line in, one structured Device out — sizes preserved but never used for matching.

Part 6 · Two dialects, one parser (the honest limitations)

The parser deliberately swallows two SPICE dialects with the same code:

FeatureUnsized (synthesizer)Sized (real flows / sizer)
type tokennmos / pmossky130_fd_pr__nfet_01v8
ref prefixm…m… or x…
trailing tokensnoneW= L= nf= m=
R/C valueoften absentkept under params["value"]

What it does not do: it makes no attempt to interpret sizes, flatten hierarchy, or resolve genuine subcircuits. An x-line whose model isn't a known MOSFET raises ValueError — out of scope, on purpose. The parser's whole ethos is "log faithfully, judge nothing."

Why this matters downstream: because sizes ride along untouched and recognition matches only on topology, recognize() gives identical answers on sized and unsized netlists. The complexity is quarantined here.

Part 7 · The hidden idea

The last three lines quietly compute a set difference that the whole rest of the package leans on:

internal_nets = (⋃ every terminal net) − external_ports

In plain English: a net is "internal" exactly when a device touches it but the outside world can't. That single boolean — is this net a port or not? — is what Layer 2 uses over and over to tell a real signal path from a bias reference. Layer 0 pays the cost once so nobody else has to.


In one sentence: netlist_parser.py is a customs officer that reads SPICE line by line, dispatches on the first letter, files sizes without judging them, and hands the next station a faithful, port-aware ledger of devices and nets.

Looking ahead → Part 3 · subcircuit_recognizer.py is where it gets exciting: a backtracking search that matches template patterns against this ledger. It's the heart of the package.