bias_levels.py — explained

circuitgenome/sizer/gmid/bias_levels.py — the bias generator has a few devices whose operating point is an absolute voltage level, not a mirror ratio. Here is how they get sized.

Part 1 · Why this file even exists

The one job: after the geometry pass has sized every mirror in the bias generator by ratio, a handful of devices aren't mirrors at all — their job is to set an absolute rail voltage. This file re-sizes exactly those, and nothing else.

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

The gm/Id sizer builds the bias generator from an intent table. Most of its transistors are current mirrors: each one copies the reference current scaled by a ratio, so its width follows a clean rule — W ∝ I. Hand the geometry pass a target current and a ratio, and it sizes the device. Done.

But a bias generator also contains level devices, whose reason for existing is to pin a node at a chosen voltage. You can't size those by a current ratio, because their spec is a voltage, not a mirror factor. Think of a tailor who sizes every suit as a scaled copy of one reference suit. That works beautifully — until someone hands him a doorframe and asks him to make it exactly 2.03 m tall. "Scaled copy of what?" There is no ratio; there is only an absolute target. This file is the specialist called in for the doorframes.

mirror device — geometry pass sizes it spec = a ratio to Iref W ∝ I current in → width out ratio rule works ✓ level device — ratio rule can't spec = an absolute rail voltage V_rail = a target no ratio to scale from needs this file ✦
Same wardrobe, two kinds of garment. The geometry pass tailors mirrors by ratio; level devices have no ratio, only an absolute voltage they must hit.

There are exactly two level structures the file knows how to spot, and it finds each one structurally — by the actual shape of device terminals, so a foreign netlist without these shapes passes straight through untouched:

  • Cascode-leg level diodes — a diode-connected device riding a resistor to a supply. The rail it produces must sit at a downstream cascode's V_GS plus a small saturation floor. This is the bulk of the file, and the whole of Parts 2 and 5.
  • The pref branch's wide-swing cascode level (ncasc) — a mirror-drain cascode topped by a diode, tuned so the mirror's Vds sits as close to the master reference's as headroom allows. Part 6.

The cast of characters

Eight small functions. The two _tune_* routines do the real work; the rest are leaves they lean on. Hover any box to trace who it calls.

tune_bias_levels() _tune_cascode_legs() size each leg's diode + floor R _tune_pref_cascode() pin the wide-swing level _size_diode_for_vgs() width whose |Vgs| ≈ target _diode_candidates() (w, |Vgs|) per LUT point _sized() build a TransistorSizing _snap_w() width → grid
The orchestrator on top; the two tuners below it; the shared width/level helpers at the bottom. We learn it bottom-up so the tuners read easily.

One more fact about where this runs: it fires after the geometry pass and the DC operating-point check, and its resistor values override the generic fallback that size_resistors assigns to nets it can't derive a level for. It is the last, most-informed word on these specific nets.

Looking ahead: Part 2 builds the one picture the whole file is drawing — a rail voltage split into a V_GS part carried by a diode and a small floor part carried by a resistor.

Part 2 · What a rail "level" actually is (one picture)

Everything the cascode-leg tuner does serves one target voltage. Let's build it.

The demand: a downstream cascode needs its gate at a certain height

Somewhere out in the op-amp, a cascode transistor sits on top of a device. For it to work, its gate must be held at a specific voltage: enough to give the cascode its own V_GS, plus enough to keep the device stacked beneath it in saturation. That second piece — the minimum voltage the stack underneath needs — is the saturation floor. So the rail that feeds this gate must sit at:

Vrail = VGS,consumer + floor

This is exactly the number _bias_rail_target_v() returns — the mean voltage the rail's consumers demand. Call it target_abs.

The supply: build that height out of two stacked pieces

Now, how do you make a rail sit at, say, 0.75 V above ground? The cascode leg does it with a two-storey stack between the supply and the rail:

  • a resistor sitting on the supply, dropping the small floor across the leg current, and
  • a diode-connected transistor on top of it, dropping its own V_GS — and the diode's drain is the rail.

The genius is in the pairing: the diode supplies the big V_GS part, and the resistor supplies the small floor part. Add them and you land on the target.

vss = 0 V (supply) mid = 0.104 V rail = 0.75 V = target resistor → floor diode |Vgs| = 0.646 V = 0.104 V
The rail is a sum: a resistor drops the small floor (0.104 V), a diode drops its own V_GS (0.646 V), and their drain lands the rail at the 0.75 V target.

The one knob on the diode: gm/Id

