gmid_sizer.py — explained

circuitgenome/sizer/gmid/gmid_sizer.py — the conductor that runs the whole gm/Id op-amp sizing pipeline as five phases with typed hand-offs.

Part 1 · Why this file even exists

The one job: take a recognised circuit plus a spec and turn it into a fully sized, self-graded design — by running five specialist stages in order and handing a clearly-typed parcel from each one to the next.

Here is the whole story without a line of code.

Sizing an analog op-amp is not one calculation — it is a sequence of very different calculations. First you have to understand the circuit's shape (which transistor is the tail, which pair is the input, what's a cascode). Then you have to decide how much current flows in each branch. Only then can you work out each device's gm target, turn that into an actual width and length, and finally grade the result. Each of those steps needs different knowledge and different math.

If you tried to do all five in one giant function, you'd get a thousand-line tangle where a change to the current math could silently corrupt the geometry math. So gmid_sizer.py does the opposite: it is a conductor. It plays no instrument itself. It only decides who goes when, and carries the output of one player to the next.

Think of it as a five-station assembly line. A raw chassis enters at station 1; each station bolts on its part and slides the car to the next; a finished, inspected car rolls off station 5. Crucially, the thing sliding down the line is not a loose pile of parts — it's a labelled crate: a CircuitView, then a CurrentPlan, then a SizingPlan. Those typed hand-offs are the whole trick, and this hero figure is the spine of the entire package:

parsed · recogniser topology · tech · spec 1 · Analyze find the shape: slots, cascodes analyze.py 2 · Bias I KCL + ibias → per-branch I plan.py 3 · Plan gm targets + comp caps plan.py 4 · Size LUT geometry, check + repair geometry/bias/… 5 · Evaluate analytical metrics evaluate.py the typed crate that slides between stations CircuitView CurrentPlan SizingPlan sizing {ref→W/L} SizingResult sizes · metrics · bias_feasible
Five stations, left to right. Each one produces a labelled crate — CircuitView → CurrentPlan → SizingPlan → sizing — and the finished, self-graded SizingResult rolls off the end.

Notice this is a separate line from the Level-1 CP-SAT sizer. The gm/Id path is its own factory. And the model-independent topology math (KCL, mirror ratios) is borrowed from circuitgenome.sizer.shared rather than re-built here — the conductor reuses the orchestra's shared sheet music.

Looking ahead: Part 2 reads the function's signature and one-line body-per-phase, then names the analogy that makes the rest obvious — an assembly line whose stations you can test one at a time.

Part 2 · The signature & the assembly line

The one job of size_gmid: accept everything the recogniser and synthesiser already know about the circuit, plus the design spec, and drive the five phases to a SizingResult.

The scary-looking part is just the ingredients list. Every argument is something an earlier tool already produced; size_gmid does not compute any of them, it only consumes them:

def size_gmid(
    parsed: ParsedNetlist,                 # the raw SPICE netlist
    sr_result: SubcircuitRecognitionResult,# which sub-blocks were found
    fbr_result: FunctionalBlockRecognitionResult, # functional-block labels
    topology: TopologyTemplate,            # the intended op-amp topology
    tech: TechParams,                      # the PDK + the gm/Id LUT
    spec: SizingSpec,                      # gain / GBW / ibias targets
    intent: GmIdIntent = DEFAULT_INTENT,   # L & region design policy
) -> SizingResult:

The docstring's one warning is the load-bearing precondition: "Requires tech.gmid_lut." The whole path reads device physics from a lookup table baked into tech. No table, no gm/Id sizing.

Why an assembly line is exactly the right picture

On a real assembly line, the paint station does not need to know how the engine was bolted in. It only needs a car body of the agreed shape to arrive. That agreement — "what shape shows up at my station" — is the typed hand-off. It buys three things at once:

station N emits a crate typed crate station N+1 consumes it the contract at the seam buys you… ① test a station alone feed a fake crate in ② swap a station out same crate, new maths ③ localize a bug inspect the crate at the seam
Because each seam is a named type, a station can be tested, replaced, or debugged in isolation — the crate is the contract.

Hold onto benefit ①. It is the hidden idea of the whole file, and we return to it in Part 6. For now, let's follow one real circuit down the line.

Our running example — a two-stage Miller op-amp

We'll trace a classic textbook op-amp through all five stations, carrying real numbers the whole way:

  • Input pair M1, M2 (PMOS), tail source M5, mirror load M3, M4.
  • Second stage M6 with current source M7, Miller cap Cc.
  • Spec: ibias sets the tail, target GBW = 10 MHz, gain ≥ 60 dB.

Part 3 · Phases 1 & 2 — Analyze, then Bias currents

The one job of these two lines: first learn what the circuit is (which device plays which role), then decide how much current runs through each device.

# Phase 1 — Analyze: structural view of the recognised circuit.
view = analyze_circuit(fbr_result, topology)

# Phase 2 — Bias currents: IDS from KCL, rail-referenced load resistors.
currents = assign_currents(view, spec, tech)

Phase 1 → CircuitView

