analyze.py — explained

circuitgenome/sizer/gmid/analyze.py — Phase 1: survey the recognised circuit once, hand every later phase a frozen structural clipboard.

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.

recogniser FBR + topology Phase 1 Analyze this file — runs once CircuitView the clipboard read-only Size Bias Evaluate
The recogniser says what the circuit is; Analyze surveys how it is built, once, into a frozen record every later phase reads but never rewrites.

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.

analyze_circuit() extract_slot_transistors() slot → MOSFETs (from FBR) _adopt_orphan_mosfets() local — rescue the strays extract_slot_resistors() slot → resistors deduplicate_devices() ref → (Device, slot) build_blocks() typed floor-plan cascode_device_refs() who is stacked check_topology_match() red-flag warnings the boxed helper is the only one defined in this file; the rest are imported from preprocess.py and blocks.py
One conductor, seven players. We'll learn the local helper (_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 CircuitView and 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:

CircuitView slot_transistors slot_resistors all_transistors blocks cascode_refs warnings per-slot sizing input resistor-load sizing iDS assigned once per ref gain factor / stage count DC headroom budget (bias) honest metric de-rating
Six fields, six readers. The highlighted one, 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.

FieldAnswers the question…Who reads it
slot_transistorsWhich MOSFETs are in each slot?the sizer's iDS/width assignment
slot_resistorsWhich resistors are in each slot?resistor-load sizing
all_transistorsWhat is the full, de-duplicated device roster?per-ref current assignment
blocksLoad kind? Stage count? Fully differential?gain factor, headroom, evaluation
cascode_refsWhich devices are series-stacked?DC headroom budget (bias.py)
warningsDoes 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 from unassigned_structures plus fully unrecognized_devices.
  • the continue guard — 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:

hallway — orphan candidates M7_tail_current (nmos) M8_load (pmos) R1 (resistor) Mext (nmos, foreign ref) ends _tail_current ends _load not a MOSFET no slot suffix slot_transistors — after tail_current = [M3, M7] load = [M4, M8] R1 → handled elsewhere Mext → left untouched slot names checked longest-first: tail_current (12) · input_pair (10) · bias_gen (8) · load (4) so the most specific suffix always wins the match
Two strays walk home by their name badge; a resistor (wrong type) and a foreign-named device (no matching slot) are correctly left alone.

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 CircuitView in 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.

fbr_result topology extract_slot_transistorsraw slot map _adopt_orphan_mosfetspatch in place deduplicate_devices build_blocks cascode_device_refs check_topology_match (extract_slot_resistors runs alongside, feeding blocks) ↑ all packed into ↓ CircuitView
Two serial stations (extract, then patch), then a fan-out of four derived views — all reading the already-patched slot map, all collected into one record.

The four derived helpers each read the patched slot_transistors and answer one question:

CallProducesHow
deduplicate_devicesall_transistorsref → (Device, slot), highest-priority slot wins ties
build_blocksblockstyped groups + load kind, stage count, differential flag
cascode_device_refscascode_refsdevices whose source sits on another's drain
check_topology_matchwarningsa 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[..., Mref] bias_gen[..., Mref] priority: tail_current (2) < bias_gen (12) wins dropped all_transistors Mref → (Device, "tail_current")
One physical device, two slots, counted once — attributed to the higher-priority 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.