How do we set the diode's V_GS to whatever value we want? By choosing its width — through the gm/Id lookup table. A narrow diode runs in strong inversion with a large V_GS; a wide diode runs in weak inversion with a small V_GS. Sweep the width and you sweep V_GS.

narrow (strong) small W |Vgs| big widen device wide (weak) big W |Vgs| small
Width is the dial for V_GS. The whole file is "pick the width whose V_GS lands where I need it."

The reveal, planted early: the leg's diode is the same transistor type as the consumer cascode it feeds. So when we size the diode to reproduce the consumer's V_GS, the rail automatically tracks Vth — over process and temperature both voltages drift together. We'll cash this out in Part 8.

Part 3 · _snap_w() — the warm-up

The one job: round a computed width to the nearest value the manufacturing grid allows, then clamp it into the legal range.

def _snap_w(tech: TechParams, w_um: float) -> float:
    g = tech.width
    return float(min(max(round(w_um / g.step) * g.step, g.min), g.max))

Analogy: you can order lumber only in whole inches. Ask for 23.4 inches and the yard hands you 23. That is all this does — snap to the grid, then refuse anything below min or above max.

Traced with real numbers

Say step = 0.1 µm, min = 0.5, max = 100, and the tuner computed w_um = 4.27:

round(4.27 / 0.1) * 0.1  =  round(42.7) * 0.1  =  43 * 0.1  =  4.3 µm
max(4.3, 0.5) = 4.3       min(4.3, 100) = 4.3   ->  4.3 µm

Every width the two tuners invent is passed through this, so whatever V_GS they aim for, the device they actually place is manufacturable. Keep it in your pocket — it shows up inside both tuners.

Part 4 · Picking a diode width — _diode_candidates(), _size_diode_for_vgs(), _sized()

The one job: given a target V_GS, find the diode width whose |V_GS| lands closest to it — optionally never exceeding a cap.

This is the shared engine both tuners call to turn a voltage wish into a width. It comes in two small steps: enumerate the candidates, then pick the closest.

Step 1 — enumerate one width per LUT point

def _diode_candidates(model, tech, dtype, l_um, ids_a) -> list:
    out = []
    for gm_id in model.lut.gm_id_axis:
        idw = model.lut.id_per_w(dtype, float(gm_id), l_um)  # current density
        if idw <= 0: continue
        w = _snap_w(tech, abs(ids_a) / idw)             # width to carry this current
        out.append((w, abs(model.vgs(dtype, w, l_um, ids_a))))
    return out

For each gm/Id point in the table, it asks "at this operating point, how much current does a 1 µm-wide device carry?" (id_per_w), then back-solves the width that carries our fixed current ids_a, snaps it to the grid, and records the pair (width, |V_GS|). The axis runs weak-to-strong, so the list runs from small |V_GS| to large.

Step 2 — pick the width closest to the target

def _size_diode_for_vgs(model, tech, dtype, l_um, ids_a, target_vgs,
                        cap_vgs=None) -> float | None:
    cands = _diode_candidates(model, tech, dtype, l_um, ids_a)
    if cap_vgs is not None:
        cands = [c for c in cands if c[1] <= cap_vgs] or []
    if not cands: return None
    return min(cands, key=lambda c: abs(c[1] - target_vgs))[0]

Analogy: you want a paint shade of "0.65". You're handed a fan deck of chips, each a real available shade. You throw out any chip darker than your ceiling (cap_vgs), then pick the remaining chip closest to 0.65. If the cap threw out every chip, you return None and the caller leaves the device alone.

|Vgs| per width → wideVgs 0.58 Vgs 0.62 Vgs 0.646 ✓ Vgs 0.71 narrowVgs 0.79 closest to target 0.65 → pick this width
Not a threshold scan — a nearest-match. The chip whose V_GS is closest to the wished target wins (here 0.646, nearest to 0.65).

And _sized() — the little constructor

def _sized(model, ref, dtype, w_um, l_um, ids_a) -> TransistorSizing:
    return TransistorSizing(ref=ref, w_um=w_um, l_um=l_um, ids_a=ids_a,
        vgs_v=model.vgs(dtype, w_um, l_um, ids_a),
        vds_sat_v=model.vds_sat(dtype, w_um, l_um, ids_a))

Once a width is chosen, this packages it back into a full TransistorSizing record — re-reading the model for the actual V_GS and Vdsat that width produces. That "actual, after snapping" V_GS is what the tuner reads next; it won't be exactly the target, and the code is honest about that.

Looking ahead: now we have the engine. Part 5 wires it into the cascode-leg tuner and traces a full rail voltage — diode plus floor — with real numbers.