Analogy: before an electrician touches a house, they walk it and label every wire — "this is the main, this is the kitchen ring." analyze_circuit does exactly that: it takes the functional-block recognition result and the intended topology and produces a CircuitView, the labelled map everyone downstream reads:

CircuitView slot_transistors "input_pair" → [M1,M2] all_transistors ref → (Device, slot) blocks : OpAmpBlocks typed stage decomposition slot_resistors slot → [resistors] cascode_refs the stacked devices warnings topology-mismatch notes
The CircuitView is a pure description — no sizes yet, just "who is who." Every later phase reads from it.

For our op-amp, Phase 1 concludes: slot_transistors["input_pair"] = [M1, M2], ["tail_current"] = [M5], ["mirror"] = [M3, M4], and it records no cascodes. Nothing has been sized — this is pure structure.

Phase 2 → CurrentPlan

Now the current. This is Kirchhoff's current law as bookkeeping: spec.ibias fixes the tail, and every other branch current follows from the wiring. The tail current splits evenly between the two matched input branches — like a river of known flow forking into two equal streams:

M5 tail = 20 µA ← spec.ibias ½ ½ M1 = 10 µA M2 = 10 µA M3 = 10 µA M4 = 10 µA
KCL bookkeeping: 20 µA in the tail forks into two 10 µA legs, and each mirror device inherits its leg's current. No physics yet — just conservation of charge.

Phase 2 also sizes the rail-referenced load resistors (if the load is passive) and records their conductance. The result is a CurrentPlan with three fields: ids_map (ref → current), load_resistors (ref → Ω), and gd_load_r. For us:

refM1M2M3M4M5M6M7
ids_map (µA)10101010204040

Still no widths. Phases 1 and 2 answer "who" and "how much current" — the geometry comes later, and that separation is deliberate.

Part 4 · Phase 3 — Plan

The one job: turn the spec and the currents into per-device targets — how much gm each signal device must deliver, what compensation caps are needed, and each device's design intent — without yet choosing a single width.

# Phase 3 — Plan: gm requirements + compensation caps + per-device intent.
plan = plan_devices(view, currents, spec, tech, intent)

Analogy: the current plan told us how much water flows in each pipe. Phase 3 is the architect's spec sheet: it says how strong each amplifying device has to be to hit the customer's requirements, and reads each device's role-specific intent (its L, its inversion region) from the functional-block registry.

The single most important target is the input pair's transconductance. For a Miller op-amp the gain-bandwidth is set almost entirely by gm1 and the compensation cap:

gm1 = 2π · GBW · Cc

Plug in our numbers — GBW = 10 MHz, Cc = 2 pF:

gm1 = 2π × 10e6 × 2e-12  =  6.283 × 20e-6  ≈  126 µA/V

So Phase 3 writes gm_req_map["M1"] ≈ 126 µA/V. That single number is what Phase 4 will chase when it picks M1's width. The output of Phase 3 is the richest crate on the line:

SizingPlan gm_req_map M1 → 126 µA/V vod_max_map max VDS_sat from swing model : GmIdModel the LUT + region policy cc_pf / cc2_pf compensation caps (pF) tintents ref → TransistorIntent warnings gm-ceiling advisories
The SizingPlan is all targets and policy — the "what to aim for," carried into Phase 4 which decides the "how."

One honest subtlety lives in warnings: if the spec demands more gm than a device can physically deliver at its assigned current, Phase 3 doesn't crash — it records a gm-ceiling advisory and carries on. The plan is allowed to be ambitious; later phases and the final verdict decide whether ambition was met.

Part 5 · Phase 4 — Size (where targets become widths)

The one job: turn every target into a real, manufacturable width — then run three physical sanity checks (geometry, DC bias, stage interface), repairing where possible, and fold their verdicts into one feasibility flag.

This is the busiest phase — five helper calls, one after another. But it's still just an assembly sub-line. The crate (sizing, a ref → W/L dict) enters, and each step either fills it in or repairs it:

sizing, geom_warnings, geom_feasible = assign_geometry_gmid(
    plan.model, view.all_transistors, view.slot_transistors,
    currents.ids_map, plan.tintents, plan.gm_req_map, tech,
    vod_max_map=plan.vod_max_map)
sizing, dc_warnings, bias_feasible = check_dc_operating_point(...)
sizing, si_warnings, si_feasible   = check_stage_interface(...)
bias_feasible = bias_feasible and si_feasible and geom_feasible
extra_r, modifiers = size_resistors(...)
sizing, level_r    = tune_bias_levels(...)
extra_r = {**extra_r, **level_r}
geometry LUT → W/L geometry.py DC bias check + repair bias.py stage interface window check stage_interface.py resistors non-load net resistors.py bias levels tune constructed bias_levels.py
Phase 4 is itself a little five-station line inside the big one: pick widths, then check bias, check interfaces, add resistors, tune levels.

The single most important line in Phase 4

It's this one:

bias_feasible = bias_feasible and si_feasible and geom_feasible

Three independent checks each return a boolean verdict. The conductor ANDs them into one feasibility flag. It's an all-must-pass gate: a design is only trustworthy if the geometry fit the LUT and the DC operating point held and the inter-stage windows lined up. One failure poisons the verdict — exactly as it should.

