blocks.py — explained

circuitgenome/sizer/gmid/blocks.py — the structural layer that reads a pile of recognised transistors and says which ones form each functional block, and what kind of load and tail they are.

Part 1 · Why this file even exists

The one job: given the raw pile of transistors the recogniser dropped into named slots, decide which devices form the input pair, the load, the tail — and, crucially, what kind of load and tail they are (mirror? cascode? resistor? plain current source?).

Here is the whole story before a single line of code.

Upstream, a recogniser matches your netlist against known op-amp topologies and drops each device into a named FBR slotinput_pair, load, tail_current, second_stage, and so on. But a slot is just a list of devices. It knows where a transistor sits, not how it behaves.

Think of the recogniser as an orchestra roster: "these eight musicians showed up, here's the section each sits in." blocks.py is the conductor's chart laid over that roster: "these two are the first violins (input pair); those two are cellos that play in unison (a current mirror); that one keeps time in the back (the tail)." Same people — but now grouped by role and, more importantly, labelled by how they combine.

Why does the label matter so much? Because the downstream Analyze phase can't compute anything without it:

  • A mirror load recombines both halves of the differential signal at the output — full first-stage gain. A pair of plain current sources passes only one half — half the gain. Same two PMOS devices; a 2× difference in the answer.
  • A cascode tail is two transistors stacked in series — it needs the sum of two saturation voltages of DC headroom. Miss the stack and bias.py budgets for one device and silently overruns the supply.
  • The evaluate phase needs a cascode-aware output resistance at the gain node — and cascoding multiplies ro by ~100×.
FBR slots (raw lists) input_pair: [m1, m2] load: [m3, m4] tail_current: [m5] second_stage: [m6] build_blocks() OpAmpBlocks (typed) Block "input_pair" Block "load" kind = MIRROR Block "tail_current" is_diff = False n_stages = 2
Same devices in, structural meaning out. The word MIRROR on the load block is the whole reason this file is worth writing.

So blocks.py never simulates anything. It reads only connectivity — which terminal touches which net — and infers topology from it. That is the entire trick, and we'll name it explicitly in Part 8.

The cast of characters

The file has three independent entry points, each with its own little family of helpers. Hover any box to trace who it calls.

recurses build_blocks() assemble the whole block view classify_load() name the load kind Block / OpAmpBlocks the typed data model _has_cascode() series-stacked? _has_diode() diode-connected? node_rout() cascode-aware output R _looking_in_drain() R into one drain (recursive) cascode_device_refs() refs of the stacked devices
Three entry points (accent), each with helpers. We learn them bottom-up: the two detectors first, then classification, then the resistance recursion, then the data model, and the orchestrator last.

Looking ahead: Part 2 builds the vocabulary — the five LoadKind labels and the two tiny detectors (_has_diode, _has_cascode) that read raw terminals to tell diode-connection from series-stacking.

Part 2 · The vocabulary and the two detectors

The one job: from a bag of devices and their terminal wiring, answer two yes/no questions — "is any device diode-connected?" and "are any two devices series-stacked?" — because those two facts decide everything.

First, the five labels

A "load" is whatever the input pair pushes its current into at the output node. How it turns that current into a voltage sets the gain and how much supply room it eats. The file names five possibilities:

class LoadKind(Enum):
    MIRROR = "mirror"               # active current-mirror (has a diode-connected member)
    CASCODE = "cascode"             # series-stacked devices
    RESISTOR = "resistor"           # resistor load
    CURRENT_SOURCE = "current_source" # plain (non-mirror) current-source load
    NONE = "none"
MIRROR a member has g == d recombines both halves → full gain CASCODE one dev's source s == another d huge rout, but eats headroom RESISTOR no mosfets, resistors present low gain, wide swing CURRENT_SOURCE plain mos, no diode, no stack single-ended → half gain (a fifth label, NONE, means "no load devices at all")
Every distinction reduces to a terminal equality: g == d (mirror) or one device's s == another's d (cascode). No simulation — just wiring.

