preprocess.py — explained

circuitgenome/sizer/shared/preprocess.py — the model-independent pre-sizing both sizers run before any geometry is chosen.

Part 1 · Why this file even exists

The one job: take a recognized netlist plus a performance spec and work out everything that can be decided before you pick a transistor model — which device carries which current, which node needs which gm, how big the load resistor and compensation cap must be — so that both sizers start from the same, already-solved pre-work.

Here is the whole story with no code.

Sizing an op-amp is a big job, and CircuitGenome does it two very different ways: a fast Level-1 analytical sizer (square-law hand equations) and a table-driven gm/Id pipeline (silicon lookup tables). But a surprising amount of the work is identical no matter which one you use.

Think of building a house. Before you choose whether the walls are brick or timber, you still have to read the blueprint, count the rooms, and decide how much load each beam carries. That structural pre-work does not care about the wall material. This file is exactly that pre-work — it is factored out of both sizers and run once, up front.

What can be decided model-independently? A lot:

  • Which transistors are where — pull the MOSFETs and resistors out of each recognized slot.
  • How much current each carries — Kirchhoff's current law plus the one number the user gives, spec.ibias.
  • How big the load resistor must be — Ohm's law, R = V / I.
  • How much gm each gain device needs — read straight off the GBW / gain / CMRR / phase-margin spec.

The only place a device model sneaks in is the output conductance gds — and even that is asked through a DeviceModel handle, so the gm/Id path gets LUT-accurate gds while Level-1 reproduces the analytical λ·Id. Same code, both answers. We'll unpack that at the very end.

SizingSpec ibias, GBW, gain… FBR netlist slot assignments 1 · extract slots (mosfets + resistors) 2 · deduplicate refs 3 · assign IDS (KCL + ibias) 4 · topology sanity check 5 · size load resistors 6 · gm & VDS_sat requirements + Cc, Cc2 Level-1 sizer reproduces λ·Id gm/Id pipeline LUT-accurate gds same pre-work → both
Read the netlist, decide currents and targets model-independently, hand the identical pre-work to whichever sizer runs. That is the whole file.

The cast of characters

Nine functions. Five are little leaf helpers the sizer calls in order; two are the big passes (assign_ids and compute_requirements), each with one private helper. We'll learn the leaves first, so the two passes read easily. Hover any box to trace its link.

sizer driver (external — calls in order) extract_slot_transistors / extract_slot_resistors deduplicate_devices each ref once, by priority assign_ids KCL current to each device check_topology_match warn on netlist mismatch size_load_resistors R = V_node / (ibias/2) compute_requirements gm, VDS_sat, Cc targets _cascode_load_current_plan KCL at the folding node _first_stage_gain_factor k_fs = 1.0 or 0.5
Five leaf helpers (top + left) and the two orchestrating passes (right, teal). We climb bottom-up: leaves first, passes last.

Looking ahead: Part 2 starts with the humblest pair of functions — just pulling transistors and resistors out of the recognized slots.

Part 2 · Slot extraction — the humble first step

The one job: the recognizer already grouped devices into named slots (input_pair, load, tail_current…); these two functions just fish the MOSFETs (or the resistors) out of each slot into a tidy {slot: [devices]} dictionary.

def extract_slot_transistors(fbr_result) -> dict[str, list[Device]]:
    result = {}
    for slot_name, sa in fbr_result.slot_assignments.items():
        mosfets = [d for d in sa.structure.devices if d.type in ("nmos", "pmos")]
        if mosfets:
            result[slot_name] = mosfets
    return result

Analogy: the recognizer handed us a set of labeled drawers (a drawer marked input_pair, one marked load, …). Each drawer is a jumble of parts. This function walks every drawer and keeps only the transistors, skipping caps, resistors, and anything else. extract_slot_resistors is the identical walk, but it keeps type == "resistor" instead.

The one subtlety: a slot with no matching device is dropped, not stored as an empty list. That's the if mosfets: guard. So later code can write slot_transistors.get("load", []) and trust that a present key always has at least one device — a small invariant that removes a lot of "is it empty?" checks downstream.

