plan.py — explained

circuitgenome/sizer/gmid/plan.py — before drawing a single transistor, pin down the current each device is forced to carry and the target each device must hit.

Part 1 · Why this file even exists

The one job: before any transistor gets a width or a length, pin down two things for every device — the current it is forced to carry, and the performance target it must meet. No geometry yet, just derivations.

Here is the whole story without reading a line of code.

Sizing an op-amp is like casting and budgeting a play before you build the set. You cannot start hammering scenery together until you know who is in each role and how much money each scene gets. plan.py is that pre-production meeting. It runs in two acts — the sizer's Phase 2 and Phase 3 — and both produce nothing but plans. Not one micron of transistor is drawn here.

Why split it out? Because two very different kinds of number get decided, and confusing them is how sizers go wrong:

  • What cannot be chosen. Every device's quiescent current is dictated — by the bias reference spec.ibias and by Kirchhoff's current law. You do not get to pick it; physics already did.
  • What must be achieved, and what is chosen. The performance spec demands a certain gain-bandwidth, swing, phase margin — which translate into a required gm per stage and compensation caps. And a design-intent registry chooses each device's role, its gm/Id operating region, and its channel length.

Do this carefully up front and the geometry solver that follows has a clean, KCL-consistent target sheet. Skip it, and the solver invents currents ad-hoc, quietly violates KCL at some node, and chases gain targets that the bias current can never physically deliver.

The cast of characters

Two orchestrators, five helpers. Phase 2 on the left, Phase 3 on the right; the currents computed on the left flow into the right. Hover any box to trace its link.

currents assign_currents() Phase 2 — bias currents plan_devices() Phase 3 — sizing plan assign_ids() KCL + spec.ibias → per-device IDS size_load_resistors() branch current → load Ω _model_for() intent knobs → GmIdModel compute_requirements() spec → gm req, caps, warnings resolve_transistor_intents() registry → role · region · L
Two acts. Phase 2 fixes what physics dictates; Phase 3 derives what the spec demands and what the designer chooses. We learn them left-to-right, helpers first.

Looking ahead: before touching a function, Part 2 builds the three words the whole file leans on — quiescent current, KCL, and the gm/Id knob — and draws the two-phase pipeline as one picture.

Part 2 · Three words, one pipeline

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

Quiescent current — the idle flow through each pipe

A transistor at rest, with no signal wiggling it, still passes a steady quiescent current — its DC operating point. Picture the whole amplifier as a network of water pipes: even when nobody turns a tap, there is a baseline flow humming through each one. Phase 2's entire job is to write down that baseline flow for every device.

KCL — water in equals water out

At any junction of pipes, the water flowing in must equal the water flowing out. That is Kirchhoff's current law. It is why the currents cannot be freely chosen: fix the tail current and the two branches of a differential pair are forced to split it. There is no room to negotiate.

gm/Id — the efficiency dial

Every transistor has a dial called gm/Id — how much transconductance (gain-per-volt) you extract per amp of current you spend. Turn it up and the device runs in weak inversion: more gain-efficiency, smaller Vgs and Vdsat, but slower. Turn it down and it runs strong: fast, but current-hungry. Phase 3 uses a lookup-table model to convert between this dial and real volts and amps.

One more character: the intent registry — a per-functional-block table that says "the input pair is a signal device at gm/Id ≈ 15, length 2×min; the tail is a current source at gm/Id ≈ 10, length 4×min," and so on. It is where the chosen design decisions live.

The whole file as one pipeline

inputs view spec tech intent Phase 2 assign_currents CurrentPlan ids · load R · gd Phase 3 plan_devices (also reads spec, tech, intent) SizingPlan
Two pure functions in series. Phase 2's CurrentPlan becomes an input to Phase 3, which emits the SizingPlan. Nothing here has a width.

Both boxes are pure derivations: same inputs in, same plan out, no hidden state, no geometry. Keep that in mind — it is the punchline we will unpack in Part 6.

Part 3 · assign_currents() — Phase 2, the currents you don't get to pick

The one job: hand every device its quiescent current from spec.ibias and KCL, and size the rail-referenced load resistors from the first-stage branch current.

def assign_currents(view, spec, tech) -> CurrentPlan:
    ids_map = assign_ids(view.slot_transistors, view.all_transistors, spec)
    load_resistors = size_load_resistors(view.slot_resistors, spec, tech)
    gd_load_r = (1.0 / min(load_resistors.values())) if load_resistors else 0.0
    return CurrentPlan(ids_map=ids_map, load_resistors=load_resistors,
                       gd_load_r=gd_load_r)

Three short lines, three jobs: assign the currents, size the resistors, and compute one conductance the next phase will need. Let's trace each with real numbers.

The current split, traced by hand

