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.
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.
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.
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Ω
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
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 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 group | Rule | Current each device gets |
|---|---|---|
HALF_BIAS_SLOTSinput_pair, load | 2-device group splits the tail | ibias / n |
FULL_BIAS_SLOTStail_current, bias_gen | carries the whole tail | ibias |
SECOND_STAGE_SLOTS | scaled by ratio | ibias · ratio₂ |
THIRD_STAGE_SLOTS | scaled by ratio | ibias · 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}
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 carryibias/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.0or0.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.
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
Routand the compensation capCc— 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:
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.
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.
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
| Spec | Demands | Formula (simplified) |
|---|---|---|
| CMRR | gm1 | cmrr_lin · 2 · gds,tail |
| GBW | gm1 | 2π · GBW · Cc / k_fs |
| Phase margin | gm2 (gm2,gm3 in 3-stage) | k_fs·gm1·CL / (Cc·tan(90°−PM)) |
| Gain | gm2 (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.
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:
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:
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:
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_resistorsonly touches rail-referencedloadresistors; 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_matchonly detects a mismatch; it never repairs the netlist.- Absent stages use
rout = infas "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.