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:
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 aSizingResult.
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:
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 sourceM5, mirror loadM3, M4. - Second stage
M6with current sourceM7, Miller capCc. - Spec:
ibiassets the tail, targetGBW = 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 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:
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:
| ref | M1 | M2 | M3 | M4 | M5 | M6 | M7 |
|---|---|---|---|---|---|---|---|
ids_map (µA) | 10 | 10 | 10 | 10 | 20 | 40 | 40 |
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:
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 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}
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.
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:
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:
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:
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 = Trueis 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.pyorevaluate.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.