metrics.py — explained

circuitgenome/sizer/shared/metrics.py — turn a solved geometry into predicted op-amp metrics (gain, GBW, PM, CMRR…) through one DeviceModel, so the same code is exact for both sizers.

Part 1 · Why this file even exists

The one job: the sizer has already chosen every transistor's width, length and current. Given that solved geometry, what performance does the amplifier actually deliver — its gain, bandwidth, phase margin, slew rate, power, swing, CMRR and PSRR — and does each number clear its spec?

Here is the whole story without a line of code.

Sizing an op-amp is like assembling a team: you pick the players (transistors), give each a role and a budget (current). But picking the team is not the same as knowing how it will perform on game day. Someone still has to compute the report card.

That is all metrics.py is — a report card generator. It reads the finished sizing and computes the numbers a circuit designer actually cares about, then compares each against the target to produce a margin (how much headroom you have, positive means "meets spec").

solved sizing W, L, Ids per device Cc, spec, tech _evaluate_ metrics() the report card metrics gain, GBW, PM… margins metric − spec
Two dictionaries come out: the raw metrics, and the margins that say by how much each metric beats (or misses) its spec.

But here is the twist that makes this file interesting. There are two different sizers in CircuitGenome — a simple Level-1 one (geometry-free λ·Id physics) and a gm/Id one (lookup tables from real device curves). A naïve design would need two report-card generators, one per physics model. This file needs only one.

The trick: every small-signal number it needs — gm, gds — is fetched through a single object, the DeviceModel, evaluated at the solved geometry. Swap the model, and the exact same arithmetic below becomes exact for the other physics. One codebase, two sizers, no duplication. That is the hidden idea, and Part 2 is entirely about it.

The cast of characters

Only one public function — _evaluate_metrics() — plus three tiny local helpers and two neighbours it leans on. Hover any box to trace who talks to whom.

_evaluate_metrics() _sz() look up a sizing _gm() gm, capped _gds() output conductance k_fs factor _first_stage_gain_factor DeviceModel params @ solved geometry eq.* (equations.py) textbook formulas → metrics
The orchestrator on top; three private helpers below it; and the two neighbours everything routes through — the DeviceModel (the swappable-physics seam) and equations.py (the formulas). The dashed edges show that _gm/_gds are just thin wrappers over the model.

We will learn it in reading order, not file order: first the seam (Part 2), then the three helpers (Part 3), then how a stage's resistance is built (Part 4), and only then the metrics themselves — gain, then dynamics, then the rest.

Looking ahead: Part 2 answers the question that makes this whole file possible — how can one function be exact for two completely different physics models?

Part 2 · The DeviceModel seam (the idea behind the file)

The one job of this Part: understand why the metrics code never mentions λ or a lookup table directly — it only ever asks a DeviceModel for gm and gds at the geometry already chosen.

Think of a universal power adapter. Your laptop does not care whether the wall socket behind the adapter is European or American — it just asks the adapter for "clean DC," and the adapter hides the difference. The DeviceModel is that adapter. The metrics code plugs into it and asks for two things:

  • model.gm(type, W, L, Ids) — the small-signal transconductance: how strongly this device turns a gate voltage into a drain current.
  • model.gds(type, W, L, Ids) — the small-signal output conductance: how "leaky" the device is, i.e. how far it falls short of an ideal current source.

Behind that adapter sit two completely different physics engines — but the metrics code cannot tell them apart:

_evaluate_metrics() asks only: gm, gds DeviceModel (the adapter) evaluated at the SOLVED geometry Level-1 backend gm=√(2·µCox·W/L·Id), gds=λ·Id gm/Id backend lookup table from real curves
Same top box, two possible bottom boxes. Because the numbers are read at the geometry the sizer already solved, both backends are exact — not an approximation of each other.

Why is this "exact" and not just "roughly consistent"? Because the metrics are evaluated after the geometry is frozen. The Level-1 backend evaluates its λ·Id physics at that (W, L, Id); the gm/Id backend looks up its table at that same (W, L, Id). Neither is guessing — each reports its own truth at the solved point. The arithmetic downstream (multiply gm by Rout, take a log) is physics-agnostic.

Look at how the module docstring says exactly this:

# from the module docstring
"""Performance-metric evaluation from a solved sizing.

Small-signal parameters come through a DeviceModel evaluated at the
*solved* geometry, so the same code is exact for both Level-1
(geometry-free λ·Id) and gm/Id (LUT).  Shared by both sizers.
"""

The takeaway: the file's power is a design decision, not an algorithm — route all physics through one gm/gds interface, and one report card serves both sizers exactly.

Part 3 · _sz, _gm, _gds — the three tiny helpers

The one job: three one-liners that turn "a device plus its sizing" into the two small-signal numbers everything else is built from — with one safety clamp on gm.