assign_ids reads each transistor's slot (its functional role) and stamps a current on it. The rules that matter here:

  • The tail (a full-bias slot) carries the whole reference: ids = spec.ibias.
  • Each input-pair transistor (a half-bias slot of two devices) carries spec.ibias / 2.

Say spec.ibias = 40 µA. The tail current source sets a 40 µA branch. That current arrives at the pair's common node and, by KCL, must split into the two matched input transistors:

spec.ibias = 40 µA sets the tail branch tail carries full ibias I_tail = 40 µA input M1 ibias/2 = 20 µA input M2 ibias/2 = 20 µA KCL at the pair node: 40 µA = 20 µA + 20 µA
The tail current is set by the reference; the split is not a choice — KCL forces 40 = 20 + 20. That is the whole meaning of "what cannot be chosen."

Second-stage and third-stage devices scale off the same reference through fixed ratios (ibias · second_stage_current_ratio, etc.), so every current in the amplifier traces back to that one number.

Sizing the load resistors

When the first stage uses a plain resistor load (rather than an active mirror), size_load_resistors picks each R so the DC drop lands the output node at a sensible level. For a PMOS-input stage the resistor ties to ground and must set:

Vnode = Vth,n + Vov  ⇒  R = Vnode / (ibias/2)

With Vth,n = 0.4 V, an overdrive Vov = 0.2 V, and the branch current we just derived (20 µA):

V_node = 0.4 + 0.2 = 0.6 V
R      = 0.6 V / 20 µA = 30 kΩ

The last line of the function then turns the smallest load resistor into a conductance the next phase will fold into its gain math:

min load R = 30 kΩ gd = 1 / R gd_load_r ≈ 33 µS gd_load_r = 0.0 when the load is an active current mirror (no resistor)
The resistor's conductance 1/R is the extra load it hangs on the first-stage output node — Phase 3 needs it to compute the true gain, so Phase 2 hands it over pre-computed.

What comes back — CurrentPlan

FieldMeaningWhere it goes
ids_mapref → quiescent IDS (A), from KCL + ibiasPhase 3's gm math
load_resistorsref → Ω for the rail-referenced loadsthe final netlist
gd_load_rconductance the loads add at the output nodePhase 3's gain calc

Every number in it was forced, not chosen. That is Phase 2 in one sentence.

Part 4 · _model_for() — the oracle Phase 3 asks

The one job: pack the design intent's length and gm/Id choices into a single GmIdModel — the object that converts a gm/Id dial into real volts and amps.

def _model_for(tech, intent) -> GmIdModel:
    policy = GmIdPolicy(
        signal_l_mult=intent.signal_l_mult,
        cs_l_mult=intent.current_source_l_mult,
        cs_gmid=intent.current_source_gm_id,
        signal_nominal_gmid=intent.signal_gm_id,
        cascode_l_mult=intent.cascode_l_mult,
        cascode_gmid=intent.cascode_gm_id,
    )
    return GmIdModel(tech, GmIdLut(tech.gmid_lut), policy)

This little helper does no physics of its own — it is pure assembly. Think of it as filling out a settings form and then handing that form, together with the process characterization, to a calculator that knows how to answer questions like "at gm/Id = 12 and length 2×min, what is Vgs?"

signal_l_mult signal_gm_id cs_l_mult cs_gm_id cascode_l_mult cascode_gm_id intent knobs GmIdPolicy L multipliers + gm/Id regions tech GmIdLut GmIdModel gm/Id ↔ Vgs, Vdsat, gm
Six intent knobs become a GmIdPolicy; wrapped with the process (tech) and its lookup table, they form the model that every requirement calc in Phase 3 will query.

Why give this its own function? Because the model must be born from the intent. The same silicon behaves differently depending on which gm/Id region and length you commit to, so the calculator has to be pre-loaded with those choices before anyone asks it a question. Isolating that wiring keeps plan_devices readable — its first line just says "build my oracle," and moves on.

Part 5 · plan_devices() — Phase 3, targets and intent

The one job: from the spec (via the device model) derive what must be achieved — gm per stage, compensation caps — and from the registry resolve what is chosen — each device's role, gm/Id region, and length.

def plan_devices(view, currents, spec, tech, intent) -> SizingPlan:
    model = _model_for(tech, intent)
    gm_req_map, vod_max_map, cc_pf, cc2_pf, ceil_warnings = compute_requirements(
        view.slot_transistors, view.all_transistors, currents.ids_map,
        tech, spec, model, currents.gd_load_r,
    )
    tintents = resolve_transistor_intents(
        view.all_transistors, view.cascode_refs, intent.block_intents)
    return SizingPlan(model=model, gm_req_map=gm_req_map,
                      vod_max_map=vod_max_map, cc_pf=cc_pf,
                      cc2_pf=cc2_pf, tintents=tintents, warnings=ceil_warnings)

