geometry.py — explained

circuitgenome/sizer/gmid/geometry.py — how the gm/Id path computes every transistor's width and length instead of searching for them.

Part 1 · Why this file even exists

The one job: for every transistor whose current is already fixed, hand back a concrete (W, L) — computed straight from a lookup table, then patched for symmetry, mirror ratios, and one deliberate margin — instead of asking a constraint solver to search for it.

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

Most of this sizer sizes transistors by search. A CP-SAT solver (a constraint-satisfaction engine, like a Sudoku solver) is told the rules — "these two must match," "this mirror must be 3× that one," "everyone must saturate" — and it hunts through combinations of widths until it finds a set that satisfies every rule at once. Search is powerful, but it is slow, and it can fail to find anything even when an answer exists.

This file makes a bet: for the gm/Id path, we don't have to search at all. The current through every device is already pinned by Kirchhoff's current law. The transconductance target gm/Id is already chosen. Once those two are fixed, physics leaves no freedom in the width — the lookup table hands it to you directly. So instead of a solver exploring a tree, we walk a short, deterministic forward pass: plug in, read out, done.

Analogy: it's the difference between a Sudoku puzzle and a recipe. Sudoku (CP-SAT) means trying values and backtracking until nothing contradicts. A recipe (this file) means every quantity is already determined — "2 cups per person, 4 people, therefore 8 cups" — you just evaluate it. Same kitchen, no guessing.

CP-SAT — search & backtrack many branches tried, most discarded geometry.py — forward pass Id, gm/Id LUT → W (W, L) ✓ no branching
Left: a solver explores and backtracks. Right: with current and gm/Id fixed, the answer is a straight-line evaluation — one path, no dead ends.

The cast of characters

Seven functions. One orchestrator on top; five helpers it leans on; one physics leaf. Hover any box to trace who it calls — we'll learn them bottom-up, so the orchestrator reads like plain English by the time we reach it.

assign_geometry_gmid() the forward pass swing_gm_id_floor() weakest inversion for swing _output_stage_slots() who sits on the output? _mirror_tied_refs() whose W is overwritten later _apply_symmetry() matched pairs copy the anchor _apply_mirror_ratios() W = ratio × reference W _apply_load_current_margin() knife-edge load gets +5% top row: decide & pick · bottom row: patch the raw geometry
The orchestrator calls six helpers. Three answer questions before sizing; three patch the geometry after. We start with the leaves.

Looking ahead: Part 2 builds the one idea that makes the whole forward pass possible — why a fixed current and a fixed gm/Id leave the width with nowhere to hide.

Part 2 · Why geometry can be computed, not searched

One idea unlocks everything. It is not scary.

Current density is the missing link

A MOSFET's drain current scales with its width. Double the width, double the current — everything else held fixed. So engineers talk about a current density: current per micron of width, written Id/W (units of A/µm). It is like people per square metre at a concert.

The magic of the gm/Id method is that this density is not free: it is completely determined by two things you can read off a lookup table (the LUT) — the chosen inversion level gm/Id and the channel length L. Pick those, and the LUT tells you exactly how crowded each micron of channel is.

So the width has nowhere to hide

If you know the total current (people at the concert) and the density (people per square metre), the area is forced:

W = Id ÷ (Id/W)

That single division is what GmIdModel.geometry_for() does. There is no search, no backtracking, no solver — just a division. This is the beating heart of the entire file, and it fits on one line of physics.

Traced with real numbers

Say Kirchhoff has already fixed the device's current, and the intent picked an inversion level and length:

Id      = 20 µA           # pinned by KCL
gm/Id   = 15 S/A          # chosen inversion level
LUT says  Id/W = 5 µA/µm  # density at (gm/Id=15, L)

W = Id / (Id/W) = 20 / 5 = 4.0 µm      # forced — no choice

Four microns. Not "somewhere between 3 and 5, let the solver decide" — exactly four, then snapped to the manufacturing grid.

But three things still need patching

If every device stood alone, we'd be done after that division. Real circuits add three coupling rules the raw per-device pass ignores — and the last four helpers exist precisely to patch them, in order:

1+2 · LUT (W,L), snap W 3 · symmetry pairs copy anchor 4 · mirrors W = ratio × ref 5 · load margin +5% if knife-edge sizing ✓ done one direction, no loops back — a genuine forward pass
The whole file, drawn once. Raw per-device geometry first; then three coupling patches applied left-to-right; then the sizing is final.