def _sz(ref: str) -> TransistorSizing | None:
    return transistor_sizing.get(ref)

def _gm(d: Device, s: TransistorSizing) -> float:
    return min(model.gm(d.type, s.w_um, s.l_um, s.ids_a),
               model.gm_ceiling(d.type, s.ids_a, s.l_um))

def _gds(d: Device, s: TransistorSizing) -> float:
    return model.gds(d.type, s.w_um, s.l_um, s.ids_a)

_sz is a dictionary lookup: given a device reference like "M1", hand back its solved sizing (or None if it was never sized). _gds is a pure pass-through to the model. The interesting one is _gm, and it is interesting because of that min(…).

Why the min? The weak-inversion ceiling

The Level-1 square-law formula gm = √(2·µCox·(W/L)·Id) has no upper limit — make the device wide enough and it promises unbounded transconductance. But real physics says gm can never exceed Id / (n·φt), roughly gm/Id ≤ 25 /V at room temperature (the weak-inversion limit). Left unchecked, the square-law formula would let the sizer promise a gm the device can only reach by sliding into weak inversion — a gm the amplifier will never actually deliver.

So _gm takes the smaller of the two: the formula's value, or the physical ceiling gm_ceiling = 25 · |Id|.

min( square-law gm , ceiling 25·Id ) narrow device formula = 0.6 mA/V ceiling = 0.5 mA/V picks ceiling: 0.5 ✗ formula honest device formula = 0.4 mA/V ceiling = 0.5 mA/V picks formula: 0.4 ✓
When the square-law formula over-promises (left), the clamp pulls it down to the physical ceiling; when it is honest (right), the formula wins. Either way, gm stays believable.

A number, traced

Say a device carries Id = 20 µA, and the square-law formula returns 0.6 mA/V:

ceiling = 25 × 20e-6 = 500e-6 = 0.5 mA/V
gm      = min(0.6 mA/V, 0.5 mA/V) = 0.5 mA/V   # clamped

Note the ceiling depends on Id (and length), not width — you cannot buy more gm per amp of current than physics allows, no matter how wide you go. With these three helpers in hand, every metric below is just arithmetic on gm and gds.

Part 4 · Building each stage's Rout

The one job: for each gain stage, gather its signal transistor's gm and the stage's total output resistance Rout — the two numbers a stage's voltage gain is made of.

A gain stage is a signal transistor pushing current into a resistance. Its gain is gm · Rout. We already have gm. Where does Rout come from? From the transistors fighting to hold the output node — every device connected there leaks a little (its gds), and leaks in parallel add as conductances:

Rout = 1 / (gds,top + gds,bot)

Analogy: two leaks draining the same bucket empty it faster together than either alone. The "resistance to going empty" is one over the total leak rate. That is literally eq.rout:

# --- Load / input pair → Rout1 ---
gd_ip = _gds(ip_devs[0], s_ip)          # input-pair leak
# gd_ld = _gds(load device)         # load leak
if rout1_override is not None:
    rout1 = rout1_override            # gm/Id pipeline: cascode-aware value
else:
    rout1 = (eq.rout(gd_ip, gd_ld + gd_load_r)
             if (gd_ip + gd_ld + gd_load_r) > 0 else float("inf"))
Vdd load gds_top = 6 µS out drive gds_bot = 4 µS Rout = 1/(6+4) µS = 100 kΩ
Both devices leak into the output node; their conductances add, and Rout is one over the sum. Here 6 µS + 4 µS → 100 kΩ.

The _override escape hatch — where the two sizers differ

Notice rout1_override. This is the one place the two physics models genuinely need different treatment. The Level-1 default just sums single-device gds values. But a gm/Id design may use a cascode — a stack of devices that multiply the output resistance far beyond one device's gds. So the gm/Id pipeline computes a cascode-aware Rout itself and passes it in through the override; when it is None (the Level-1 case), the simple estimate is used unchanged. Same escape hatch exists for rout2 and rout3.

The same story, three times

The file repeats this pattern for each stage that exists:

StageSignal transistor givesRout fromWhen absent
1 · input pairgm1input-pair + load gdsalways present
2 · second stagegm2nmos + pmos gdsrout2 = ∞
3 · third stagegm3nmos + pmos gdsrout3 = ∞

Two things to note. First, "which device is the signal transistor?" is decided by is_signal_device(d) — the other device in the stage is the current-source load, whose gds we also grab (for PSRR later). Second, when a stage does not exist, its Rout is set to float("inf") — a clean sentinel that makes the gain code below simply skip that stage.

Why inf is elegant: an absent stage has no output node to load, so its "resistance" is infinite. Using inf lets the gain logic test rout < inf to mean "this stage is real" — the physics and the code agree.