Detector 1 — _has_diode: is anyone wired gate-to-drain?

def _has_diode(devs: list[Device]) -> bool:
    return any(d.type in ("nmos", "pmos") and d.terminals.get("g") == d.terminals.get("d")
               for d in devs)

A transistor whose gate is soldered to its own drain is diode-connected. That is the tell-tale of a current mirror's reference leg: it converts the incoming current into exactly the gate voltage needed to mirror it into its partner. So the test is one line — does any MOS have g and d on the same net?

Detector 2 — _has_cascode: is anyone standing on someone's shoulders?

def _has_cascode(devs: list[Device]) -> bool:
    mos = [d for d in devs if d.type in ("nmos", "pmos")]
    drains = {d.terminals.get("d") for d in mos}
    return any(d.terminals.get("s") in drains for d in mos)

Two devices are series-stacked when the upper one's source lands on the lower one's drain — the drain of the bottom transistor is the source node of the one above it. So: collect the set of every drain net, then ask whether any device's source is one of those nets. If yes, someone is standing on someone else's shoulders — a cascode.

Traced with real nets

Take a classic PMOS current-mirror load, load = [m3, m4], with this wiring:

devicetypegds
m3pmosn1n1vdd!
m4pmosn1outvdd!
_has_diode → True m3 g = n1 == d = n1 gate wired back to its own drain → mirror reference leg _has_cascode → False drains = { n1, out } sources = { vdd!, vdd! } no source sits on a drain (both sources are the rail)
For a mirror load: the diode test fires on m3, the cascode test doesn't (both sources are the vdd! rail). That pair of answers is exactly what Part 3 turns into a label.

Looking ahead: Part 3 chains these two booleans into classify_load() — a priority ladder that turns "diode? stacked?" into one LoadKind — and meets its cousin cascode_device_refs().

Part 3 · classify_load() and cascode_device_refs()

The one job: collapse the two detector answers (plus "any resistors? any mosfets?") into a single LoadKind, checking the possibilities in the right priority order.

def classify_load(mosfets, resistors) -> LoadKind:
    if _has_cascode(mosfets):
        return LoadKind.CASCODE
    if _has_diode(mosfets):
        return LoadKind.MIRROR
    if resistors:
        return LoadKind.RESISTOR
    if mosfets:
        return LoadKind.CURRENT_SOURCE
    return LoadKind.NONE

It's a waterfall of guards: the first one that fires wins and the rest never run. Read top to bottom like a triage nurse asking the most consequential question first.

_has_cascode? yes CASCODE no _has_diode? yes MIRROR no resistors present? yes RESISTOR no any mosfets? yes CURRENT_SOURCE no NONE
First match wins. Cascode is asked before mirror because a cascoded mirror is still fundamentally a stack — the extra device is what matters for headroom.

Run our mirror example through it: _has_cascode([m3,m4]) is False (Part 2), _has_diode([m3,m4]) is True → it stops at the second rung and returns LoadKind.MIRROR. Exactly the label on the block in the Part 1 pipeline figure.

Why cascode outranks mirror

Order is not arbitrary. A wide-swing cascode mirror also has a diode-connected device, so it would pass the mirror test too. But its defining structural fact — the extra stacked transistor that eats a whole Vdsat of headroom — is what the DC budget must know about. So the more consequential label wins the tie. That's the single most important design decision in this tiny function.

The cousin: cascode_device_refs()

_has_cascode answers "is there a stack?" (a boolean). Sometimes callers need "which devices are the stacked ones?" (a set of refs). That's this function — same idea, but it names names:

def cascode_device_refs(slot_transistors) -> set[str]:
    out = set()
    for devs in slot_transistors.values():
        mos = [d for d in devs if d.type in ("nmos", "pmos")]
        drains = {d.terminals.get("d") for d in mos}
        for d in mos:
            src = d.terminals.get("s")
            if src not in RAILS and src in drains and not is_signal_device(d):
                out.add(d.ref)
    return out