slot_assignments input_pair M1 nmos · M2 nmos (2 devices) load R1 resistor M3 pmos bias_gen Cc cap only (no mosfet) keep mos empty → dropped extract_slot_transistors → input_pair: [M1, M2] load: [M3] bias_gen absent — no mosfet to keep extract_slot_resistors → load: [R1]
One pass, one type filter. A slot survives only if it actually holds a device of the wanted kind — so present keys are never empty.

That empty-drop rule is the whole cleverness. Everything that follows leans on it.

Part 3 · size_load_resistors() — Ohm's law biases a node

The one job: pick the resistance of a resistor load so that the DC current flowing through it drops just enough voltage to switch the next transistor on — nothing more than R = V / I.

Analogy: a resistor with current flowing through it is a voltage divider you tune by choosing the resistance. You know the current (physics fixed it). You know the voltage you want at the node (enough to turn the driven device on). So the resistance is forced: R = wanted-drop / current.

How much drop do we want? Enough that the output node sits at a threshold plus a little overdrive — that 0.15 V constant:

_RESISTOR_LOAD_OVERDRIVE = 0.15   # V_node ≈ Vth + this

def size_load_resistors(slot_resistors, spec, tech) -> dict[str, float]:
    branch_i = spec.ibias / 2.0          # each load carries one pair branch
    if branch_i <= 0: return {}
    vov = _RESISTOR_LOAD_OVERDRIVE
    for r in slot_resistors.get("load", []):
        nets = [str(n).lower() for n in r.terminals.values()]
        if any("gnd" in n or n in ("0", "vss!") for n in nets):
            v = tech.nmos.vth + vov            # resistor to gnd → NMOS turns on
        elif any("vdd" in n for n in nets):
            v = abs(tech.pmos.vth) + vov         # resistor from vdd → PMOS turns on
        else:
            continue                            # not rail-referenced → skip
        if v > 0:
            out[r.ref] = v / branch_i
    return out

Traced with real numbers

Take spec.ibias = 20 µA, so each branch carries 10 µA. The resistor connects to gnd, so it must lift its node high enough to turn an NMOS on. With Vth,n = 0.45 V and Vov = 0.15 V:

V_node = Vth_n + Vov = 0.45 + 0.15 = 0.60 V
R      = V_node / branch_i = 0.60 V / 10 µA = 60 kΩ
gnd rail (0 V) R 60 kΩ 10 µA V_node = 0.60 V → turns NMOS on (Vth 0.45 + Vov 0.15) R = V / I 0.60 / 10µA = 60 kΩ
The current is fixed; the wanted node voltage is fixed; so the resistance is forced. Ohm's law, nothing more.

Honest limits. Only the load slot is sized, and only if the resistor actually touches a rail (gnd/vss!/0, or vdd). A resistor floating between two internal nodes hits the else: continue and keeps whatever value the netlist gave it — this function refuses to guess when it can't reason about the drop. The polarity mirror is neat: a resistor to ground lifts its node to switch an NMOS on; a resistor from vdd drops from the top to switch a PMOS on.

Part 4 · check_topology_match() — the sanity inspector

The one job: catch the case where the netlist you handed in does not actually realize the topology you asked to size for — and say so, loudly, instead of silently producing garbage gain/PM numbers.

Analogy: you asked the builder for a two-car garage, but the blueprint clearly shows one car space. Rather than pour a foundation that half-fits, the inspector stops and says "this blueprint doesn't match the order." That's this function.

The tell-tale sign of a valid gain stage: every stage slot holds exactly one signal transistor — a device whose gate is driven by the previous stage's output, not a bias rail. If a stage slot exists but contains no signal device (just bias-generator leftovers shoved in), the netlist doesn't match the chosen topology.

def check_topology_match(slot_transistors, topology_name) -> list[str]:
    warnings = []
    for slot in sorted(STAGE_SLOTS):
        devs = slot_transistors.get(slot)
        if devs and not any(is_signal_device(d) for d in devs):
            warnings.append(f"stage slot '{slot}' has no signal transistor …")
    return warnings
stage slot holds a signal transistor? yes no valid gain stage no warning topology mismatch warn: check --topology
An occupied stage slot with only bias devices means single-ended vs fully-differential was mixed up — worth a warning, not silence.

