DC-Bias Feasibility, explained

circuitgenome/sizer/gmid/bias.py — does the tail current source still fit under the supply, and can we repair it if it doesn't?

Part 1 · Why this file even exists

The one job: after the sizer has chosen every transistor's current and width, does the tail current source still have enough voltage room to stay in saturation — and if not, can we quietly fix it?

Here is the whole story without a line of code.

The gm/Id sizer does something clever: it hands each device a current (by Kirchhoff's current law) and sizes it to hit a target gm/Id. But it reads its lookup table at a fixed Vds = Vdd/2, and it never checks that the devices, once stacked on top of each other, actually fit between the two supply rails with everyone still saturated.

Think of a supply rail as a ceiling of fixed height, and each transistor as a person who must stand at their full height to work correctly. Stack three people in a room and the top of the stack may punch through the ceiling. When that happens to the tail transistor at the bottom of a differential pair, it gets squashed — it drops out of saturation, sources far less current than assumed, and the amplifier's gm1 and gain-bandwidth collapse (this is issue #76, cause A).

good — tail saturated Vdd rail tail (sat) Vdsat fits ✓ input pair Vss bad — tail squashed tail (triode) too tall ✗ input pair lifted too high
Same room, same ceiling. On the right the input pair lifts the stack too high, and the tail has no room left to stand up.

So this file is a safety inspector that runs after sizing. It does three things: detect the squeeze, try to repair it by adjusting a couple of design knobs, and report an honest verdict (bias_feasible) so callers can trust or de-rate the metrics.

The cast of characters

Five functions. Hover any box to trace who it calls.

check_dc_operating_point() _apply_headroom() detect & repair the squeeze _tail_stack_to_rail() walk devices up to the rail _tail_gm_id_for_headroom() smallest gm/Id that fits _resize_group() nested helper _snap_w() width → grid
The orchestrator on top; the repair engine and its helpers below. We'll learn them bottom-up so the top reads easily.

Looking ahead: before any code, Part 2 builds the three words we need — Vgs, Vdsat, and headroom — as a single picture of a voltage stack.

Part 2 · The physics primer (three words, one picture)

You need exactly three ideas. None are as scary as they sound.

Vgs — how much a transistor "costs" to turn on

To conduct, a MOSFET needs a gate-to-source voltage Vgs. For our PMOS input pair, its source sits |Vgs| above its gate. If the gate is at the common-mode voltage Vcm, then the pair's source node — the net called net_tail — is dragged up to:

net_tail = Vcm + |Vgspair|

Vdsat — how much room a transistor needs to do its job

A current source only behaves like a current source while it stays saturated, which requires at least Vdsat of drain-to-source voltage. Below that it slips into triode and its current sags. Think of Vdsat as the transistor's minimum standing height.

Headroom — the room actually left over

The tail sits between net_tail and the Vdd rail. The gap it's given is the headroom:

headroomPMOS = Vdd − (Vcm + |Vgspair|)

The bias is healthy exactly when headroom ≥ Vdsattail. When headroom falls short, the tail is squashed.

Vdd = 1.0 V Vss = 0 V net_tail = 0.9 V Vcm = 0.5 V (gate) headroom = 100 mV Vdsat needs 150 mV |Vgs_pair| = 0.4 V
The tail needs 150 mV of standing height but is handed only 100 mV — it's 50 mV too tall for the gap. That is the bug, drawn to scale.

The one knob that saves us: gm/Id

Here's the lever the whole file pulls. Raising a device's gm/Id pushes it toward weak inversion, which makes both its |Vgs| and its Vdsat smaller. That's two wins at once:

  • Raise the tail's gm/Id → its Vdsat shrinks → it needs less room.
  • Raise the pair's gm/Id → its |Vgs| shrinks → net_tail drops → more room appears.
low gm/Id (strong) |Vgs| big Vdsat big raise gm/Id high gm/Id (weak) |Vgs| small Vdsat small
The gm/Id knob: turn it up and the transistor gets "shorter" in every voltage that matters.

And crucially — raising the pair's gm/Id is spec-safe, because the pair's gm requirement is a minimum. A stronger pair still meets spec. That's the loophole the repair exploits.

Part 3 · _snap_w() — the warm-up

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

def _snap_w(g: GridSpec, w_um: float) -> float:
    return float(min(max(round(w_um / g.step) * g.step, g.min), g.max))

Analogy: you can order lumber in whole inches only. Ask for 23.4 inches and the yard gives you 23. That's 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 we computed w_um = 2.34:

round(2.34 / 0.1) * 0.1  =  round(23.4) * 0.1  =  23 * 0.1  =  2.3 µm
max(2.3, 0.5) = 2.3       min(2.3, 100) = 2.3   ->  2.3 µm

Every width the repair invents is passed through this so the result is always manufacturable.

Part 4 · _tail_gm_id_for_headroom() — pick a gm/Id that fits

The one job: given a headroom budget, find the smallest gm/Id whose Vdsat still squeezes into it — and return None if even the weakest-inversion setting won't fit.

def _tail_gm_id_for_headroom(model, dtype, l_um, headroom_v) -> float | None:
    margin = 0.9 * headroom_v  # keep a little slack below the rail
    for gm_id in model.lut.gm_id_axis:  # ascending -> Vdsat descending
        if model.lut.vdsat(dtype, float(gm_id), l_um) <= margin:
            return float(gm_id)
    return None

Why "smallest gm/Id"? Because a smaller gm/Id means a larger Vdsat — which means the largest output resistance the tail can have while still fitting. We want the best current source that fits, not just any that fits. Walking the axis ascending and taking the first hit gives exactly that.

gm/Id → gm/Id 5Vdsat 200 gm/Id 10Vdsat 150 gm/Id 15Vdsat 120 ✓ gm/Id 20Vdsat 100 gm/Id 25Vdsat 90 first Vdsat ≤ margin (135 mV) → stop, return 15
Scan left-to-right; the first Vdsat that fits the margin wins, giving the highest-rout tail that still saturates.

The 0.9 × margin leaves a sliver of slack so a device that just barely fits on paper isn't riding the exact edge of the rail. If nothing fits — even the highest gm/Id in the table — it returns None, and the caller will have to try its other knob or give up honestly.

Part 5 · _tail_stack_to_rail() — walking up the chain

The one job: starting at net_tail, follow the series-stacked tail devices one by one up to the supply rail, collecting them in order.

def _tail_stack_to_rail(tail_devs, net_tail) -> list:
    by_drain = {d.terminals.get("d"): d for d in tail_devs}
    chain, node, seen = [], net_tail, set()
    while node in by_drain and node not in seen:
        seen.add(node)
        d = by_drain[node]
        chain.append(d)
        node = d.terminals.get("s")  # follow to the device below
    return chain

This is a classic linked-list walk, but the "pointers" are circuit nets. Each device's source is the next device's drain. Build a lookup by_drain[net] → device, then keep hopping: start at net_tail, grab the device whose drain is that net, jump to its source, repeat. The seen set is a seatbelt against a malformed loop.

net_tail dev A d=net_tail net_mid dev B d=net_mid Vdd
chain = [A, B]. Each hop follows a device's source to the next device's drain, until the net is the rail itself.

Why bother collecting the whole chain? Because a cascode tail is two (or more) devices in series, and Part 7 will need the sum of their Vdsats, not just one.

Part 6 · _apply_headroom() — the heart

The one job: detect the tail squeeze and, if it's real, repair it with a two-knob search — first the tail's gm/Id, then (only if needed) the input pair's — never mutating the caller's sizing.

This is the single most important function in the file. Everything else is a helper it leans on. Let's take it in four beats.

Beat 1 — bail out unless this is the gm/Id path with a real pair + tail

if not isinstance(model, GmIdModel): return sizing, []
ip, tc = slot_transistors.get("input_pair", []), slot_transistors.get("tail_current", [])
if not (ip and tc): return sizing, []
...
if not (s_ip and s_tc): return sizing, []

Every guard returns the original sizing untouched and an empty warning list. The contract is: "if I can't help, I do no harm."

Beat 2 — compute the headroom, and stop early if it already fits

vcm = (spec.vdd + spec.vss) / 2.0
vgs_pair = abs(model.vgs(ip_dev.type, s_ip.w_um, s_ip.l_um, s_ip.ids_a))
if ip_dev.type == "pmos":
    headroom = spec.vdd - (vcm + vgs_pair)
else:
    headroom = (vcm - vgs_pair) - spec.vss
vdsat_tail = model.vds_sat(tc_dev.type, s_tc.w_um, s_tc.l_um, s_tc.ids_a)
if headroom >= vdsat_tail: return sizing, []  # tail already fits

Note the PMOS/NMOS symmetry: a PMOS pair pushes net_tail up toward Vdd, an NMOS pair pulls it down toward Vss. Same idea, mirrored.

Beat 3 — build the candidate list (the clever part)

The repair searches over input-pair operating points, weakest-inversion loophole first:

cands = [(None, headroom)]                 # candidate 0: leave the pair as-is
for gm_id_pair in model.lut.gm_id_axis:      # then progressively weaker pairs
    ... w_p = _snap_w(tech.width, abs(s_ip.ids_a) / idw)
    vgs_p = abs(model.vgs(...))
    h = spec.vdd - (vcm + vgs_p) if ip_dev.type == "pmos" else (vcm - vgs_p) - spec.vss
    if h > headroom + 1e-9: cands.append((gm_id_pair, h))

Candidate 0 keeps the pair untouched (the cheapest fix — only move the tail). Every later candidate weakens the pair to buy more headroom h, and is only kept if it genuinely raises the room.

Beat 4 — try each candidate; take the first that fully fits

for gm_id_pair, h in cands:
    gm_id_tail = _tail_gm_id_for_headroom(model, tc_dev.type, s_tc.l_um, h)
    if gm_id_tail is None: continue
    repaired = sizing if gm_id_pair is None else _resize_group(sizing, pair_devs, gm_id_pair)
    repaired = _resize_group(repaired, tail_group, gm_id_tail)
    if model.vds_sat(...) <= h: return repaired, []   # verified fit ✓
    best = best or repaired                          # else remember closest
return (best, _warn()) if best else (sizing, _warn())

Two subtleties worth naming. First, _resize_group re-sizes a whole mirror group at one gm/Id — because the group shares a gate, one gm/Id keeps every mirror ratio intact (W ∝ I). Second, the code re-verifies the fit after snapping to the grid, because rounding the width can nudge Vdsat just over the edge. If nothing fully fits, it keeps the closest attempt and emits an honest warning rather than pretending.

The full repair, traced by hand

Using our running numbers — Vdd = 1.0, Vcm = 0.5, PMOS pair |Vgs| = 0.40, tail Vdsat = 0.15:

start: headroom 100 < Vdsat 150 short by 50 mV — repair needed knob 1: tail gm/Id only best Vdsat 100 mV vs margin 90 mV still short ✗ knob 2: also weaken pair |Vgs| 0.35 → headroom 150, fit ✓ net_tail = 0.5 + 0.40 = 0.90 V margin = 0.9 × 100 = 90 mV net_tail = 0.5 + 0.35 = 0.85 V margin = 0.9 × 150 = 135 mV ≥ 100
The tail knob alone can't close a 50 mV gap; weakening the pair opens 50 mV more room and the repair lands.

Part 7 · check_dc_operating_point() — the orchestrator

The one job: run the headroom repair, then add a cascode-aware budget check, and return the sizing plus an honest bias_feasible verdict.

sizing, warnings = _apply_headroom(...)
bias_feasible = not any("headroom" in w for w in warnings)

First it delegates to Part 6. The verdict starts as "feasible unless a headroom warning fired."

The cascode catch

A single-device check misses a stacked tail. Two cascoded devices each need their own Vdsat, so the real requirement is the sum:

chain = _tail_stack_to_rail(tail.mosfets, net_tail)
stacked_vdsat = sum(model.vds_sat(d.type, ...) for d in chain if d.ref in sizing)
if stacked_vdsat > headroom and not any("cascode" in w for w in warnings):
    warnings.append(...); bias_feasible = False
available 150 mV vs needed (stacked) 100 + 120 = 220 mV 220 > 150 → cannot bias, bias_feasible = False dev A dev B
A single-device check would see only 100 mV and pass; summing the cascode's two Vdsats reveals the real 220 mV demand.

The not any("cascode" in w ...) guard keeps the warning from being appended twice. The function returns (sizing, warnings, bias_feasible) — the repaired sizing, every honest warning, and the one boolean callers use to trust or de-rate the metrics.

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

Although the file never writes it as a single formula, every check here is testing one physical inequality — the whole voltage stack must fit under the supply with everyone saturated:

Vdd- (Vcm+ |Vgs,pair|) i Vdsat,i

Read it aloud: "the room left under the rail after the pair lifts net_tail must cover the sum of every stacked tail device's saturation voltage." The single-device path is just this with one term in the sum; the cascode path restores the missing terms.

What it honestly does not do

  • It only acts on the GmIdModel path — any other model returns feasible = True untouched.
  • It repairs only the tail squeeze (issue #76, cause A), not every possible bias failure.
  • When even weak inversion can't fit, it does not lie — it warns and lets the downstream SPICE DC check ground the final verdict.
  • It never mutates the caller's sizing; a repair always comes back as a new mapping.

Why it's elegant

The whole repair rides one insight: gm/Id is a single knob that shrinks both the room needed and the room consumed, and because gm is a minimum spec, turning that knob up is always safe. Two cheap moves — resize the tail mirror, then optionally weaken the pair — recover a bias that a naive sizer would have silently broken.


In one sentence: bias.py checks that the tail current source still has enough headroom under the supply to stay saturated, repairs a shortfall by turning up gm/Id on the tail (and, if needed, the input pair), and returns an honest bias_feasible verdict — including the summed-Vdsat demand of a cascoded tail that a single-device check would miss.