Three conditions must all hold for a device to count as a cascode (upper) device:

  • src not in RAILS — its source is not a supply rail (a rail-source device sits at the bottom, not on top of anyone).
  • src in drains — its source is some sibling's drain (it's standing on that device).
  • not is_signal_device(d) — its gate is a bias net, not a signal net. The signal transistor of a stage isn't the cascode; the quiet, bias-gated one sitting above it is.

So where _has_cascode returns a yes/no for one slot, cascode_device_refs sweeps every slot and hands back the exact refs of the wide-swing cascode devices, so other passes can single them out.

Looking ahead: a cascode is worth detecting because it multiplies output resistance. Part 4 shows the recursion that actually computes that boost, _looking_in_drain().

Part 4 · _looking_in_drain() — resistance into one drain

The one job: stand at a transistor's drain and ask "how many ohms do I see looking in?" — and if that transistor is cascoded (its source sits on another device below), multiply its own ro by the cascode-boost factor.

def _looking_in_drain(device, by_drain, model, sizing, stop) -> float:
    s = sizing.get(device.ref)
    if s is None: return float("inf")
    gds = model.gds(device.type, s.w_um, s.l_um, s.ids_a)
    ro = 1.0 / gds if gds > 0 else float("inf")
    src = device.terminals.get("s")
    if src in RAILS or src in stop or src is None:
        return ro                                # bottom of the stack: just ro
    below = by_drain.get(src)
    if below is not None and below.ref != device.ref and below.type == device.type:
        gm = model.gm(device.type, s.w_um, s.l_um, s.ids_a)
        r_src = _looking_in_drain(below, by_drain, model, sizing, stop)   # recurse down
        return ro * (1.0 + gm * r_src) if r_src != float("inf") else float("inf")
    return ro

The intuition, no notation

A lone transistor acting as a current source has some output resistance ro — call it "leakiness." Higher ro is better (stiffer current, more gain). A cascode stacks a second transistor on top and produces a near-magical improvement: the top device watches its own source node and pushes back against any change, so the stack looks vastly stiffer than either device alone.

The analogy is a staircase. To know the resistance at the top step, you first need the resistance of the step below — and that step needs the one below it. You can only answer the top by climbing all the way down to solid ground (a rail), then multiplying your way back up. That "you need the step below first" is exactly why this is recursive.

Rin= ro (1+ gm Rsrc)

Read it plainly: your resistance is your own ro, amplified by (1 + gm · R_below). When there's nothing below you but a rail, R_below = 0 in effect, the factor is 1, and you contribute just ro.

Traced with real numbers

Two NMOS in a telescopic stack, both sized so gds = 10 µS (so ro = 100 kΩ) and gm = 1 mS:

deviceroleds
m_casccascode (upper)outnmid
m_botbottomnmidvss!
look in m_casc (s = nmid) nmid is a drain below → must recurse look in m_bot (s = vss!) source is a RAIL → base case, return ro R_src = 100 kΩ base: R(m_bot) = ro = 100 kΩ boost: R(m_casc) = ro · (1 + gm · R_src) = 100k · (1 + 1m · 100k) = 100k · 101 R_in = 10.1 MΩ
Descend until a rail (the base case flips it), then multiply back up. One cascode turns 100 kΩ into 10.1 MΩ — a ~101× boost, exactly (1 + gm·ro).

The three guards worth naming

  • src in stopstop lists nets to treat as AC ground (typically the input-pair tail node). A device sourcing into an AC ground contributes plain ro, not a degenerated cascode. This is how the differential half-circuit is modelled correctly.
  • below.type == device.type — the boost only applies between same-type stacked devices. A different-type neighbour isn't a cascode partner; the device falls back to ro.
  • below.ref != device.ref — a device can't be its own cascode below; guards a self-reference.

And the honest limitation: an infinite ro anywhere (a device with gds = 0, or unsized and missing from sizing) short-circuits to inf rather than pretending — the caller will treat that branch as open.