Why does this matter so much? Because the downstream gain, phase-margin, and PSRR math reads the second/third-stage signal device. If that device is missing, those metrics would silently drop out and the sizer would happily report numbers for a circuit it never actually sized. A concrete trigger: sizing a single-ended netlist against a fully-differential --topology, where a bias-generator leftover gets shoehorned into second_stage_p. The function doesn't fix it — it just refuses to be silent about it.

Note the if devs and …: an absent stage slot is fine (a two-stage amp simply has no third stage). Only a slot that exists but has no signal device trips the warning.

Part 5 · deduplicate_devices() — one ref, one home

The one job: a single physical transistor can appear in two slots at once; before we assign it a current, decide which slot "owns" it — the higher-priority slot wins.

Analogy: an employee is listed on two org charts (they help two teams). For payroll, you must pick exactly one home team so they don't get paid twice. This function picks that home team using a fixed priority list.

The classic collision: a tail-mirror reference transistor legitimately shows up in both tail_current and bias_gen. If we assigned current from both slots we'd double-count. So we impose an order and let the first slot encountered win.

def deduplicate_devices(slot_transistors) -> dict[str, tuple[Device, str]]:
    priority = ["input_pair", "load", "tail_current",
                "second_stage", …, "bias_gen"]
    ordered = sorted(slot_transistors.keys(),
        key=lambda s: priority.index(s) if s in priority else len(priority))
    seen = {}
    for slot in ordered:
        for d in slot_transistors[slot]:
            if d.ref not in seen:
                seen[d.ref] = (d, slot)     # first (highest-priority) slot wins
    return seen

The two-line trick worth admiring: sorted(..., key=priority.index) walks the slots in priority order, and if d.ref not in seen means a ref is claimed exactly once — by whichever high-priority slot reached it first. Unknown slots sort to the very end (len(priority)), so they only ever claim refs nobody else wanted.

tail_current priority #3 holds MR bias_gen priority #13 (last) also lists MR wins ✓ skipped (already seen) seen["MR"] = (MR, "tail_current") counted once, in tail_current
MR sits in two slots; priority order lets tail_current claim it first, so bias_gen's copy is skipped and never double-counted.

The output shape — {ref: (Device, slot)} — is exactly what the next pass wants: a flat list of every unique transistor, each tagged with the one slot that decides its current.

Part 6 · assign_ids() — Kirchhoff hands out the currents

The one job: give every transistor its quiescent drain current, worked out purely from where it sits in the circuit (its slot) and the one bias number the user supplied, spec.ibias.

Analogy: ibias is a river of current entering at the tail. Kirchhoff's law says whatever flows in must split and flow out — so each branch's share is fixed by the wiring, not by any transistor model. A differential pair splits the tail evenly: two branches, half each.

The slot names encode the split. The taxonomy groups them:

Slot groupRuleCurrent each device gets
HALF_BIAS_SLOTS
input_pair, load
2-device group splits the tailibias / n
FULL_BIAS_SLOTS
tail_current, bias_gen
carries the whole tailibias
SECOND_STAGE_SLOTSscaled by ratioibias · ratio₂
THIRD_STAGE_SLOTSscaled by ratioibias · ratio₃
for ref, (device, slot) in all_transistors.items():
    if slot == "load" and ref in cascode_load:
        ids_map[ref] = cascode_load[ref]        # special: folding/cascode plan
    elif slot in HALF_BIAS_SLOTS:
        n = len([d for d in slot_transistors[slot] if d.type == device.type])
        ids_map[ref] = spec.ibias / max(n, 1)
    elif slot in FULL_BIAS_SLOTS:
        ids_map[ref] = spec.ibias
    elif slot in SECOND_STAGE_SLOTS:
        ids_map[ref] = ids_2                     # ibias · second_stage_current_ratio
    elif slot in THIRD_STAGE_SLOTS:
        ids_map[ref] = spec.ibias * spec.third_stage_current_ratio
    else:
        ids_map[ref] = spec.ibias                # conservative default

The easy split, traced

With ibias = 20 µA, an input_pair of two NMOS devices: n = 2, so each gets 20 / 2 = 10 µA. The tail_current device carries the full 20 µA. Clean KCL.

The hard split: _cascode_load_current_plan()

The generic "half each" rule starves a folded/telescopic cascode load. At the folding node, the bottom sink must swallow the input-pair branch current plus the cascode branch current — otherwise the pair's excess current has nowhere to go and the whole load rails. So the helper reads the assembled netlist and does real KCL at that node:

pair_drains = {d.terminals.get("d") for d in pair}
cascode = {d.ref for d in load if d.terminals.get("s") in pair_drains}
if not cascode: return {}                          # simple load → generic rule
folding = {d.ref for d in load
           if d.terminals.get("s") in RAILS
           and d.terminals.get("d") in pair_drains}
i_pair = spec.ibias / 2.0
if not folding:
    return {d.ref: i_pair for d in load}        # telescopic: whole stack = ibias/2
i_casc = spec.ibias / 2.0
return {d.ref: (i_pair + i_casc if d.ref in folding else i_casc)
        for d in load}
folding node pair branch i_pair = 10 µA cascode branch i_casc = 10 µA folding dev source on rail must sink 10 + 10 = 20 µA supply rail KCL: in = out
The folding device carries both branches (ibias/2 + ibias/2 = ibias); every plain cascode device carries only the cascode branch ibias/2. The generic "half each" rule would have starved it.

Three shapes fall out of the same helper:

  • Simple load (nothing stacked on the pair drains) → returns {}, keep the generic rule.
  • Telescopic (cascode devices, no folding devices) → whole stack carries ibias/2.
  • Folded → folding devices carry ibias/2 + ibias/2; cascode devices carry ibias/2.

This is the file's one genuine topology-reading moment — everything else keys off slot names, but here it walks terminals to find the fold.

Part 7 · _first_stage_gain_factor() — the ½ that bites

The one job: return k_fs = 1.0 or 0.5 — a single number that says whether the first stage delivers its full transconductance to the loop, or only half of it because a non-mirror load throws one branch away.

Analogy: a differential pair generates signal in two branches. To use both, you need a load that folds them together — an active current mirror. A plain resistor or a non-mirror current-source load only lets one branch through, and you lose half the gm. It's like a two-lane road merging: a smart merge keeps all the traffic; a bad one drops a lane.

def _first_stage_gain_factor(slot_transistors) -> float:
    if any(s in slot_transistors for s in
           ("second_stage_p", "second_stage_n", "third_stage_p", "third_stage_n")):
        return 1.0   # fully-differential: both branches drive the next stage
    mosfets = [d for d in slot_transistors.get("load", []) if d.type in ("nmos", "pmos")]
    is_mirror = any(d.terminals.get("g") == d.terminals.get("d") for d in mosfets)
    return 1.0 if is_mirror else 0.5

The mirror test is beautifully cheap: a current-mirror load has a diode-connected device, and diode-connected means gate == drain (g and d on the same net). One terminal comparison detects the whole topology.

mirror load — k_fs = 1.0 branch A branch B g = d → diode-connected both branches combine full gm1·Rout1 resistor load — k_fs = 0.5 branch A branch B wasted only one branch tapped half: gm1·Rout1 / 2
A mirror folds both branches back together (full gm); a resistor or non-mirror source drops a branch, so the device must be twice as strong to hit the same spec.