Four beats: build the oracle (Part 4), ask it for the performance requirements, resolve the per-device design intent, and pack it all into a SizingPlan. The two derivations are independent branches that both feed the same output object.

model · ids_map · gd_load_r · spec · tech compute_requirements() gm_req_map — required gm (A/V) vod_max_map — max Vdsat (V) cc_pf, cc2_pf — comp caps (pF) warnings — gm-ceiling advisories resolve_transistor_intents() block registry → for each device: role · gm/Id region · length L tintents map SizingPlan the Phase 3 output object
Two independent derivations converge: the spec-driven requirements on the left, the registry-driven intent on the right, both sealed into one plan.

Where the gm requirement comes from — with numbers

The performance spec is really a demand on the first-stage transconductance. To hit a gain-bandwidth GBW driving a load capacitance CL, the amplifier needs roughly:

gm1 ≈ 2π · GBW · CL

For GBW = 10 MHz and CL = 2 pF:

gm_req = 2π · 10e6 · 2e-12 ≈ 1.26e-4 A/V = 126 µS

That number goes into gm_req_map for the input pair. compute_requirements does the same for later stages, and derives the Miller compensation caps cc_pf / cc2_pf that keep the loop stable (None when a topology needs none).

The honest limit — the gm ceiling

Here is the reveal that makes this file trustworthy. A device can only deliver so much gm at a given current. The most efficient setting, deep weak inversion, tops out around gm/Id ≈ 25 /V. With our branch current of 20 µA:

gm_max = (gm/Id)_max · Ids = 25 · 20 µA = 500 µS

Our 126 µS request fits comfortably — good. But suppose the spec demanded gm_req = 600 µS at the same 20 µA. That is physically impossible at this current, no matter the width. Rather than lie, compute_requirements clamps to the ceiling and appends a warning:

the ceiling gm_max = 500 µS need 126 µS ✓ a spec that overreaches need 600 µS ✗ > 500 clamp to 500 µS + emit warning
A requirement below the ceiling is achievable; one above it cannot be met at this bias current, so the code caps it and warns instead of pretending.

What comes back — SizingPlan

FieldMeaningKind
modelthe gm/Id oracle (LUT + policy)tool
gm_req_mapref → required gm (A/V)must achieve
vod_max_mapref → max Vdsat from output swingmust achieve
cc_pf / cc2_pfcompensation cap(s), pF (or None)must achieve
tintentsref → role · region · Lis chosen
warningsgm-ceiling advisorieshonesty

Part 6 · The hidden separation, limits, and the one line

Step back and the whole file is answering three different questions, and its cleverness is refusing to blur them. Each maps to a specific piece of code:

cannot be chosen quiescent currents (KCL + spec.ibias) load resistor values → Phase 2 must be achieved gm requirements max Vdsat (swing) compensation caps → compute_requirements is chosen device role gm/Id region channel length L → resolve_intents
The file's real structure: three categories of decision, never mixed. Confusing "forced" with "chosen" is exactly the bug this separation prevents.

The hidden equation

Although the file never writes it as one formula, everything Phase 3 derives is anchored to a single relationship between the spec and the bias current — the gm the design must reach, given what physics allows at that current:

2π·GBW· CL (gm/Id)max · Ids

Read it aloud: "the gm the spec asks for must not exceed the gm the device can physically deliver at the current KCL forced on it." The left side is Phase 3's demand; the right side is the ceiling set by Phase 2's current. When the inequality fails, that is precisely the warning we traced.

What this file honestly does not do

  • It computes no geometry — no widths, no fingers, no layout. Those come later, guided by this plan.
  • It does not fix an over-reaching spec; it clamps to the ceiling and warns, leaving the verdict to downstream checks.
  • The currents are only as right as the slot classification (which device is "tail," which is "input pair") handed to assign_ids.
  • Both functions are pure — they never mutate view, spec, or intent; each returns a fresh plan object.

Why it's elegant

The whole design rides one discipline: separate what physics dictates from what the spec demands from what the designer chooses, and resolve each with its own function. Currents fall out of KCL, targets fall out of the spec through the gm/Id model, and intent is looked up from a registry — three clean derivations, zero geometry, so the hard geometric search that follows starts from a target sheet it can trust.


In one sentence: plan.py runs the sizer's two derivation phases — Phase 2 assigns every device's quiescent current from spec.ibias and KCL and sizes the load resistors, and Phase 3 turns the performance spec (through a gm/Id device model) into per-stage gm requirements and compensation caps while resolving each device's chosen role, region, and length — emitting two pure plan objects and never drawing a single transistor.