Looking ahead: one drain is only half the story. Part 5's node_rout() collects every device pulling on an output node and combines them in parallel.

Part 5 · node_rout() — parallel-combine at the output

The one job: at a given output net, find every transistor whose drain lands there, ask each one its cascode-boosted resistance, and combine them all in parallel into one output resistance.

def node_rout(out_net, mosfets, model, sizing, stop=frozenset()) -> float:
    by_drain = {}
    for d in mosfets:
        if d.type in ("nmos", "pmos"):
            by_drain.setdefault(d.terminals.get("d"), d)
    g = 0.0
    for d in mosfets:
        if d.type in ("nmos", "pmos") and d.terminals.get("d") == out_net:
            r = _looking_in_drain(d, by_drain, model, sizing, stop)
            if r > 0:
                g += 1.0 / r
    return 1.0 / g if g > 0 else float("inf")

Why work in conductances?

Resistors in parallel are annoying to add directly, but their conductances (g = 1/R) simply sum. So the function converts each branch's resistance to a conductance, adds them into one running total g, and flips back to a resistance at the very end. Same trick you'd use to add three pipes draining one tank — add the flow rates, not the "restrictions."

The first loop builds by_drain — a map from each net to the device that drives it — so _looking_in_drain can walk the stack. The second loop is the real work: keep only devices whose drain is the output node (those are the ones connected to the node we care about), and sum their conductances.

Traced with real numbers

At out, two branches pull: the NMOS cascode from Part 4 (10.1 MΩ) and a PMOS mirror load contributing plain ro = 400 kΩ.

out NMOS cascode R = 10.1 MΩ PMOS load R = 400 kΩ g = 1/10.1M + 1/0.4M = 0.099 µS + 2.5 µS = 2.599 µS R_out = 1/g ≈ 385 kΩ
The 10.1 MΩ cascode branch is so stiff it barely leaks; the 400 kΩ load dominates, so the parallel total lands just under 385 kΩ. Add conductances, invert once.
Rout= 1 i 1Ri , Ri= ro,i k (1+gmRk)

That boxed pair is the whole cascode-aware output-resistance calculation: Part 4 supplies each stiff R_i, Part 5 parallels them. The evaluate phase reads this to turn a topology into a gain number.

Looking ahead: we've covered every free function. Part 6 turns to the data model — Block, the typed container these labels attach to.

Part 6 · Block — one functional group

The one job: bundle the devices of one slot together with their computed LoadKind, and offer two convenience lookups: "which device carries the signal?" and "is this a cascode?"

@dataclass
class Block:
    name: str                        # "input_pair", "load", "second_stage", ...
    mosfets: list[Device] = field(default_factory=list)
    resistors: list[Device] = field(default_factory=list)
    load_kind: LoadKind = LoadKind.NONE

    @property
    def signal_device(self) -> Device | None:
        return next((d for d in self.mosfets if is_signal_device(d)), None)

    @property
    def is_cascode(self) -> bool:
        return self.load_kind == LoadKind.CASCODE or _has_cascode(self.mosfets)

A Block is just a labelled folder: a name, its mosfets, its resistors, and the one classification verdict. The two properties are shortcuts callers reach for constantly.

Block name = "load" m3 (diode) m4 resistors = [ ] load_kind = MIRROR .signal_device first mos gated by a signal net .is_cascode kind==CASCODE OR structural fallback
Devices plus one verdict, with two derived views. is_cascode has a belt-and-braces design worth a closer look.

Why is_cascode checks two things

Look at the or. It returns true if the stored load_kind already says CASCODE, or if a fresh structural _has_cascode scan finds a stack right now. Why both?