Each remaining Part is one box in this pipeline. Learn the boxes and the orchestrator in Part 8 is just "call them in this order."

Part 3 · swing_gm_id_floor() — leave room to swing

The one job: for an output-path device, find the smallest gm/Id whose saturation voltage still fits inside half the output-swing budget — so the output can swing without the device dropping out of saturation.

An output device must not hog voltage the signal needs to swing through. A device's "cost" here is its Vds_sat — the minimum drain voltage it needs to stay a good current source. Pushing a device toward weak inversion (higher gm/Id) shrinks that cost. So this function looks for the gentlest push that gets the cost under budget.

Analogy: a doorway with a fixed height (the swing budget). A person who is too tall must crouch (weaken inversion) to fit. We want the smallest crouch that clears the frame — crouch more than necessary and you lose other qualities (output resistance). The floor is that minimum crouch.

def swing_gm_id_floor(model, dtype, l_um, vod_max) -> tuple[float, bool]:
    axis = [float(g) for g in model.lut.gm_id_axis]
    target = vod_max * _SWING_VDSAT_FRACTION      # half the budget (0.5)
    for g in axis:                             # ascending → Vdsat descending
        if model.lut.vdsat(dtype, g, l_um) <= target:
            return g, True
    weakest = axis[-1]
    return weakest, model.lut.vdsat(dtype, weakest, l_um) <= vod_max