Part 5 · _tune_cascode_legs() — the heart

The one job: for each cascode leg, re-size its diode so V_GS matches what the leg's consumers actually need, then set the floor resistor to make up the remaining rail voltage.

This is the most important function in the file. Take it in four beats.

Beat 1 — find the leg, structurally

for r in bg.resistors:
    t1, t2 = r.terminals.get("t1"), r.terminals.get("t2")
    supply = next((t for t in (t1, t2) if t in RAILS), None)
    mid    = next((t for t in (t1, t2) if t and t not in RAILS), None)
    if supply is None or mid is None: continue
    diode = next((d for d in bg.mosfets
                  if d.terminals.get("s") == mid
                  and d.terminals.get("d") == d.terminals.get("g")), None)
    if diode is None: continue  # plain rail resistor — size_resistors owns it
    rail = diode.terminals["d"]

A leg is recognized by its shape: a resistor with one end on a supply and the other on an internal node mid, with a diode-connected device (drain = gate) whose source sits on that same mid. The diode's drain is the rail. No mid-riding diode? Then it's just an ordinary rail resistor, and the generic sizer keeps it.

supply (in RAILS) R mid diode d = g rail (= drain) feeds consumer gates consumer cascode(s) gate on rail, drain ≠ rail
The exact terminal pattern the loop hunts for. Miss the diode-on-mid and the leg is skipped — this is why foreign netlists pass through untouched.

Beat 2 — read what the consumers demand

target_abs = _bias_rail_target_v(rail, consumers, sizing, spec)  # rail voltage
vgs_targets = [abs(cs.vgs_v) for dev in consumers
    if dev.terminals.get("g") == rail                # gated by this rail
    and dev.terminals.get("d") != rail               # not the diode itself
    and (cs := sizing.get(dev.ref)) is not None and cs.vgs_v]
if not vgs_targets: continue
target_vgs = sum(vgs_targets) / len(vgs_targets)  # mean consumer |Vgs|
i_leg = abs(ids_map.get(diode.ref, spec.ibias)) or spec.ibias

Two demands come out of the consumers. target_abs is the absolute rail voltage they need (V_GS + floor, from Part 2). target_vgs is the mean planned V_GS of the actual consumer cascodes gated by this rail — this is the number the diode will be re-sized to reproduce. If several consumers share one rail with conflicting wishes, the mean is the compromise a single leg can offer.

Beat 3 — re-size the diode to the consumers' V_GS

w = _size_diode_for_vgs(model, tech, diode.type, s.l_um, s.ids_a, target_vgs)
if w is not None:
    sizing[diode.ref] = _sized(model, diode.ref, diode.type, w, s.l_um, s.ids_a)
vgs_actual = abs(sizing[diode.ref].vgs_v)

Straight into the Part 4 engine: pick the width whose V_GS is closest to target_vgs, rebuild the record, then read back the actual V_GS the grid gave us. This is the line that makes the rail track Vth — the diode now carries the same V_GS as the consumer, and it's the same device type.

Beat 4 — the resistor makes up the difference

span  = (target_abs - spec.vss) if supply != "vdd!" \
        else (spec.vdd - target_abs)
floor = max(span - vgs_actual, 0.0)
out_r[r.ref] = floor / i_leg if floor > 0 else 1.0

