Part 1 · Why this file even exists
The one job: after the transistors have been sized, look at every resistor that is not a rail-referenced load, and give it the ohm value its role actually needs — degeneration, tail, bias, CMFB, or compensation — replacing the 1 kΩ placeholder the netlist shipped with.
Here is the whole story with no code at all.
When the synthesizer draws an op-amp, it drops a resistor symbol wherever a resistor belongs and stamps every one of them with the same 1 kΩ placeholder — a stand-in, not a real value. The earlier Level-1 sizer only fixed the load resistors (the ones tied straight to a supply rail). Every other resistor — the source-degeneration r1/r2, the resistor tail, the tunable bias legs, the CMFB sense network, the compensation nulling resistor — was left sitting at 1 kΩ.
Think of it like a recipe that lists five ingredients but writes "1 cup" next to all of them because nobody measured. One cup of salt where you needed a pinch will ruin the dish. A resistor at the wrong value doesn't just under-perform — it moves DC currents and node voltages, so it mis-biases the whole variant.
So resistors.py is a tailor that runs once after transistor sizing. It fits each non-load resistor to its role, and — because two of those resistors change how the finished amplifier measures — it also writes down two metric effects for the evaluation phase to honor. Its output flows out through SizingResult.resistors, and spice_sim._inject_sizes swaps the placeholder for the real value, exactly the way the load resistors are handled.
The cast of characters
The whole file is one public function, size_resistors(), plus four small helpers it leans on. Hover any box to trace who it calls.
_bias_rail_target_v() calls in turn. We'll learn the five resistor roles first, then peel into the bias helpers bottom-up.Notice the five roles are not separate functions — they are five if-blocks inside size_resistors(), one per resistor slot. We'll take them one at a time, simplest first.
Looking ahead: before touching a slot, Part 2 builds the two ideas the whole file rests on —
R = V / I, and the little report card calledMetricModifiers.
Part 2 · The physics primer (one law, one report card)
You need exactly two ideas. Neither is hard.
Idea 1 — a resistor's value is a voltage divided by a current
Ohm's law says the voltage across a resistor equals the current through it times its resistance: V = I · R. Turn it around and you get the one formula this entire file keeps re-using:
Every resistor role below is just an answer to two questions — "what voltage do I need to appear across (or because of) this resistor?" and "what current flows through it?" — divided.
Idea 2 — two resistors change how the amplifier measures
Most of these resistors just set a DC operating point. But two of them alter a performance metric, and the evaluation phase (Phase 5) needs to know. So the function hands back a little frozen record — MetricModifiers — with three fields, its report card:
@dataclass(frozen=True)
class MetricModifiers:
gm1_factor: float = 1.0 # input-pair gm multiplier (degeneration)
gd_tail_override: float | None = None # 1/R of a resistor tail (for CMRR)
gd_out_extra: float = 0.0 # extra output conductance (CMFB sense)
These are the defaults — a report card that says "nothing changed." As size_resistors() runs, some slots overwrite a field. Here is the whole map we're about to walk, slot by slot:
| Slot (role) | Value it sizes to | Metric field it sets |
|---|---|---|
| degeneration (input-pair) | factor / gm1 | gm1_factor |
| resistor tail | |Vrail − V_tail| / I_tail | gd_tail_override |
| bias legs | V_gate / ibias | — |
| CMFB sense | intent.cmfb_sense_r | gd_out_extra |
| compensation | (Cc+CL) / (gm_out·Cc) | — |
Looking ahead: Part 3 opens the first slot — source degeneration — the cleanest use of
R = V/I's cousin,R = factor / gm.
Part 3 · Source degeneration — trading gain for linearity
The one job: put a small resistor under each input transistor's source, sized so its degeneration is exactly the intended fraction of the device's own strength — and record how much that softens the input-pair gain.
Analogy: degeneration is local negative feedback, like a governor on an engine. Add a resistor in series with the source and every wiggle of current develops a voltage across it that pushes back on the gate drive. The stage gets a little weaker (lower gm) but much more linear and predictable — a deliberate trade.
ip = blocks.input_pair
ip_res = slot_resistors.get("input_pair", [])
if ip_res and intent.degeneration_factor > 0 and ip and ip.mosfets:
d = ip.mosfets[0]
s = sizing.get(d.ref)
if s:
gm1 = model.gm(d.type, s.w_um, s.l_um, s.ids_a)
if gm1 > 0:
r_val = intent.degeneration_factor / gm1
for r in ip_res:
out[r.ref] = r_val
gm1_factor = 1.0 / (1.0 + intent.degeneration_factor)
The whole slot rests on one clever definition. The degeneration_factor is not a resistance — it is the dimensionless product gm1 · R. So to hit a chosen factor you just divide it by the device's realized gm1:
This is the single most important line in the slot. Because factor = gm1 · R, the resistor auto-scales with whatever gm1 the sizer actually landed on — a strong device gets a small resistor, a weak one gets a larger resistor, and the degeneration ratio stays put.
Traced with real numbers
Suppose the sizer gave the input pair gm1 = 2 mS and the intent asks for degeneration_factor = 0.5:
R = 0.5 / 0.002 S = 250 Ω
gm1_factor = 1 / (1 + 0.5) = 0.667
0.667, is what gm1_factor hands to Phase 5.Notice the reported gm1_factor = 1/(1+factor) matches the textbook degenerated-gm formula gm/(1+gm·R) exactly — because gm·R is the factor. The slot fires only when the intent asks for degeneration (factor > 0) and a real input device with a sizing exists; otherwise it leaves gm1_factor = 1.0 and touches nothing.
Part 4 · The resistor tail — a resistor that sets the tail current
The one job: when the tail is a plain resistor instead of a current-source transistor, size it so the intended bias current flows given the voltage the input pair leaves across it — and note its finite conductance, which hurts CMRR.
Analogy: a current-source tail is a precise pump; a resistor tail is a garden hose pinched to a fixed width. The flow you get depends on the pressure across it. So we size the pinch so that, at the pressure the circuit provides, the right current comes out. This is pure R = V / I.
tail_res = slot_resistors.get("tail_current", [])
if tail_res and ip and ip.mosfets:
ip_dev = ip.mosfets[0]
s = sizing.get(ip_dev.ref)
vgs = abs(model.vgs(ip_dev.type, s.w_um, s.l_um, s.ids_a)) if s else 0.0
if ip_dev.type == "pmos": # source toward vdd
v_tail, vrail = vcm + vgs, spec.vdd
else: # source toward vss
v_tail, vrail = vcm - vgs, spec.vss
i_tail = spec.ibias
r_val = abs(vrail - v_tail) / i_tail if i_tail > 0 else 0.0
for r in tail_res:
out[r.ref] = r_val
if r_val > 0:
gd_tail_override = 1.0 / r_val
First it finds the voltage at the tail node. The input pair drags its own source node one |Vgs| away from the common-mode voltage Vcm. For a PMOS pair the source sits above Vcm (toward Vdd); for an NMOS pair, below it (toward Vss). The resistor spans from that tail node to the rail, and Ohm's law finishes the job.
Traced with real numbers
PMOS pair, Vdd = 1.0, Vcm = 0.5, |Vgs| = 0.4, ibias = 10 µA:
v_tail = 0.5 + 0.4 = 0.9 V
ΔV = |1.0 - 0.9| = 0.1 V
R_tail = 0.1 V / 10 µA = 10 kΩ
gd_tail_override = 1 / 10 kΩ = 100 µS
That last line is the honest bit. A perfect current-source tail has near-zero output conductance, which is what gives a differential pair its common-mode rejection. A resistor tail has a very real 1/R of conductance leaking common-mode signal through — so the slot reports gd_tail_override = 1/R and lets Phase 5 compute the CMRR penalty truthfully instead of pretending the tail is ideal.
Why the PMOS/NMOS split? It is the same equation mirrored: a PMOS pair pushes the tail node up toward
Vdd, an NMOS pair pulls it down towardVss. Theabs()makes the sign bookkeeping vanish.
Part 5 · Two more slots — CMFB sense and compensation
These two share a Part because one is trivially simple and the other is a single elegant formula.
CMFB resistive-sense averager — big and gentle
The one job: give each CMFB sense resistor a fixed, deliberately large value so it can average the two outputs without loading them — then report the tiny extra output conductance it still adds.
cmfb_res = slot_resistors.get("cmfb", [])
if cmfb_res:
for r in cmfb_res:
out[r.ref] = intent.cmfb_sense_r
gd_out_extra = 1.0 / intent.cmfb_sense_r if intent.cmfb_sense_r > 0 else 0.0
A CMFB averager taps both outputs through equal resistors into a shared "sense" node to read their common mode. Analogy: it's a voltmeter with very high input impedance — you want to read the outputs, not drain them. So the value is just a fixed large number from the intent (default 1 MΩ), no derivation needed. But even a 1 MΩ tap adds 1/R of conductance at each output node, so it reports that as gd_out_extra.
R_sense = 1 MΩ → gd_out_extra = 1 / 1e6 = 1 µS (small, but honest)
Compensation — placing the zero on the output pole
The one job: size the series (nulling) resistor of a Miller / indirect compensation network so the zero it creates lands exactly on the output pole it is meant to cancel.
comp_slots = [(name, rs) for name, rs in slot_resistors.items()
if name.startswith("comp")]
if comp_slots and cc_pf:
gm_out = _output_stage_gm(blocks, sizing, model)
if gm_out > 0:
for name, rs in comp_slots:
slot_cc_f = (cc2_pf if "comp2" in name and cc2_pf else cc_pf) * 1e-12
r_val = (slot_cc_f + spec.cl) / (gm_out * slot_cc_f)
for r in rs:
out[r.ref] = r_val
The placeholder problem bites hardest here: the synthesizer emits the nulling resistor at 1 kΩ, but a good value must track the stage it bridges. The formula is the standard nulling-resistor condition for cancelling the right-half-plane zero and pushing it onto the output pole:
It needs the output stage's real transconductance, and that is what _output_stage_gm() fetches — walking the third stage (or the second when there is no third), grabbing the signal device, and returning its gm. The compensation network wraps the last gain stage, so its resistor must be sized against that stage's gm — never the input pair's.
Traced with real numbers
slot_cc_f = 1 pF = 1e-12 F CL = 2 pF = 2e-12 F gm_out = 1e-3 S
R = (1e-12 + 2e-12) / (1e-3 · 1e-12) = 3e-12 / 1e-15 = 3000 Ω = 3 kΩ
The comp2 branch simply lets a two-cap scheme pair its second resistor with the second cap cc2_pf. And the guard if comp_slots and cc_pf means: no compensation cap planned, no resistor sized — the slot bows out cleanly.
Part 6 · Tunable bias legs — a resistor that programs a gate voltage
The one job: for each resistor in a bias generator, size it so that the fixed bias current dropped across it produces exactly the gate voltage the thing it feeds actually needs.
Analogy: a bias leg is a known current pushed through a resistor to manufacture a voltage — Ohm's law run in reverse. You pick the resistor so that ibias · R lands on the target gate voltage. So the value is R = V_gate / ibias. The only real work is figuring out which voltage each leg should target.
bias_res = slot_resistors.get("bias_gen", [])
if bias_res and spec.ibias > 0:
consumers = [d for name, b in blocks.blocks.items() if name != "bias_gen"
for d in b.mosfets]
v_fallback = _representative_bias_vgs(blocks, sizing) or 0.5 * (spec.vdd - spec.vss)
for r in bias_res:
rail = next((t for t in (r.terminals.get("t1"), r.terminals.get("t2"))
if t and t not in RAILS), None)
v_abs = (_bias_rail_target_v(rail, consumers, sizing, spec)
if rail else None)
v_gate = v_abs - spec.vss if v_abs is not None and v_abs > spec.vss \
else v_fallback
out[r.ref] = v_gate / spec.ibias
Read it as three steps per resistor:
Step 2 is the interesting one, and it can fail — a rail whose consumers give no derivable level. That's why there is a v_fallback: either a representative current-source |Vgs| from the bias generator, or, failing even that, half the supply span. The design never leaves a leg unsized.
Where the target comes from: _bias_rail_target_v()
The one job: ask every transistor gated by this rail what absolute voltage it needs the rail to sit at, and return their average.
def _bias_rail_target_v(rail_net, consumers, sizing, spec):
targets = []
for dev in consumers:
if dev.terminals.get("g") != rail_net or dev.terminals.get("d") == rail_net:
continue # not gated by it, or diode-connected → skip
s = sizing.get(dev.ref)
src = dev.terminals.get("s")
if not (s and s.vgs_v and src): continue
sign = 1.0 if dev.type == "nmos" else -1.0
if src in RAILS:
base = spec.vdd if src == "vdd!" else spec.vss
else:
base = _stack_node_v(src, dev, consumers, sizing, spec)
if base is None: continue
targets.append(base + sign * abs(s.vgs_v))
return sum(targets) / len(targets) if targets else None
The idea is beautifully simple once you see it. A transistor gated by the rail wants its gate one |Vgs| away from its own source. So: find where the source sits (a rail is easy; an internal node needs a stack walk), then step one |Vgs| toward the gate — up for NMOS, down for PMOS. Do that for every consumer and average, because one physical resistor can only offer one compromise voltage to a rail that several devices share.
Two guards deserve a name. A diode-connected consumer (d == rail_net) is skipped — it's a current interface, not a voltage demand. And a consumer with no usable sizing is skipped too. If nobody yields a level, it returns None and the caller falls back.
The fallback helper: _representative_bias_vgs()
def _representative_bias_vgs(blocks, sizing):
bg = blocks.blocks.get("bias_gen")
for d in (bg.mosfets if bg else []):
s = sizing.get(d.ref)
if s and s.vgs_v:
return abs(s.vgs_v)
return None
The simplest possible fallback: grab the first current-source |Vgs| in the bias generator as a stand-in for "a typical bias voltage." A rough answer beats leaving the resistor at 1 kΩ.
Looking ahead: Part 7 opens the one genuinely recursive-feeling helper —
_stack_node_v(), which finds where an internal source node sits by walking a cascode stack down to the rail.
Part 7 · _stack_node_v() — walking a cascode down to the rail
The one job: find the saturation floor (NMOS) or ceiling (PMOS) of an internal node, by walking the same-type transistor stack from that node down to a supply rail, adding up every device's minimum standing height along the way.
When a consumer's source is not a rail but an internal node — the middle of a cascode — we can't just read its voltage. We have to reconstruct it. Analogy: it's a staircase. To know how high a landing is, you count the steps beneath it, each of a known height, from the ground floor up.
Each transistor below the node contributes at least its Vdsat (plus a small saturation margin) so everything under it stays saturated with slack. Sum those from the rail up, and you have the node's floor.
def _stack_node_v(node, consumer, mosfets, sizing, spec):
sign = 1.0 if consumer.type == "nmos" else -1.0
vcm = (spec.vdd + spec.vss) / 2.0
acc = 0.0
seen = set()
while node not in RAILS:
if node in seen: return None # loop seatbelt
seen.add(node)
dev = next((d for d in mosfets
if d.type == consumer.type and d.ref != consumer.ref
and d.terminals.get("d") == node), None)
s = sizing.get(dev.ref) if dev else None
if not (dev and s and s.vgs_v and s.vds_sat_v): return None
if is_signal_device(dev) and dev.terminals.get("s") not in RAILS:
return (vcm
- sign * (abs(s.vgs_v) - abs(s.vds_sat_v)
- _CASCODE_SAT_MARGIN_V)
+ sign * acc)
acc += abs(s.vds_sat_v) + _CASCODE_SAT_MARGIN_V
node = dev.terminals.get("s")
if (consumer.type == "nmos") == (node == "vdd!"):
return None # stack terminated on the wrong supply
return (spec.vdd if node == "vdd!" else spec.vss) + sign * acc
This is a linked-list walk, just like the tail-stack walk — but the "pointers" are nets. Each device's source is the next device's drain. Build no lookup this time; on each hop, scan for the same-type device whose drain is the current node, add its Vdsat + margin to acc, then jump to its source. The seen set is a seatbelt against a malformed loop.
Traced with real numbers
NMOS consumer (sign = +1), one cascode device below with Vdsat = 0.15 V, margin 0.1 V:
node → find M0 (drain = node), source = Vss → acc += 0.15 + 0.10 = 0.25
node is now Vss (a rail) → loop ends
floor = Vss + sign·acc = 0 + 0.25 = 0.25 V
Back in _bias_rail_target_v, that floor becomes the base, and the consumer's own gate is one |Vgs| above it:
rail target = 0.25 + 1·|Vgs| = 0.25 + 0.70 = 0.95 V
then in size_resistors: R = (0.95 - Vss) / ibias = 0.95 / 10 µA = 95 kΩ
The special case: a signal device in the stack
The one branch that looks strange handles a telescopic topology, where the cascode sits directly on the input pair. A signal device rides the tail node — its gate is at the common mode Vcm, not one plain Vdsat above a rail. So the walk stops early and anchors to Vcm, landing a margin inside the signal device's own saturation edge:
And the comment flags a genuine subtlety (issue #129): a current-mirror's bottom device looks like a signal device to is_signal_device but actually sources straight to a supply, so the guard dev.terminals.get("s") not in RAILS makes it fall through and terminate at the rail with an ordinary Vdsat floor.
The honest failure modes
- No device below the node, or a device with missing sizing →
None(caller falls back). - A repeated node →
None(theseenseatbelt catches a malformed loop). - The stack terminates on the wrong supply for the device type →
None(the lastifguard).
Every failure is a clean None, never a wrong number — the module would rather fall back to a representative voltage than confidently mis-bias a leg.
Part 8 · The hidden equation, limits, and the one line
Strip away the five slots and one law is doing all the work. Every resistor here is a voltage the circuit needs, divided by the current that will flow through it:
Read it as: "each resistor's ohms are whatever it takes to turn the current its role carries into the voltage its role demands." Degeneration disguises it as factor/gm1 (since factor = gm1·R); compensation disguises it as a pole-matching ratio; but under every disguise it is the same division.
| Slot | V (role) | I (role) | Metric it reports |
|---|---|---|---|
| degeneration | factor / gm1 (as gm·R) | — | gm1_factor |
| resistor tail | |Vrail − V_tail| | ibias | gd_tail_override → CMRR |
| bias leg | V_gate | ibias | — |
| CMFB sense | fixed large R (intent) | gd_out_extra | |
| compensation | (Cc+CL) / (gm_out·Cc) | — | |
What it honestly does not do
- It sizes only non-load resistors — the rail-referenced load resistors were already handled upstream.
- Every slot is guarded: no matching slot resistors, or a missing precondition (no
cc_pf,ibias = 0,degeneration_factor = 0), and it simply doesn't fire — leaving that resistor and that metric field untouched. - A bias leg whose rail yields no derivable level gets a representative voltage, not a precise one — deliberately rough, but far better than the 1 kΩ placeholder.
_stack_node_vreturnsNonerather than a guess whenever the stack can't be traced; the module never fakes a number.
Why it's elegant
The whole file rides one insight: a resistor's job fully determines its value, and two of those jobs also change how the amplifier measures. By deriving each value from the operating point the sizer already computed — and reporting the two metric side-effects instead of hiding them — it turns a netlist full of identical 1 kΩ placeholders into a correctly biased, honestly evaluated amplifier.
In one sentence: resistors.py replaces the 1 kΩ placeholder on every non-load resistor with the value its role demands — degeneration factor/gm1, tail ΔV/ibias, bias leg V_gate/ibias, a fixed large CMFB sense, and the pole-matching compensation nulling resistor — while reporting the two metric effects (degeneration on gm1, resistor-tail conductance on CMRR) that the evaluation phase must honor.