Why half the budget (_SWING_VDSAT_FRACTION = 0.5)? Because the swing bench measures the tracking region out to where the small-signal slope drops to 0.7, which cuts off before the true Vdsat point — a device sized exactly at the budget measured ~50 mV short (issue #126). Targeting half the budget is the validated margin that makes the spec real.

gm/Id ascending → gm/Id 5Vdsat 220 gm/Id 10Vdsat 170 gm/Id 15Vdsat 140 ✓ gm/Id 20Vdsat 115 gm/Id 25Vdsat 100 first Vdsat ≤ target 150 mV → stop, floor = 15
Scan left-to-right; the first Vdsat that fits half the budget wins. The floor is the gentlest inversion that still clears the swing frame.

Traced with real numbers

Take a device with output-swing budget vod_max = 0.30 V:

target = 0.30 × 0.5 = 0.15 V          # half the budget
gm/Id 5  → Vdsat 0.22  > 0.15  skip
gm/Id 10 → Vdsat 0.17  > 0.15  skip
gm/Id 15 → Vdsat 0.14  ≤ 0.15  → return (15, True)   # the floor

The bool is a feasibility flag

If no gm/Id gets under the half-budget target, the loop falls through and returns the table's weakest inversion as a best effort. The boolean then answers a harsher question: does even that weakest inversion fit the raw (full) budget? If not — False — the output stage cannot meet the swing spec at all, at any sizing. That honest "no" propagates all the way up to feasible = False.

Why the name. floor is exactly right: it is a lower bound on gm/Id. The device may go weaker, but never stronger than this and still swing. I like that the name states the constraint's direction.

Part 4 · The two bookkeepers

Before sizing, the orchestrator asks two yes/no questions about which devices deserve special handling. Both helpers just build sets — no physics.

_output_stage_slots() — who actually sits on the output?

The one job: return the set of slots whose devices really touch the amplifier's output node, so only they get the swing floor.

def _output_stage_slots(slot_transistors) -> frozenset[str]:
    if any(s in slot_transistors for s in STAGE_SLOTS):
        return STAGE_SLOTS          # multi-stage: the later stages own the output
    return frozenset({"load"})    # one-stage: the load is the output path

The subtlety: compute_requirements also puts swing bounds on the first-stage load. But in a multi-stage amp, only the last stage touches the output — so if any second/third-stage slot exists, the load must not be re-biased for swing. Only a one-stage circuit keeps the load as its output path.

any stage slot? yes (multi-stage) no (one-stage) STAGE_SLOTS { "load" }
Multi-stage: the output belongs to the later stages, spare the load. One-stage: the load is the output.

_mirror_tied_refs() — whose width gets overwritten later?

The one job: find every current-mirror output device — because its width is destined to be overwritten in step 4, so a swing floor placed on it now would not stick.

def _mirror_tied_refs(all_transistors) -> set[str]:
    groups, diodes = {}, {}
    for ref, (device, _slot) in all_transistors.items():
        gate = device.terminals.get("g")
        if not gate: continue
        key = (gate, device.type)                 # group by shared gate net + type
        groups.setdefault(key, []).append(ref)
        if device.terminals.get("d") == gate:      # drain tied to gate = diode
            diodes.setdefault(key, ref)
    tied = set()
    for key, members in groups.items():
        if len(members) >= 2 and key in diodes:
            tied.update(m for m in members if m != diodes[key])
    return tied

A current mirror is a family of transistors sharing one gate net. The one whose drain is tied to that gate (d == g) is diode-connected — it is the reference that sets the bias. Everyone else in the family is an output that copies it. Those outputs are "tied": their inversion level tracks the reference, so a per-device floor can't hold. They're checked against the raw budget only after the mirror pass (Part 8, step 7).

shared gate net · same type → one group gate net reference diode (d=g) output A tied ✗ floor output B tied ✗ floor
The diode member is the anchor; the dashed outputs are "tied" — their geometry is set later by ratio, so no swing floor is placed on them now.

Part 5 · _apply_symmetry() — matched pairs share one geometry

The one job: force every matched pair (input pair, symmetric load, tail) to have identical W and L by copying the group's first device — the anchor — onto the rest.

A differential amplifier is only balanced if its matched devices are truly identical. Analogy: identical twins must wear the exact same size — not "close," the same. Rounding each independently could hand two "matched" devices slightly different widths and unbalance the amp. So we pick one as the anchor and copy it verbatim.

def equalize(refs):
    anchor = refs[0]
    for r in refs[1:]:
        W[r], L[r] = W[anchor], L[anchor]      # plain assignment — verbatim copy

for slot, devices in slot_transistors.items():
    if slot not in _SYMMETRY_SLOTS: continue       # input_pair, load, tail_current
    groups = {}
    for d in devices:
        if d.type in ("nmos", "pmos") and d.ref in W:
            groups.setdefault((d.type, ids_map.get(d.ref)), []).append(d.ref)
    for grp in groups.values():
        equalize(grp)

The single most important line is the grouping key: (d.type, ids_map.get(d.ref)). Devices match only when they share a type and the same planned current. That is what keeps a folded-cascode load's folding sinks (which carry pair + cascode current) separate from its cascode devices (cascode current only) — different currents, different groups, no accidental equalizing.

group (nmos, 20µA) M1anchor M2→ copy group (pmos, 40µA) — separate! M3anchor M4→ copy after: W[M2],L[M2] = W[M1],L[M1] · W[M4],L[M4] = W[M3],L[M3] different current ⇒ never merged, even in the same slot
Same type and same current makes a group; the first device is copied onto the rest. Different-current devices stay independent.

A second block handles fully-differential cross-slot pairssecond_stage_p ↔ second_stage_n and third_stage_p ↔ third_stage_n — gathering same-type devices across the two mirrored halves and equalizing them the same way. Same rule, wider net.

Part 6 · _apply_mirror_ratios() — the exact ratio rule

The one job: set each current-mirror output's width to the exact current ratio times the diode reference's width, at the reference's length — no fractions, no rounding of the ratio.

A current mirror's whole purpose is to copy a current by a chosen ratio. If the reference carries 20 µA at 4 µm wide, an output meant to carry 50 µA must be 50/20 = 2.5× as wide — because current scales with width. Getting that ratio slightly wrong biases the whole circuit wrong, so the code multiplies floats directly and never approximates with a Fraction.

groups = {}
for ref, (device, _slot) in all_transistors.items():
    gate = device.terminals.get("g")
    if gate:
        groups.setdefault((gate, device.type), []).append(ref)
for members in groups.values():
    if len(members) < 2: continue
    diodes = [m for m in members if ... g == d ...]   # the reference
    if not diodes: continue
    ref0 = diodes[0]
    i_ref = ids_map.get(ref0, 0.0)
    if i_ref <= 0: continue
    for m in members:
        if m == ref0: continue
        i_m = ids_map.get(m, 0.0)
        if i_m <= 0: continue
        L[m] = L[ref0]
        W[m] = snap_w((i_m / i_ref) * W[ref0])       # the ratio rule

That last line is the whole function. Everything above it just finds the reference (ref0, the diode) inside each shared-gate family and its current i_ref. Note it also copies L[ref0] onto the output: a mirror only tracks accurately when both devices share the same length.

Wout= IoutIref Wref

Traced with real numbers

Reference diode already sized at W_ref = 4.0 µm carrying I_ref = 20 µA. An output in the same family must carry I_out = 50 µA:

ratio = I_out / I_ref = 50 / 20 = 2.5
W_out = snap_w(2.5 × 4.0) = snap_w(10.0) = 10.0 µm
L_out = L_ref                                    # matched length
reference (diode) W = 4.0 I_ref = 20 µA × 2.5 = 50/20 output W = 10.0 I_out = 50 µA
Width scales exactly with the current ratio. The output is 2.5× wider because it carries 2.5× the current — same length, exact ratio.

This is precisely the constraint CP-SAT would have searched for. Here it is one multiplication — the deterministic replacement in action.

Part 7 · _apply_load_current_margin() — the knife-edge fix

The one job: in one specific fragile topology, make the current-source load 5% wider so it wins a tug-of-war against the tail current that would otherwise drift out of control.

Picture a single-ended first stage whose load is a plain current source balancing a mirrored tail current. There is no feedback fixing the load-vs-tail balance — no diode in the signal path, no common-mode feedback. At an exact mirror ratio the two currents are nominally equal, so the fold node has no reason to settle: it drifts until the input pair triodes or the output rails. It sits on a knife-edge.

The fix: size the load mirror slightly strong (_LOAD_CS_MARGIN = 1.05). Now the load gently pulls the node toward its supply rail and it settles there, keeping the input pair saturated with the load right at the edge of saturation. The old uncascoded design provided this ~4% surplus by accident; this makes it an explicit design intent (issue #103).

def _apply_load_current_margin(W, slot_transistors, snap_w):
    fd = any(s in slot_transistors for s in ("second_stage_p", ...))
    load = [d for d in slot_transistors.get("load", []) if d.type in ("nmos", "pmos")]
    tail = [d for d in slot_transistors.get("tail_current", []) if ...]
    if fd or not tail or classify_load(load, []) is not LoadKind.CURRENT_SOURCE:
        return                                   # not the fragile case — do nothing
    for d in load:
        if d.ref in W:
            W[d.ref] = snap_w(W[d.ref] * _LOAD_CS_MARGIN)

The three-part guard is strict on purpose: it fires only for a single-ended (not fully-differential, so fd is false), MOSFET-tailed, current-source load. Any other topology returns untouched — no harm.

before — knife-edge W = 8.0 node drifts ✗ × 1.05 after — settles to rail W = 8.4 pair stays saturated ✓
A deliberate 5% width surplus lets the load win the balance and pin the node, trading a hair of symmetry for a stable bias point.

Part 8 · assign_geometry_gmid() — the forward pass assembled

The one job: run the whole pipeline in order — raw LUT geometry, symmetry, mirror ratios, load margin — build the final TransistorSizing, and return an honest feasible verdict for the swing spec.

By now every helper is familiar, so the orchestrator reads like a checklist. It threads two dictionaries — W and L — through the pipeline, mutating them step by step, then packages the result.

Setup — ask the two bookkeepers first

out_slots    = _output_stage_slots(slot_transistors)   # Part 4a
mirror_tied  = _mirror_tied_refs(all_transistors)      # Part 4b
cascode_load = any(intents[d.ref].role == CASCODE for d in ...load...)

def snap_w(w_um):
    v = round(w_um / g.step) * g.step
    return float(min(max(v, g.min), g.max))       # snap to grid, clamp range

Steps 1+2 — the LUT division, per device

for ref, (device, slot) in all_transistors.items():
    ti = intents[ref]
    gm_id_min = None
    vod = vod_max_map.get(ref)
    if (vod is not None and slot in out_slots and ref not in mirror_tied
            and not (cascode_load and ti.role == SIGNAL)):
        gm_id_min, fits = swing_gm_id_floor(model, device.type, ...)   # Part 3
        if not fits:
            feasible = False; warnings.append(...)
    geo = model.geometry_for(device.type, ids_map[ref], ti.role, ...,
                             gm_id_min=gm_id_min)               # W = Id / (Id/W)
    W[ref] = snap_w(geo.w_um)
    L[ref] = geo.l_um

The long if is the swing-floor gate we built the bookkeepers for: apply a floor only to a device that (a) has a swing budget, (b) truly sits on the output, (c) is not a mirror output, and (d) is not a signal device under a cascode load (whose Vgs is a delicate stage-interface pin that must not move). Every excluded device is still checked later against the raw budget — nothing is silently skipped.

Steps 3–5 — the three patches, in order

_apply_symmetry(W, L, slot_transistors, ids_map)        # 3 · Part 5
_apply_mirror_ratios(W, L, all_transistors, ids_map, snap_w)  # 4 · Part 6
_apply_load_current_margin(W, slot_transistors, snap_w)   # 5 · Part 7

Order matters exactly once. Symmetry runs before mirror ratios. On the rare device that is both a matched-pair member and a mirror output, the mirror pass runs second and wins — and that is deliberate, because the mirror ratio is the bias-current-correctness fix. For plain matched pairs the two rules agree anyway, so ordering only decides the tie-break, never the value.

3 · symmetry pairs equalized 4 · mirror ratios overwrites overlap 5 · load margin +5% if fragile both/either device? the mirror ratio written second is the one that stands
The patches apply left to right. When symmetry and a mirror both touch one device, the mirror runs last and its exact ratio wins.

Steps 6+7 — package, then the honest post-mirror check

for ref, (device, _slot) in all_transistors.items():
    sizing[ref] = TransistorSizing(ref=ref, w_um=W[ref], l_um=L[ref], ids_a=...,
        vgs_v=model.vgs(...), vds_sat_v=model.vds_sat(...))   # 6

for ref, (_device, slot) in all_transistors.items():           # 7
    vod = vod_max_map.get(ref)
    if (vod is not None and slot in out_slots and ref in mirror_tied
            and sizing[ref].vds_sat_v > vod):
        feasible = False; warnings.append(...)
return sizing, warnings, feasible

Step 7 is the promise we made back in Part 4 coming due. The mirror-tied outputs skipped the swing floor, because their geometry was going to be overwritten by the ratio rule. Now that their final width is known, we check each one's actual Vds_sat against its raw budget. If it exceeds it, the output stage genuinely cannot swing — feasible = False, with a specific warning.

StepWhat it doesHelper
1+2Per-device (W, L) from the LUT; snap W to gridgeometry_for + snap_w
3Matched pairs copy the anchor's geometry_apply_symmetry
4Mirror outputs = ratio × reference W_apply_mirror_ratios
5Knife-edge load gets +5% width_apply_load_current_margin
6Build final TransistorSizing
7Raw-budget swing check on mirror-tied outputs

Part 9 · The hidden idea, the limits, and one sentence

The hidden idea it never spells out

Nowhere does this file say it, but its whole reason for existing is a shift in kind of computation. The rest of the sizer poses geometry as a constraint-satisfaction search: find widths W such that every rule holds at once. This file observes that once current and gm/Id are fixed, the constraints don't leave a search space to explore — they pin the answer. A search collapses into an evaluation:

searchWs.t.rules W= Id(Id/W)

Read it aloud: "stop searching for a width that satisfies the rules; the rules already tell you the width — divide." The symmetry, mirror-ratio, and load-margin patches are just the remaining rules that couple devices together, each resolved by copy or multiply rather than by a solver. That is the entire file's cleverness in one line.

What it honestly does not do

  • It only works when current and gm/Id are already fixed — it is the gm/Id path's tool, not a general sizer.
  • It trusts the LUT: garbage density in, garbage width out. There is no SPICE verification here.
  • The load-margin patch fires for exactly one fragile topology; every other case is left untouched by design.
  • Feasibility here is only about the swing spec. It reports an honest False when an output device can't fit its raw budget, but it does not repair it — that is left to the caller and the downstream checks.
  • It resolves the symmetry/mirror overlap by ordering, not by proving the two rules always agree — they do for matched pairs, but the code leans on "mirror runs last" rather than a guarantee.

Why it's elegant

The design's whole lightness comes from recognizing that a hard search problem was secretly a division in disguise. No solver, no backtracking, no timeout — a short forward pass with three coupling patches produces geometry that a CP-SAT engine would have spent real time hunting for, and produces it exactly.


In one sentence: geometry.py replaces CP-SAT search for the gm/Id path with a deterministic forward pass — read each width straight from the LUT (W = Id ÷ Id/W), then patch device coupling by copying matched pairs, multiplying mirror outputs by their exact current ratio, and nudging one knife-edge load 5% wider — returning final sizings plus an honest swing-feasibility verdict.