Part 5 · Gain — the product of the stages

The one job: multiply each stage's gm · Rout together to get the total open-loop DC gain, then convert to decibels.

This is the headline metric. Cascade three amplifiers and their gains multiply — like gears, where the final speed-up is the product of every gear ratio. The DC gain is:

A0= iN gm,i · Rout,i
k_fs = _first_stage_gain_factor(slot_transistors)   # 0.5 or 1.0
if is_three_stage and rout2 < float("inf") and rout3 < float("inf"):
    stage_gains = [k_fs * gm1 * rout1, gm2 * rout2, gm3 * rout3]
elif has_second_stage and rout2 < float("inf"):
    stage_gains = [k_fs * gm1 * rout1, gm2 * rout2]
else:
    stage_gains = [k_fs * gm1 * rout1]
if all(g > 0 for g in stage_gains):
    gain_db = eq.open_loop_gain_db(stage_gains)   # 20·log10(∏)

See how the rout < inf sentinel from Part 4 does the work: the code picks the two-, or one-stage form automatically. And k_fs — the first-stage gain factor — is 0.5 for a single-ended non-mirror first stage (which throws away half the signal) and 1.0 for a mirror or fully-differential front end that keeps all of it.

stage 1 gm1·Rout1 = 100 × stage 2 gm2·Rout2 = 100 A0 = 100 × 100 = 10 000 = 80 dB
Two 40-dB stages don't add to 40 — they multiply to 10 000, which is 80 dB. Decibels turn the product into a sum you can read at a glance.

Traced by hand

Using our running two-stage design — gm1 = 1 mA/V, Rout1 = 100 kΩ, gm2 = 4 mA/V, Rout2 = 25 kΩ, mirror front end so k_fs = 1:

stage 1: 1.0e-3 × 100e3 = 100
stage 2: 4.0e-3 × 25e3  = 100
A0      = 100 × 100 = 10 000
gain_db = 20 · log10(10 000) = 80.0 dB

Then, if the spec set a floor, the margin is gain_db − spec.gain_min_db. If the floor was 70 dB, the margin is +10 dB — a positive number, meaning the design comfortably clears the bar.

One honest edge case: the whole block is guarded by all(g > 0). If any stage gain is zero or negative (a device that never got sized, an inf resistance producing a degenerate product), the gain is simply not reported rather than reporting a garbage number. Silence beats a lie.

Part 6 · GBW, phase margin, slew rate — the dynamics

The one job: from the input-pair gm and the compensation capacitor Cc, predict how fast the amplifier is (GBW), how stable it is (phase margin), and how fast it can swing (slew rate).

Gain tells you how strong the amplifier is; this Part tells you how fast and stable it is. All three numbers hinge on one component: the Miller compensation capacitor Cc, which deliberately slows the amplifier down to keep it from oscillating.

cc_f = (cc_pf * 1e-12) if cc_pf else None
if has_second_stage and cc_f and gm1 > 0:
    gm1_loop = k_fs * gm1                     # gm into the Miller loop
    gbw = eq.unity_gain_bw(gm1_loop, cc_f)     # gm1 / (2π·Cc)

GBW — the gain-bandwidth product

The gain rolls off at −20 dB/decade from a low-frequency pole, and hits unity (0 dB) at:

GBW = gm1 / (2π · Cc)
dB log f 0 80 dominant pole GBW ≈ 159 MHz −20 dB / decade
The magnitude falls from 80 dB at −20 dB/decade and crosses unity gain at the GBW — the single frequency the amplifier can no longer amplify.

Traced: gm1_loop = 1 × 1 mA/V = 1e-3, Cc = 1 pF = 1e-12 F:

GBW = 1e-3 / (2π × 1e-12) = 1e-3 / 6.283e-12 ≈ 1.59e8 Hz = 159 MHz

Phase margin — how much stability is left

Every pole adds phase lag. If the total lag reaches 180° while the gain is still above 1, the negative feedback becomes positive and the amplifier oscillates. Phase margin is the leftover angle to that 180° cliff at the unity-gain frequency:

PM2-stage ≈ 90° − arctan(gm1·CL / (gm2·Cc))
phase lag at GBW, out of 180° dominant pole 90° 26.6° PM = 63.4° 180° = unstable 2nd pole
The dominant pole eats 90°, the second pole adds 26.6° more; what remains before the 180° cliff is the 63.4° phase margin — healthy (designers want ≳ 60°).

Traced with gm1 = 1e-3, CL = 2 pF, gm2 = 4e-3, Cc = 1 pF:

ratio = (1e-3 × 2e-12) / (4e-3 × 1e-12) = 2e-15 / 4e-15 = 0.5
PM    = 90° − arctan(0.5) = 90° − 26.57° = 63.4°

