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:
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()
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=valuesettings. 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"]
| Step | Value |
|---|---|
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.
Part 6 · Two dialects, one parser (the honest limitations)
The parser deliberately swallows two SPICE dialects with the same code:
| Feature | Unsized (synthesizer) | Sized (real flows / sizer) |
|---|---|---|
| type token | nmos / pmos | sky130_fd_pr__nfet_01v8 |
| ref prefix | m… | m… or x… |
| trailing tokens | none | W= L= nf= m= |
| R/C value | often absent | kept 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:
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.