Part 1 · Why this file even exists
The one job: once the recogniser has decided what the circuit is, take a single, careful inventory of its structure — who lives in which slot, a duplicate-free master roster, a typed floor-plan, the stacked devices, and any red flags — and freeze it so every later phase can just read it.
Here is the whole story without a line of code.
Upstream, a recogniser looked at a raw netlist and figured out its role: "this is a two-stage Miller op-amp, here is the input pair, here is the tail, here is the load." That result — a FunctionalBlockRecognitionResult (FBR) — is rich but messy. It is organised around pattern-matching, not around the questions the sizer is about to ask.
The sizer's later phases each need to know something about structure: how many gain stages are there? Is the load a current mirror or a cascode? Which devices are stacked on top of each other? Which transistors even exist, each counted exactly once? Computing all of that fresh, in every phase, would be wasteful and — worse — a chance for two phases to disagree.
Think of analyze.py as the intake desk of a building. When a recognised circuit "checks in," one clerk walks the whole place once and fills a clipboard: a room-by-room guest list, a master roster with no double-counting, a typed floor-plan, a note of who is standing on whose shoulders, and any "this doesn't look right" flags. From then on, nobody re-surveys the building — every later phase just reads the clipboard. That clipboard is the CircuitView.
The cast of characters
The file itself is tiny — one dataclass and two functions. But analyze_circuit() is a conductor: it calls seven helpers (six imported from neighbouring modules, one local). Hover any box to trace who calls it.
_adopt_orphan_mosfets) first, then watch the conductor call the whole set in order.Looking ahead: Part 2 opens the clipboard itself — the six fields of a
CircuitViewand who downstream reads each one.
Part 2 · The CircuitView — what Analyze hands over
The one job: be the single, read-only record of everything structural the later phases could ask about the circuit — so no phase has to recompute it, and no two phases can disagree.
Before we see how it's built, let's see what it is. It's a plain dataclass — six fields, no behaviour. That plainness is the point: it's a data container, a clipboard, not a machine.
@dataclass
class CircuitView:
slot_transistors: dict[str, list[Device]] # slot → its MOSFETs
slot_resistors: dict[str, list[Device]] # slot → its resistors
all_transistors: dict[str, tuple[Device, str]] # ref → (Device, owning slot)
blocks: OpAmpBlocks # typed decomposition
cascode_refs: set[str] # refs of stacked devices
warnings: list[str] # topology-mismatch advisories
Read the fields as answers to six questions a later phase will ask. Each answer flows to a specific downstream reader:
all_transistors, is the deduplicated master roster — the reason it exists is the subject of Part 5.Notice the shape of all_transistors: ref → (Device, slot). It's not just a list of devices — it also records which slot each device belongs to, and it guarantees each ref appears exactly once. Why that matters (a transistor can legitimately show up in two slots) is a subtle idea we'll save for the end.
| Field | Answers the question… | Who reads it |
|---|---|---|
slot_transistors | Which MOSFETs are in each slot? | the sizer's iDS/width assignment |
slot_resistors | Which resistors are in each slot? | resistor-load sizing |
all_transistors | What is the full, de-duplicated device roster? | per-ref current assignment |
blocks | Load kind? Stage count? Fully differential? | gain factor, headroom, evaluation |
cascode_refs | Which devices are series-stacked? | DC headroom budget (bias.py) |
warnings | Does the netlist actually match the topology? | honest metric de-rating |
Looking ahead: five of those fields come straight from imported helpers. The one piece of real logic in this file is the quiet fix that runs before the roster is built — Part 3.
Part 3 · _adopt_orphan_mosfets() — rescuing the strays
The one job: find MOSFETs the recogniser left in no slot at all, and — using the slot name baked into their
ref— walk each one into the slot it belongs to, so it doesn't silently go unsized.
This is the only function in the file with genuine logic, so it earns the most attention.
The problem, in plain words
Pattern matching is imperfect. Sometimes the recogniser can't confidently place a device in any slot — maybe there are two identical parallel diodes on one net and it can't tell them apart, or maybe a bias leg's expected partner lives in a different slot. That device ends up an orphan: it's in the netlist, but in none of the slot lists.
Why is that dangerous? Because the sizer only sizes devices it can see in a slot. An orphan is invisible to sizing — so it would silently run at the simulator's default W/L, quietly corrupting the whole circuit's behaviour. Nobody would get an error; the numbers would just be wrong.
The rescue trick: synthesized netlists name every device
{ref}_{slot_name}. The slot is stamped right into the name — like a coat-check tag. So even if the recogniser dropped a device, its own name tells us where it belongs.
Analogy: a guest wanders out of the party into the hallway, unlisted. But their name badge reads Alice_ballroom. We read "ballroom" off the badge and walk them back in. A guest whose badge is a foreign name with no matching room (an external, hand-written netlist) is left alone — we don't force strangers into rooms.
def _adopt_orphan_mosfets(slot_transistors, fbr_result, topology) -> None:
assigned = {d.ref for devs in slot_transistors.values() for d in devs}
slot_names = sorted((s.name for s in topology.slots), key=len, reverse=True)
candidates = [d for s in fbr_result.unassigned_structures for d in s.devices]
candidates += list(fbr_result.unrecognized_devices)
for dev in candidates:
if dev.type not in ("nmos", "pmos") or dev.ref in assigned:
continue
slot = next((s for s in slot_names if dev.ref.endswith("_" + s)), None)
if slot is not None:
assigned.add(dev.ref)
slot_transistors.setdefault(slot, []).append(dev)
Five beats:
assigned— collect every ref already placed, so we never adopt a device twice.slot_names— the topology's slot names, sorted longest-first (this matters — see below).candidates— everything the recogniser couldn't place: leftovers fromunassigned_structuresplus fullyunrecognized_devices.- the
continueguard — skip anything that isn't a MOSFET, or is already placed. - the match — take the first slot name the ref ends with, and slot the device there.
Why sort slot names longest-first?
This is the single subtlest line. Suppose two slots are named stage and second_stage. A ref like M9_second_stage ends with both "_stage" and "_second_stage". If we tested stage first we'd mis-file it. Sorting longest-first makes the most specific match win — second_stage is checked before stage. I really like this: one sorted(..., key=len, reverse=True) quietly removes a whole class of prefix-collision bugs.
Traced with real names
Say the topology has slots {input_pair, tail_current, load, bias_gen}, and after extraction the slots already hold {M1, M2, M3, M4}. Four candidates arrive in the hallway:
Trace the loop by hand:
M7_tail_current : nmos ✓, unassigned ✓, ends "_tail_current" ✓ → adopt into tail_current
M8_load : pmos ✓, unassigned ✓, ends "_load" ✓ → adopt into load
R1 : type "resistor" ✗ → skip (continue)
Mext : nmos ✓, but ends no slot name → slot = None, skip
Two subtle safety features worth naming. First, the function mutates slot_transistors in place and returns None — it's a surgical patch on an existing dict, not a rebuild. Second, adopting M7 immediately adds its ref to assigned, so the same device can never be double-adopted even if it appears in both candidate lists.
Looking ahead: now that every device is in a slot, Part 4 watches the conductor assemble the full
CircuitViewin seven calls.
Part 4 · analyze_circuit() — the assembly line
The one job: run the seven helpers in the right order and pack their outputs into one
CircuitView. It holds no logic of its own — it's pure orchestration.
def analyze_circuit(fbr_result, topology) -> CircuitView:
slot_transistors = extract_slot_transistors(fbr_result)
_adopt_orphan_mosfets(slot_transistors, fbr_result, topology) # patch strays first!
slot_resistors = extract_slot_resistors(fbr_result)
return CircuitView(
slot_transistors=slot_transistors,
slot_resistors=slot_resistors,
all_transistors=deduplicate_devices(slot_transistors),
blocks=build_blocks(slot_transistors, slot_resistors),
cascode_refs=cascode_device_refs(slot_transistors),
warnings=check_topology_match(slot_transistors, topology.name),
)
The single most important line
It's the order of the first two statements. extract_slot_transistors() builds the raw slot map; then _adopt_orphan_mosfets() patches it before anything else reads it. Every downstream call — dedup, blocks, cascode, warnings — takes slot_transistors as input, so the orphans must be adopted first or they'd be missing from all four derived views at once. Get this order wrong and an orphan silently vanishes from the roster, the floor-plan, the cascode set, and the warnings.
The four derived helpers each read the patched slot_transistors and answer one question:
| Call | Produces | How |
|---|---|---|
deduplicate_devices | all_transistors | ref → (Device, slot), highest-priority slot wins ties |
build_blocks | blocks | typed groups + load kind, stage count, differential flag |
cascode_device_refs | cascode_refs | devices whose source sits on another's drain |
check_topology_match | warnings | a stage slot with no signal device → mismatch advisory |
Part 5 · The hidden idea, the limits, and one sentence
The hidden idea: derive-once, read-many
The file never says it, but it is implementing a classic pattern — a single source of structural truth. Compute every structural fact once, from one patched input, into one immutable record; then let every later phase read that record instead of re-deriving. The payoff isn't just speed. It's consistency: because blocks, cascode_refs, and all_transistors all descend from the same slot_transistors (patched at exactly one point), no two phases can ever disagree about what the circuit contains.
The subtle reason all_transistors is deduplicated
A single transistor can honestly belong to two slots. The classic case: a tail mirror's reference diode appears in both tail_current and bias_gen — it's one physical device wearing two hats. If we assigned it a current twice, we'd double-count. So deduplicate_devices keeps each ref once, attributing it to the highest-priority slot.
tail_current so its current is assigned exactly once.What this file honestly does not do
- It computes no electrical values — no currents, no widths, no voltages. It is purely structural; sizing happens in later phases.
- Orphan adoption only works on synthesized netlists (the
{ref}_{slot}convention). External, hand-written netlists whose refs match no slot are deliberately left untouched. - It only adopts MOSFETs (
nmos/pmos); orphan resistors are handled elsewhere. - The topology check only flags one failure mode — a gain-stage slot with no signal transistor. It warns; it never blocks. The final say belongs to the downstream simulator.
Why it's elegant
The whole file is a study in doing one thing at one place. All the messy reconciliation — orphan rescue, deduplication, typing, cascode detection — is concentrated here, once, so that everything downstream can be written as if the circuit were already perfectly structured. The 12 lines of analyze_circuit() read like a table of contents precisely because each helper does its job in isolation and the conductor just sequences them.
In one sentence: analyze.py is Phase 1 of the gm/Id sizer — it takes the recogniser's messy result, quietly adopts any MOSFETs left orphaned (by the slot name baked into their ref), and then derives, once and read-only, the six structural facts every later phase needs — per-slot device lists, a deduplicated ref→(device, slot) roster, the typed block view, the cascode set, and topology-mismatch warnings — packaged as one immutable CircuitView.