resistors.py — explained

circuitgenome/sizer/gmid/resistors.py — give every non-load resistor the value its job demands, instead of leaving the 1 kΩ netlist placeholder that quietly mis-biases the amplifier.

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.

before — placeholder degeneration = 1 kΩ tail = 1 kΩ bias leg = 1 kΩ CMFB = 1 kΩ compensation = 1 kΩ all identical → variants mis-biased ✗ size_resistors() after — sized per role degeneration = 250 Ω tail = 10 kΩ bias leg = 50 kΩ CMFB = 1 MΩ compensation = 3 kΩ each fits its role → correctly biased ✓
Same five resistors. On the left every one reads 1 kΩ; on the right each carries the value its job demands. This module is the arrow in the middle.

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.

size_resistors() _output_stage_gm() gm of the last gain stage _representative_bias_vgs() a fallback bias voltage _bias_rail_target_v() what voltage does the rail need? _stack_node_v() saturation floor of an inner node
The orchestrator on top; three helpers it calls, and the one helper that _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 called MetricModifiers.

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:

R = V / I

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.

R = ? I flows through → V across R = V / I
Voltage across, current through, resistance is their ratio. Hold this picture — every slot is a variation on it.

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)
MetricModifiers gm1_factor1.0 gd_tail_overrideNone gd_out_extra0.0 input-pair gain (gm1) CMRR (tail conductance) output conductance (CMFB)
The default card says "I changed nothing" (1.0, None, 0.0). Each slot that fires flips one field, and Phase 5 reads them to adjust the metric it computes.

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 toMetric field it sets
degeneration (input-pair)factor / gm1gm1_factor
resistor tail|Vrail − V_tail| / I_tailgd_tail_override
bias legsV_gate / ibias
CMFB senseintent.cmfb_sense_rgd_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:

R = factor / 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
drain M1 gate 250 Ω to tail effective gm before 2.00 mS after 1.33 mS × 0.667 = 1/(1+0.5) gm/(1+gm·R) = 0.002/1.5 = 1.33 mS ✓
The 250 Ω resistor cuts the pair's effective gm to two-thirds. That exact multiplier, 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.

Vdd = 1.0 V Vss = 0 V Vcm = 0.5 V (gate) v_tail = Vcm + |Vgs| = 0.9 V |Vgs| = 0.4 V R_tail ΔV = 0.1 V
The resistor sees only the 0.1 V gap between the tail node and Vdd. That small voltage, divided by the bias current, sets its ohms.

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 toward Vss. The abs() 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:

R= Cc+CL gm,out·Cc

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.

Cc = 1 pFcomp cap CL = 2 pF gm_out = 1 mS (Cc+CL)/(gm·Cc) = 3p / 1e-15 3 kΩ
Three planned quantities in, one nulling resistor out. With Cc=1 pF, CL=2 pF, gm_out=1 mS the zero lands where the output pole lives.

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:

1 · find the leg'snon-rail net 2 · what voltagedoes it need? 3 · R = V_gate/ ibias step 2 delegates to _bias_rail_target_v(); falls back to a representative Vgs if it can't decide
The leg spans a rail and one internal net; we derive the internal net's target voltage, then Ohm's-law it into ohms.

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.

Vss = 0 V M0 (below) d = node, s = Vss node (source of cascode) Vdsat + margin acc = 0 + (0.15 + 0.10) floor = Vss + acc = 0 + 0.25 = 0.25 V
One device below the node: floor = Vss + (Vdsat + margin) = 0.25 V. A two-device cascode would add a second step before reaching the rail.

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:

Vcm ∓ (|Vgs| − |Vdsat| − margin) ± acc

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 (the seen seatbelt catches a malformed loop).
  • The stack terminates on the wrong supply for the device type → None (the last if guard).

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:

R= V(role) I(role)

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.

SlotV (role)I (role)Metric it reports
degenerationfactor / gm1 (as gm·R)gm1_factor
resistor tail|Vrail − V_tail|ibiasgd_tail_override → CMRR
bias legV_gateibias
CMFB sensefixed 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_v returns None rather 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.