geom_feasible = ✓ bias_feasible = ✓ si_feasible = ✗ AND = False
All-must-pass: any single ✗ makes the whole design infeasible. Here the stage-interface check fails, so the honest verdict is False.

Two of these steps don't just judge — they repair. check_dc_operating_point can turn up gm/Id to un-squash a tail; check_stage_interface can nudge a stage-interface window. The conductor threads the (possibly repaired) sizing through each call, so later steps always see the newest widths. The two resistor steps (size_resistors, tune_bias_levels) contribute extra resistors, merged with {**extra_r, **level_r}.

For our op-amp, Phase 4 reads the LUT to give M1 a width that hits gm1 ≈ 126 µA/V at 10 µA, all three checks pass, and bias_feasible = True.

Part 6 · Phase 5, the hidden idea & the honest limits

The one job of Phase 5: grade the finished design analytically — gain, bandwidth, phase margin — without ever launching ngspice, then package everything into one SizingResult.

# Phase 5 — Evaluate: analytical (ngspice-free) metrics from the sizing.
metrics, margins, eval_notes = evaluate_circuit(
    view, currents, plan, sizing, modifiers, spec, tech)

return SizingResult(
    transistors=sizing, cc_pf=plan.cc_pf, metrics=metrics, margins=margins,
    solver_status="GMID", cc2_pf=plan.cc2_pf,
    warnings=(view.warnings + plan.warnings + geom_warnings + dc_warnings
              + si_warnings + eval_notes),
    resistors={**currents.load_resistors, **extra_r},
    bias_feasible=bias_feasible,
    transistor_intents=plan.tintents)

Notice the return is a gathering step. It reaches back into every crate that came down the line — plan.cc_pf, currents.load_resistors, plan.tintents — and the running bias_feasible flag. Because each hand-off was kept as a named object, the final assembly is just picking the right field off each one:

CurrentPlan SizingPlan sizing dict metrics + flag SizingResult transistors, resistors, cc_pf metrics, margins warnings (all six sources merged) bias_feasible, transistor_intents
The final crate is assembled by field-picking from every earlier crate — the payoff of never flattening the hand-offs into loose variables.

Look at the warnings line: it concatenates advisories from all six stages — view, plan, geometry, DC, stage-interface, and eval. Nothing is swallowed. A reader of the result can see exactly which station raised each concern.

The complete walk, all five phases, one op-amp

Here is our Miller op-amp riding the whole line, with numbers at every seam:

1 · Analyze M1,M2 = input pair · M5 = tail · M3,M4 = mirror → CircuitView (no cascodes) 2 · Bias I tail 20 µA → 10 µA/leg; M6,M7 = 40 µA → CurrentPlan.ids_map 3 · Plan gm1 = 2π·10MHz·2pF ≈ 126 µA/V; Cc = 2 pF → SizingPlan.gm_req_map 4 · Size LUT → W(M1) to hit 126 µA/V @ 10 µA; checks pass → sizing, bias_feasible = True 5 · Evaluate gain ≈ 62 dB, GBW ≈ 10 MHz, PM ≈ 60° → SizingResult ✓
The same circuit, one phase per row — each seam carries a concrete, named artifact from the phase above to the phase below.

The hidden idea: typed seams make the phases independently testable

Here's what the code never writes down but is really about. Because each phase's output is a named, self-contained type, you can test any phase in isolation — hand-build a fake CircuitView, call assign_currents, and assert on the CurrentPlan, with no netlist, no LUT, no evaluation in sight:

Phase: Tin Tout

Every phase is a pure function from one type to the next: CircuitView → CurrentPlan → SizingPlan → sizing → SizingResult. That is why the orchestrator can stay a flat 97 lines with no branching cleverness — all the intelligence lives behind the typed seams, and the conductor just carries crates. Flatten those hand-offs into a bag of loose dictionaries and you lose the whole property: nothing would be testable alone, and a bug could no longer be pinned to a single seam.

What it honestly does not do

  • It is the gm/Id path only — a totally separate line from the Level-1 CP-SAT sizer, chosen upstream.
  • It requires tech.gmid_lut; without the lookup table there is no device physics to read and the path can't run.
  • Phase 5 is analytical, not SPICE — the metrics are first-order estimates. A bias_feasible = True is a green light for a real simulator, not a substitute for one.
  • It orchestrates but does not compute — every hard decision lives in a specialist module; a wrong number here is almost always a bug in analyze.py, plan.py, geometry.py, bias.py or evaluate.py, not in the conductor.

Why it's elegant

The whole 97-line file rides one design decision: make every hand-off an explicit, named type. That single choice turns a five-way tangle into five independent, testable stages a reader can follow top-to-bottom in one sitting — and lets a bug be traced to one seam instead of hunted through a monolith.


In one sentence: gmid_sizer.py is the conductor of the gm/Id sizing orchestra — it runs Analyze → Bias currents → Plan → Size → Evaluate in order, carrying an explicitly-typed crate (CircuitView, CurrentPlan, SizingPlan, sizing) across each seam, and returns one self-graded SizingResult whose bias_feasible flag is the honest AND of every check along the way.