Because only the load slot is ever run through classify_load (we'll see that in Part 8). Every other block — the tail especially — is created with load_kind = NONE. So for a cascoded tail, the stored kind is useless; the only way to know it's a cascode is the live structural scan. The or makes one property honest for both cases.

Why signal_device uses is_signal_device

A gain stage has two transistors that look alike but do opposite jobs: one is driven by the previous stage's signal (the gm-contributor), the other's gate sits on a quiet bias net (the current-source load). is_signal_device tells them apart by the gate net — and it works regardless of NMOS/PMOS polarity. next(...) returns the first signal device, or None if there isn't one. Clean, polarity-agnostic, and reused all over Part 7.

Looking ahead: a single block isn't an op-amp. Part 7 assembles all of them into OpAmpBlocks and derives the gain factor, headroom flags, and node names the analyzer wants.

Part 7 · OpAmpBlocks — the whole-circuit view

The one job: hold every Block plus two global facts (fully-differential? how many stages?), and expose the handful of derived quantities the Analyze phase actually consumes.

@dataclass
class OpAmpBlocks:
    blocks: dict[str, Block]
    is_fully_differential: bool
    n_stages: int

    @property
    def input_pair(self): return self.blocks.get("input_pair")
    @property
    def load(self): return self.blocks.get("load")
    @property
    def tail(self): return self.blocks.get("tail_current")

The three properties are just friendly names over dictionary lookups — opamp.load reads better than opamp.blocks.get("load") and returns None gracefully if the slot is absent. The interesting methods sit on top of them.

The star: first_stage_gain_factor()

def first_stage_gain_factor(self) -> float:
    if self.is_fully_differential:
        return 1.0
    ld = self.load
    if ld and ld.load_kind in (LoadKind.MIRROR, LoadKind.CASCODE):
        return 1.0
    return 0.5

This is where every label we computed pays off. Call it k_fs. A differential pair splits its signal current into two halves. Whether the output sees the full current or just one half depends entirely on the load:

fully differential? yes 1.0 no load kind is MIRROR or CASCODE? yes 1.0 no single-ended, non-mirror load only one half reaches the output 0.5 k_fs multiplies the first-stage gm — a factor-of-two swing on the whole gain estimate
A mirror actively folds the "wasted" half back to the output (full 1.0); a plain current-source load throws it away (0.5). The label we computed in Part 3 is this branch.

Two flags and two node-finders

def has_cascode_load(self) -> bool:
    ld = self.load
    return bool(ld and ld.load_kind == LoadKind.CASCODE)

def has_cascode_tail(self) -> bool:
    tail = self.tail
    return bool(tail and tail.is_cascode)   # structural fallback, not load_kind!

These two look symmetric but aren't. has_cascode_load trusts the stored load_kind — safe, because the load slot was classified. has_cascode_tail can't, because the tail slot was never run through classify_load; it leans on Block.is_cascode's structural _has_cascode fallback from Part 6. That asymmetry is exactly why is_cascode was built with an or.

def first_stage_out_net(self) -> str | None:
    for slot in ("second_stage", "second_stage_p", "second_stage_n",
                 "third_stage", "third_stage_p", "third_stage_n"):
        b = self.blocks.get(slot)
        sig = b.signal_device if b else None
        if sig is not None:
            return sig.terminals.get("g")
    return None

def tail_net(self) -> str | None:
    ip = self.input_pair
    return ip.mosfets[0].terminals.get("s") if ip and ip.mosfets else None

first_stage_out_net asks a clever question sideways: "where does the first stage dump its output?" is the same as "what net drives the next stage's signal transistor?" So it finds the next stage's signal device and returns its gate. It returns None for a one-stage op-amp — there's no downstream gate to read, an honest "I can't know."

tail_net is the simplest: the tail node is just the input pair's shared source, so it grabs input_pair.mosfets[0].terminals["s"]. That net is exactly what Part 4's stop set holds to model the half-circuit.

Looking ahead: everything is now in place. Part 8 is the factory, build_blocks(), that stitches raw slots into this OpAmpBlocks — plus the one idea the whole file quietly implements.

Part 8 · build_blocks(), the hidden idea, and limits

The one job: walk the raw slot device-lists, wrap each into a Block (classifying only the load slot), and stamp the two global facts — differential? stage count? — onto a finished OpAmpBlocks.

def build_blocks(slot_transistors, slot_resistors) -> OpAmpBlocks:
    blocks = {}
    for slot, mosfets in slot_transistors.items():
        rs = slot_resistors.get(slot, [])
        kind = classify_load(mosfets, rs) if slot in ("load",) else LoadKind.NONE
        blocks[slot] = Block(name=slot, mosfets=list(mosfets), resistors=list(rs), load_kind=kind)
    for slot, rs in slot_resistors.items():        # resistor-only slots still need a block
        if slot not in blocks:
            blocks[slot] = Block(name=slot, resistors=list(rs),
                                 load_kind=classify_load([], rs) if slot == "load" else LoadKind.NONE)
    fd = any(s in slot_transistors for s in ("second_stage_p", "second_stage_n",
                                             "third_stage_p", "third_stage_n"))
    has_2 = any(s in slot_transistors for s in SECOND_STAGE_SLOTS)
    has_3 = any(s in slot_transistors for s in THIRD_STAGE_SLOTS)
    n_stages = 1 + (1 if has_2 else 0) + (1 if has_3 else 0)
    return OpAmpBlocks(blocks=blocks, is_fully_differential=fd, n_stages=n_stages)

Three moves, in order:

  • Wrap every transistor slot — but call classify_load only for "load". That single if slot in ("load",) is the reason the tail carries NONE and needs the structural fallback we kept meeting.
  • Catch resistor-only slots — a resistor load has no mosfets, so it never appeared in the first loop; this second pass gives it a block (and still classifies it if it's the load).
  • Compute the two globals — "fully differential" is inferred purely from the presence of _p/_n split-stage slots; the stage count is a plain 1 + has_2 + has_3.
wrap each slot classify "load" only add resistor-only slots not seen yet stamp globals is_diff, n_stages → OpAmpBlocks
Raw slot dicts in, one finished OpAmpBlocks out — the object every method in Part 7 hangs off.

The hidden idea

The file never writes it down, but every function here is one assertion:

connectivity is destiny

It never solves a circuit equation. It reads only which terminal touches which net — g == d, s ∈ drains, "is this gate a signal or a bias net" — and from that pure graph topology it derives the three things the whole sizer downstream depends on:

Topological factRead byDrives
diode-connected member (mirror)_has_diodefirst-stage gain factor k_fs
series-stacked devices (cascode)_has_cascodeDC headroom budget (bias.py)
drains sharing an output netnode_routcascode-aware output resistance (evaluate)

What it honestly does not do

  • Only the load slot is classified. Every other block — including the tail — carries load_kind = NONE and relies on the structural is_cascode fallback.
  • The detectors are heuristics on connectivity: _has_cascode fires on any same-type series stack, and the cascode boost in _looking_in_drain only chains same-type neighbours — a mixed or exotic stack degrades gracefully to plain ro rather than modelling it exactly.
  • first_stage_out_net returns None for a one-stage op-amp (no downstream gate to read), and tail_net returns None if there's no input pair.
  • The gain factor is a coarse two-value model — exactly 0.5 or 1.0, not a continuum.
  • Nothing is simulated. This is a structural layer; SPICE has the final word downstream.

Why it's elegant

The whole file rides one insight: you can read an amplifier's electrical behaviour off its wiring graph alone. Two one-line terminal tests (g==d, s∈drains) plus a shallow recursion recover the gain factor, the headroom demand, and the output resistance — no matrices, no simulation — giving the Analyze phase a fast, cheap, honest structural read of what it's holding.


In one sentence: blocks.py reads only the terminal-connectivity of a recognised op-amp — g==d for mirrors, one device's source on another's drain for cascodes — to group devices into typed Blocks and derive the first-stage gain factor, cascode headroom flags, and a cascode-aware output resistance, forming the structural layer the Analyze, bias, and evaluate phases all build on.