Why does 0.5 "bite"? Because everywhere the requirements pass later divides by k_fs: a non-mirror load needs a proportionally larger device gm1 to hit the same GBW and gain. One branch wasted → double the transconductance demanded. (Subtle detail the docstring flags: k_fs is applied to gain and to gm1's role in GBW/PM, but not to the raw-device gm1 used for CMRR.)

Part 8 · compute_requirements() — the bias point & the cap

The one job (first half): establish the small-signal operating point — each node's output resistance Rout and the compensation cap Cc — because every gm requirement in the second half is derived from these two.

This is the big pass. We'll take it in two Parts. Part 8 sets the stage: output resistances and the compensation cap. Part 9 turns those into gm demands.

Output resistances — conductances in parallel

The output resistance of a stage is set by the two devices fighting over the node — the drive device and the load. In conductance terms they simply add, and resistance is the reciprocal:

Rout1 = 1 / (gds,ip + gds,ld + 1/Rload)

Analogy: two leaky taps draining the same bucket. Each leak (conductance) adds; the bucket's ability to hold voltage (resistance) is one over the total leak. A resistor load is just a third, fixed leak of 1/R.

def _gds_est(device, ids) -> float:
    role = SIGNAL if is_signal_device(device) else CURRENT_SOURCE
    return model.gds_estimate(device.type, ids, role)      # ← the ONLY model call

gd_ip = _gds_est(ip_devices[0], spec.ibias / 2)
gd_ld = _gds_est(ld_devices[0], spec.ibias / 2)
rout1 = eq.rout(gd_ip, gd_ld + gd_load_r)                # resistor load folded in
k_fs  = _first_stage_gain_factor(slot_transistors)

This _gds_est is the hidden hinge of the whole file. It's the single place a device model is consulted. Everything else — currents, Rs, gm targets — is pure algebra. Route gds through the DeviceModel and the exact same code gives Level-1 its analytical λ·Id and the gm/Id path its LUT-accurate silicon gds. We'll draw that fork in Part 10.

first-stage output node gds,ip gds,ld 1/R Rout1 = 1 / (sum of leaks)
Every conductance draining the node adds; the node's output resistance is one over their sum. The resistor load is just another 1/R term.

The same pattern computes rout2 (from the second stage's NMOS+PMOS) and rout3 (third stage). When a stage is absent, its rout is float("inf") — a clean sentinel that later gain math treats as "not a limiting factor."

The compensation cap Cc — pick the smallest stable one

For a two-stage amp you need a Miller cap to split the poles for stability. The file's philosophy is pick the smallest Cc that's still stable, because every extra picofarad inflates the gm1 you'll need (issue #108).

cc_ub_f = cc_max_f
if spec.slew_rate_min_vps:
    cc_ub_f = min(cc_ub_f, spec.ibias / spec.slew_rate_min_vps)   # SR upper-bounds Cc
cc_f = max(cc_min_f, min(_CC_STABILITY_RATIO * spec.cl, cc_ub_f))
                     # floor: 0.25·CL keeps ~60° phase margin

Traced with real numbers

With CL = 4 pF and the stability ratio 0.25, the pole-split floor is 1 pF. If the user also asked for a slew rate of 20 V/µs at ibias = 20 µA, the upper bound is 20µA / 20e6 = 1 pF — they coincide, so Cc = 1 pF.

small Cc large Cc floor 0.25·CL = 1 pF SR ceiling ibias/SR = 1 pF pick smallest stable
Slew rate only upper-bounds Cc; the stability floor lower-bounds it. The chosen Cc sits at the floor so gm1 stays as small as possible.

For a three-stage amp there's an inner cap too: Cc2 = Cc1 / 4. Both are returned in picofarads. A one-stage amp needs no cap at all — cc_pf stays None.

Part 9 · compute_requirements() — the gm demands & the ceiling

The one job (second half): turn each performance spec — GBW, gain, CMRR, phase margin — into a minimum gm for each stage, take the worst-case (the max), then clamp it to what the device can physically deliver.

Every spec is a lower bound on some device's transconductance. The pattern is always the same: compute the gm this spec demands, and keep it only if it's bigger than what we already need. That's why every line reads gm1_req = max(gm1_req, …) — many springs push up on one value; the tallest wins.

Where each gm demand comes from

SpecDemandsFormula (simplified)
CMRRgm1cmrr_lin · 2 · gds,tail
GBWgm12π · GBW · Cc / k_fs
Phase margingm2 (gm2,gm3 in 3-stage)k_fs·gm1·CL / (Cc·tan(90°−PM))
Gaingm2 (or gm3)A₀ / (k_fs·gm1·Rout1·Rout2)

Traced with real numbers — the GBW demand

With GBW = 10 MHz, Cc = 1 pF, and a mirror load (k_fs = 1.0):

gm1 = 2π · GBW · Cc / k_fs = 2π · 10e6 · 1e-12 / 1.0 ≈ 62.8 µS

If instead the load were a resistor (k_fs = 0.5), that same GBW would demand 125.7 µS — twice as much, exactly the ½-penalty from Part 7.

And the CMRR demand competes

Say CMRR = 60 dB (linear 1000) and the tail's gds,tail = 0.2 µS:

gm1 ≥ 1000 · 2 · 0.2 µS = 400 µS

So gm1 = max(62.8, 400) = 400 µS — here CMRR dominates GBW. The max() found the binding constraint automatically.

CMRR → 400 µS GBW → 62.8 µS gain → 90 µS PM → 120 µS max 400 µS ceiling 350 µS clamp ✂ gm1 = 350 µS + warning
Each spec is a spring pushing gm up; max() keeps the tallest. Then the weak-inversion ceiling clamps it — and a binding clamp becomes an honest warning.

The weak-inversion ceiling — the honesty guard

Here's a trap the square-law model walks straight into. Square-law says gm can grow without limit if you demand it — but a real transistor, as you push its gm up at fixed current, slides into weak inversion where gm saturates. So the code clamps every requirement to a physical ceiling:

gm1_ceil = _ceil(spec.ibias / 2.0, ip_devices)
if gm1_req > gm1_ceil:
    gm1_req = gm1_ceil
    gm_ceiling_warnings.append("input-pair gm requirement exceeds the weak-inversion ceiling …")

Continuing our trace: if gm_ceiling(nmos, 10 µA) = 350 µS, our demanded 400 µS is impossible at this current. The code clamps to 350 µS and emits a warning: the spec needs more bias current than the device can physically give. It doesn't pretend — the shortfall also shows up in the reported margins. This clamp runs for gm1, gm2, and gm3 identically.

Mapping requirements back to individual transistors

Finally the stage-level demands are stamped onto devices. Only the signal transistor of a stage gets a gm requirement — its partner is a current-source load and needs none:

for ref, (device, slot) in all_transistors.items():
    if slot == "input_pair":            gm_req_map[ref] = gm1_req
    elif slot in SECOND_STAGE_SLOTS:  gm_req_map[ref] = gm2_req if is_signal_device(device) else 0.0
    elif slot in THIRD_STAGE_SLOTS:   gm_req_map[ref] = gm3_req if is_signal_device(device) else 0.0

VDS_sat upper bounds from output swing

The last block runs the swing spec backwards. If the user wants the output to reach within output_swing_max_v of the top rail, then the load device can only "cost" so much saturation voltage:

VDS_sat,max = Vdd − output_swing_max_v

That cap is stamped onto load devices (high side) and second/third-stage PMOS (which sit on every output path). The low-side mirror uses output_swing_min_v − Vss for the NMOS. Wherever two constraints hit the same device, min() keeps the tighter one.

Part 10 · The hidden idea, the limits, and the one line

Step back and the whole file is implementing a single principle it never writes down:

pre-sizing= everything decidable before a device model is chosen

Currents come from Kirchhoff. Resistances come from Ohm. gm targets come straight off the spec via algebra. None of that needs to know whether you'll size with square-law equations or silicon lookup tables. So it's factored out, run once, and handed to both sizers.

The one place a model is unavoidable is output conductance gds — and even there the file doesn't branch on the model. It asks a DeviceModel handle, which routes the question:

model.gds_estimate() the one model call in the file gm/Id pipeline LUT-accurate silicon gds Level-1 analytical reproduces λ·Id exactly
Same call, two truths. Polymorphism keeps the pre-sizing code model-agnostic while each path gets the gds it deserves.

Why it's elegant

The design win is a clean seam. By pushing the only model-dependent quantity behind one method, an entire pre-sizing stage — extraction, KCL currents, resistor sizing, the whole gm/VDS_sat derivation — is written once and shared verbatim. Two sizers, zero duplication, and the analytical path still reproduces its textbook λ·Id to the letter.

What it honestly does not do

  • It never picks a geometry — no W or L is chosen here. It only sets currents and targets; the sizers turn those into widths.
  • size_load_resistors only touches rail-referenced load resistors; anything else keeps its netlist value.
  • The gm ceiling clamp doesn't rescue an infeasible spec — it caps the demand and warns, leaving the shortfall visible in the margins.
  • check_topology_match only detects a mismatch; it never repairs the netlist.
  • Absent stages use rout = inf as "not limiting" — a modelling convenience, not a physical infinity.

In one sentence: preprocess.py is the model-independent pre-sizing both sizers share — it extracts each slot's devices, assigns every transistor its KCL current from spec.ibias, sizes rail-referenced load resistors by Ohm's law, sanity-checks the topology, and derives per-stage gm and VDS_sat targets (plus the compensation caps) straight from the performance spec, pushing the only model-dependent quantity, gds, behind a DeviceModel so the gm/Id path gets LUT-accurate conductance while Level-1 reproduces λ·Id exactly.