The diode already covers vgs_actual of the rail. Whatever is left between the supply and the target is the floor, and a resistor drops exactly that across the leg current: R = floor / I_leg (Ohm's law, solved for R). If the diode alone already overshoots, floor clamps to 0 and the resistor becomes a nominal 1.0 Ω — a near-short, since no drop is needed.

Why is it called floor?

The consumer cascode needs its own V_GS, plus just enough extra to keep the device stacked beneath it in saturation — the minimum voltage floor that stack sits on. That is precisely the small piece the resistor supplies. I really like this name: it says the physics out loud.

The whole leg, traced by hand

Take a cascode_gnd leg (supply = vss = 0). The consumers want V_GS = 0.65 V and their stack needs a floor, giving a rail target target_abs = 0.75 V. The leg carries I_leg = 10 µA.

StepComputationResult
target_vgs (mean consumer V_GS)given by consumers0.650 V
diode re-sized, actual V_GSnearest LUT chip to 0.650.646 V
span0.75 − 0 (vss supply)0.750 V
floormax(0.750 − 0.646, 0)0.104 V
R = floor / I_leg0.104 / 10 µA10.4 kΩ
rail produced0.646 (diode) + 0.104 (R·I)0.750 V
what the diode gives |Vgs| = 0.646 V (tracks Vth) + floor 0.104 = 0.750 V ✓ what the resistor is set to R = floor / I_leg = 0.104 V / 10 µA = 10.4 kΩ the resistor's whole purpose: turn the leftover voltage into a value size_resistors couldn't derive
Diode plus floor equals target, exactly. The diode carries the large, Vth-tracking part; the resistor carries the small, deliberate margin.

Part 6 · _tune_pref_cascode() — the wide-swing level

The one job: in the pref branch, push the mirror's drain voltage (Vds) as close to the master reference's V_GS as headroom allows — by making the cascode as "short" as possible and the ncasc diode as "tall" as the ceiling permits.

Why bother? A plain NMOS mirror in this branch would run at Vds = vdd − |V_GSP|, far from the master's operating point — the ~4% extra-mirror-hop error from issue #103. Cascoding the mirror lets us pin its Vds wherever we choose. We choose "as near the master as we can reach."

Beat 1 — find the chain, structurally

master  = diode-connected nmos with source on a rail  # names ibias net
mirror  = another nmos, gate on ibias, source on a rail
cascode = nmos, source on the mirror's drain, gate NOT ibias
ncasc_diode = diode-connected nmos whose d=g = the cascode's gate
pref_diode  = pmos diode on the cascode's drain

The whole chain is recovered from terminals alone: the master names the ibias net, the mirror hangs off it, the mirror's drain carries a cascode, the cascode's gate names the ncasc net, and a pref PMOS diode caps the top.

master d=g=ibias ibias mirror g=ibias n1 (pinned Vds) cascode weak-inversion ncasc diode, sets gate pref diode pmos, from vdd v_pref = vdd − |Vgs_p|
Master → mirror → cascode → pref diode, with the ncasc diode setting the cascode's gate. The knob is the pinned node n1 between mirror and cascode.

Beat 2 — make the cascode as short as possible

gm_id_weak = float(model.lut.gm_id_axis[-1])   # weakest inversion
idw = model.lut.id_per_w("nmos", gm_id_weak, s_c.l_um)
if idw > 0:
    w_c = _snap_w(tech, abs(s_c.ids_a) / idw)
    sizing[cascode.ref] = _sized(model, cascode.ref, "nmos", w_c, s_c.l_um, s_c.ids_a)

Push the cascode to the weak-inversion end of the table — the last axis point. That gives it the lowest V_GS and lowest Vdsat. A shorter cascode leaves more of the ceiling for the ncasc diode to push the pinned node upward. It is the same "widen the device to shrink its V_GS" trick from Part 2, dialed to the extreme.

Beat 3 — aim the ncasc diode at the master, under the ceiling

v_pref     = spec.vdd - abs(s_p.vgs_v)              # pref node headroom
vgs_master = abs(sizing.get(master.ref, s_m).vgs_v)
n1_max     = v_pref - abs(s_c.vds_sat_v) - _MARGIN_V - spec.vss
target = vgs_master + abs(s_c.vgs_v)             # ideal ncasc |Vgs|
cap    = n1_max     + abs(s_c.vgs_v)             # ceiling on ncasc |Vgs|
w_d = _size_diode_for_vgs(model, tech, "nmos", s_d.l_um, s_d.ids_a,
                          target, cap_vgs=cap)

The pinned node is n1 = V_GS,ncasc − V_GS,cascode. To make n1 equal the master's V_GS, we want the ncasc diode's V_GS = vgs_master + V_GS,cascode — that's target. But n1 can't exceed n1_max (the pref node minus the cascode's own Vdsat minus a 0.05 V safety margin), so we cap the diode's V_GS at cap. Part 4's engine picks the nearest chip that respects that ceiling.

Traced by hand — the cap bites

With vdd = 1.0, vss = 0, pref PMOS |V_GS| = 0.50, master V_GS = 0.40, and the weak-inversion cascode landing at V_GS = 0.35, Vdsat = 0.10:

QuantityComputationValue
v_pref1.0 − 0.500.50 V
n1_max0.50 − 0.10 − 0.05 − 00.35 V
target (ideal ncasc V_GS)0.40 + 0.350.75 V
cap (ceiling)0.35 + 0.350.70 V
ncasc V_GS chosennearest to 0.75, but ≤ 0.700.70 V
pinned Vds (n1)0.70 − 0.350.35 V

The ideal would have pinned Vds at the master's 0.40 V, but the pref node's headroom caps it at 0.35 V — within 50 mV, a big improvement on the raw mirror hop, and honestly limited by the ceiling rather than pretending.

master Vds = 0.40 V (goal) ceiling n1_max = 0.35 V pinned Vds = 0.35 50 mV short
The tuner reaches for the master's Vds and gets within 50 mV — stopped not by choice but by the pref node's ceiling.

Part 7 · tune_bias_levels() — the orchestrator

The one job: guard the inputs, copy the sizing so nothing is mutated, run both tuners, and hand back the tuned sizing plus the legs' floor-resistor values.

def tune_bias_levels(blocks, ids_map, sizing, model, spec, tech):
    if not isinstance(model, GmIdModel): return sizing, {}
    bg = blocks.blocks.get("bias_gen")
    if bg is None or not bg.mosfets: return sizing, {}

    tuned = dict(sizing)                          # never mutate the caller's map
    level_r: dict[str, float] = {}
    consumers = [d for name, b in blocks.blocks.items()
                 if name != "bias_gen" for d in b.mosfets]
    _tune_cascode_legs(bg, consumers, ids_map, tuned, model, spec, tech, level_r)
    _tune_pref_cascode(bg, tuned, model, spec, tech)
    return tuned, level_r

Read it top to bottom. Two guards bail out harmlessly — wrong model, or no bias generator — returning the original sizing and an empty resistor map. Then dict(sizing) makes the promise real: the input mapping is never touched; a fresh copy absorbs every change. The consumers list is "every device that isn't in the bias generator" — the outside world the legs must satisfy. Finally the two tuners run in place on tuned and level_r, and both come back.

geometry pass+ DC check tune_bias_levelsthis file merge level_rover generic R
Where it sits: the last, best-informed word on the level nets, its resistor values layered over the generic fallback.

That last output matters: level_r is meant to be merged over the generic values from size_resistors, which cannot derive a level for a mid net and falls back to a representative value. This file supplies the real number.

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

The idea the code never writes down

Both tuners are building the same equation — a rail voltage split into a device's V_GS plus a small, deliberate margin:

Vrail= | VGS | + Vfloor

The cascode leg builds it out of atoms — a diode for the |V_GS| term, a resistor for the V_floor term. The pref branch builds the same shape out of two stacked transistors (ncasc diode minus cascode) to place a pinned node. One equation, two constructions.

The reveal: why the rail tracks Vth

Here is the payoff promised in Part 2. The leg's diode is the same device type as the consumer cascode, and it was re-sized to carry the same V_GS. So imagine temperature rises and threshold voltage Vth drifts up by 60 mV:

  • The consumer's V_GS rises by ~60 mV — it now needs a higher gate voltage.
  • The diode's V_GS rises by the same ~60 mV — so the rail it produces rises by ~60 mV too.
  • The resistor's floor (I·R) doesn't move — but it was never supposed to; it's a fixed margin, not the tracking part.

The rail and the demand move in lockstep. Had the leg used a fixed resistive divider for the whole rail, the rail would have stood still while the consumer's need climbed — and the cascode would drift out of its intended operating point. The diode mirroring the consumer's V_GS is the entire reason the bias is robust over process and temperature.

cold hot (Vth up 60 mV) floor (fixed) diode Vgs 0.646 rail 0.750 heat up floor (fixed) diode Vgs 0.706 rail 0.810 — matches demand
The diode grows exactly as the consumer's need grows; the fixed floor rides along. The rail chases Vth for free.

What it honestly does not do

  • It acts only on the GmIdModel path — any other model returns the sizing untouched with an empty resistor map.
  • It touches only the two structural shapes it recognizes; a bias generator (or foreign netlist) without a diode-on-a-resistor leg or a mirror-drain cascode passes straight through.
  • A shared rail with conflicting consumer demands gets only the mean — the best a single leg can offer, not a per-consumer optimum.
  • The pref cascode reaches the master's Vds only as far as the pref node's headroom allows; when the cap bites, it stops short rather than pretending.
  • Widths are snapped to the grid, so the actual V_GS is the nearest available chip, not the exact target — and the code reads back the actual value rather than assuming.

Why it's elegant

The whole file rides one insight: a diode-connected device is a self-calibrating V_GS reference. Size it to reproduce a downstream device's V_GS and it will track that device over every corner for free — leaving only a small, fixed floor for a humble resistor to supply. Two tiny constructions, one robust idea.


In one sentence: bias_levels.py re-sizes the bias generator's level devices — the ones set by an absolute voltage rather than a mirror ratio — by making each cascode leg's diode reproduce its consumers' V_GS (so the rail tracks Vth) and a floor resistor supply the small remainder, and by pinning the pref branch's cascoded mirror as near the master reference as headroom allows.