The code chooses between a two- and three-stage phase-margin formula (three-stage adds a second arctan lag term for the extra pole), and only computes it when the relevant gm values are positive — otherwise pm = None and it is not reported.

Slew rate — the large-signal speed limit

GBW is the small-signal speed. When a big step arrives, the tail current Ibias can only charge Cc so fast:

SR = Ibias / Cc
SR = 20e-6 / 1e-12 = 2e7 V/s = 20 V/µs

Same Cc that stabilizes the loop also caps the slew rate — one component, three metrics, all traced back to it.

Part 7 · Power, output swing, CMRR, PSRR

The one job: finish the report card — how much power it burns, how far the output can swing, and how well it rejects common-mode and supply noise.

Power — add up every leg's current

Quiescent power is simply the supply voltage times the total current drawn. The code assembles the supply-current list one leg at a time — the tail, the second (and third) stage scaled by how many slots it fills, plus a bias-generator approximation:

supply_currents = [spec.ibias]                        # tail
if has_second_stage: supply_currents.append(ids_2 * n_ss)
if is_three_stage:   supply_currents.append(ids_3 * n_ts)
supply_currents.append(spec.ibias * max(n_bias_legs, 1))  # bias gen approx
power = eq.quiescent_power(spec.vdd, spec.vss, supply_currents)
tail20 µA + 2nd stage80 µA + bias gen20 µA 1 V × 120 µA = 120 µW
P = (Vdd − Vss)·ΣI. Three legs of 20 + 80 + 20 µA at a 1 V supply burn 120 µW. Its margin is power_max − power — reversed sign, because here less is better.

Output swing — from the saturation voltage

The output cannot reach the rail; it stops a saturation voltage short. So the max output is Vdd − Vds_sat of the top (PMOS) device, and the min is Vss + Vds_sat of the bottom (NMOS). With Vds_sat = 0.1 V and Vdd = 1.0 V, the top swing is 0.9 V.

CMRR — rejecting common-mode noise

A differential pair is only as good at ignoring common-mode input as its tail current source is stiff. A leaky tail (large gd_tail) lets common-mode signal through:

CMRR ≈ 20·log10( gm1 / (2·gd,tail) )
CMRR = 20·log10( 1e-3 / (2 × 0.2e-6) ) = 20·log10(2500) ≈ 68 dB

This is the reason Part 4 bothered to grab the tail device's gds even though it is not a gain stage — it is the denominator here. And the guard gd_tail > 0 means a resistor-degenerated or ideal tail (zero conductance) simply skips CMRR rather than dividing by zero.

PSRR — rejecting supply noise (approximate)

The last metric reuses the second-stage load conductance ss_load_gd gathered back in Part 4:

PSRR+ ≈ 20·log10( gm2 / gd,load )
PSRR = 20·log10( 4e-3 / 4e-6 ) = 20·log10(1000) = 60 dB

The name of the function is honest about its status — psrr_db_approx. It is a first-order estimate; accurate PSRR needs a simulator, which brings us to the limits.

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

Step back from the eight metrics and one structure remains. Every single line above is one expression in one variable's language: it reads a small-signal parameter through the DeviceModel at the solved geometry, then feeds it to a textbook formula. Written as one idea:

metric= f( gm, gds)  where  gm, gds = Model(W,L,Id)

Because Model is the only physics-dependent term and it is evaluated at the frozen geometry, swapping Level-1 for gm/Id changes what the numbers are without changing a single line that computes them. That is the whole reason this file is called shared.

What it honestly does not do

  • Every formula here is analytical — a dominant-pole, first-order model. It is fast enough to guide a search over thousands of candidate sizings, but it is not the truth.
  • PSRR is explicitly labelled _approx; phase margin neglects the internal mirror pole; CMRR is first-order. The ground truth is an ngspice simulation run downstream on the winning candidate.
  • Metrics that cannot be computed (missing stage, zero gm, zero tail conductance) are omitted, never faked. A missing key is more honest than a wrong number.
  • The cascode-aware output resistances arrive through rout{1,2,3}_override — this file does not compute them; the gm/Id pipeline does and passes them in.

Why it's elegant

The cleverness is not in any one formula — they are all standard analog textbook results. It is in the seam: by insisting that every small-signal number flow through one gm/gds interface at the solved geometry, the author collapsed what could have been two parallel metric engines into one, and made "add a new physics backend" a change that touches this file zero times.


In one sentence: metrics.py turns a solved transistor sizing into the op-amp's predicted report card — gain, GBW, phase margin, slew, power, swing, CMRR and PSRR, each paired with a spec margin — by routing every small-signal number through a single DeviceModel evaluated at the frozen geometry, so the exact same analytical code is correct for both the Level-1 and the gm/Id sizer.