equations.py — explained

circuitgenome/sizer/shared/equations.py — the Level-1 square-law device primitives, and the op-amp performance formulas built on top of them.

Part 1 · Why this file even exists

The one job: be the pocket calculator of analog design — turn a transistor's current and size into its small-signal behavior (gm, gd, Vdsat), and turn those, in turn, into the numbers a spec sheet cares about (gain, bandwidth, phase margin, CMRR).

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

An op-amp sizer has to answer two very different kinds of question. First, the device question: "I've decided this transistor carries 100 µA and is 10× wide — so how strong is it, how leaky is it, how much voltage room does it eat?" Second, the circuit question: "given a handful of those devices wired into a two-stage amplifier, what gain and bandwidth do I actually get?"

Think of it as a two-shelf toolbox. The bottom shelf holds device primitives — tiny formulas from the 1968 Shichman–Hodges "square law," the simplest textbook model of a MOSFET. The top shelf holds op-amp metrics — the performance formulas every analog course writes on the board. The magic is that the top shelf only ever asks the bottom shelf for three things: gm, gd, and a capacitor. Nothing else.

That is the quiet secret this whole file rides on, and we'll circle back to it in Part 8: the op-amp formulas don't care where gm came from. Square-law here, a measured lookup table for the real PTM nodes — same formula, plug in a different gm. The performance math is topology, and topology is model-independent.

The cast of characters

Two families, one arrow between them. Hover a box to light up its whole family.

Level-1 square law IDS = (µCox/2)(W/L)(VGS−Vth)² Device primitives square-law formulas (this file) gm transconductance (strength) gm_ceiling weak-inversion cap on gm gd output conductance (leak) rout stage output resistance vds_sat room needed to stay saturated vgs_from_ids gate voltage to carry IDS gm, gd, rout, C → Op-amp metrics topology formulas (model-free) open_loop_gain_db — A0 unity_gain_bw — GBW phase_margin_*_deg — PM slew_rate_vps — SR cmrr_db — CMRR psrr_db_approx — PSRR quiescent_power — Pq
Bottom shelf feeds top shelf. Every metric on the right is built from the three primitives on the left — never anything more. We'll learn the left family first, then the right.

Notice there is no call graph in the usual sense — these are all leaf functions, pure math with no side effects. What matters is the data-flow: the primitives produce numbers, and the metrics consume them. So we'll teach it bottom-up, exactly as the arrow points.

Looking ahead: Part 2 introduces the single parent equation — the square law — that every device primitive is secretly a rearrangement of.

Part 2 · The one square law everything descends from

You need exactly one equation to understand the whole bottom shelf.

A MOSFET in saturation (the "on and behaving" region an amplifier lives in) turns a gate voltage into a current. The simplest model — Level-1, Shichman–Hodges — says the current grows as the square of how far the gate is pushed past the threshold:

IDS= µCox2 · WL · (VGSVth)2

Read the four knobs:

  • µCox — the process's intrinsic "muscle per volt²" (call it 200 µA/V² for our examples). Fixed by the fab.
  • W/L — the transistor's shape: how wide relative to long. Wider = more current. The one geometry knob the sizer owns.
  • VGS − Vth — the overdrive. How hard you push the gate past the point where the channel first forms. This one name will come back again and again.
  • IDS — the resulting drain current.
IDS VGS Vth = 0.4 V IDS = 100 µA VGS = 0.716 V overdrive = 0.316 V
Below Vth, no current. Above it, current rises as the square of the overdrive. Everything in this file is either walking up this curve or reading a slope off it.

Here's the trick that generates the entire bottom shelf: every device primitive is this one equation, solved for a different letter.

PrimitiveIs really the square law, solved for…
vgs_from_idsVGS (invert the parabola: given I, what gate?)
vds_satthe overdrive VGS−Vth (same square root, no Vth)
gmthe slope dIDS/dVGS at the operating point

So there is really only one idea in Parts 3–5, seen from three angles. Let's start with the slope.

Part 3 · gm & gm_ceiling — how strong is the transistor?

The one job: gm answers "if I wiggle the gate by one volt, how many amps does the drain current wiggle?" — the transistor's amplifying strength, in amps per volt (siemens).

Analogy: gm is the gain of a faucet. Turn the handle a little (gate voltage); how much extra water flows (current)? A strong faucet moves a lot of water for a small twist — that's high gm. It is literally the slope of the square-law curve at wherever you're sitting.

def gm(mu_cox, w_um, l_um, ids_a) -> float:
    return math.sqrt(2.0 * mu_cox * (w_um / l_um) * abs(ids_a))

Take the derivative of the square law with respect to VGS and the square disappears, leaving gm = µCox·(W/L)·overdrive. Then substitute the overdrive back in terms of current, and you land on the tidy form the code uses:

gm= 2·µCox· WL· |IDS|
IDS VGS ΔVGS ΔIDS gm = slope = ΔIDS / ΔVGS
gm is the steepness of the tangent at your operating point. Push further up the curve (more current) and the tangent steepens — hence gm grows with √IDS.

Traced with real numbers

Our canonical device: µCox = 200 µA/V², shape W/L = 10, current IDS = 100 µA.

gm = √(2 · 200e-6 · 10 · 100e-6)
   = √(4e-3 · 100e-6)
   = √(4e-7)
   = 6.32e-4 S  =  632 µS

So wiggling the gate by 1 V would move 632 µA. Note it scales as √IDS — to double gm you must quadruple the current. That square-root wall is exactly why analog designers obsess over efficiency, and it's why the next function exists.

Why gm_ceiling? The square law lies at the top

The one job: stop the square-law formula from promising a gm that no real transistor can deliver.

_GM_OVER_ID_MAX = 25.0

def gm_ceiling(ids_a) -> float:
    return _GM_OVER_ID_MAX * abs(ids_a)

Real physics puts a hard roof on efficiency: gm/IDS ≤ 1/(n·φt). With n ≈ 1.5 and the thermal voltage φt ≈ 25.9 mV at room temperature, that ceiling is about 25.8 /V — matching the measured gm/Id ≈ 25 for the real PTM devices. The square-law formula knows nothing about this and, pushed hard, would happily quote an efficiency the device can only reach by sliding into weak inversion.

computed gm = 632 µS cap ceiling = 25 · IDS = 2500 µS 632 < 2500 → this device is honest ✓
gm_ceiling(100 µA) = 2.5 mS. Our 632 µS device is comfortably legal; a caller clamps any square-law gm that tries to exceed this line.

In one sentence: gm is the slope of the square-law curve (strength ∝ √current), and gm_ceiling is the honesty check that keeps that slope from exceeding what real weak-inversion physics allows.

Part 4 · gd & rout — how leaky, how much gain?

The one job: gd measures how much a transistor "leaks" (its current sags with output voltage instead of staying constant), and rout combines two such leaks into the stage's output resistance.

An ideal current source pushes the same current no matter the voltage across it. Real transistors aren't ideal — squeeze the drain voltage and the channel gets a hair shorter, letting slightly more current through. That unwanted sensitivity is output conductance gd. Think of it as a slow leak in a sealed tank: you wanted a perfect valve, you got one with a pinhole.

def gd(lam, ids_a) -> float:
    return lam * abs(ids_a)

The leak is proportional to current, scaled by λ (lambda), the channel-length-modulation coefficient. A short transistor has big λ (leaky); a long one has small λ (tight). With λ = 0.1 /V and IDS = 100 µA:

gd = 0.1 · 100e-6 = 1.0e-5 S = 10 µS   →   1/gd = 100 kΩ per device

rout — two leaks share one node

In an amplifier stage, the output node is squeezed between a top load transistor and a bottom drive transistor. Both leak toward the node. Conductances in parallel simply add, and resistance is one over that:

def rout(gd_top, gd_bot) -> float:
    total = gd_top + gd_bot
    return 1.0 / total if total > 0.0 else float("inf")
Vdd top load gd_top 10µS Vout bot drive gd_bot 10µS Vss Vdd 1/gd_top Vout 1/gd_bot Vss
Two leaks pulling on one node = two resistors in parallel. Add the conductances, invert once. The stiffer the node (bigger rout), the more voltage gain the stage delivers.

With both leaks at 10 µS:

rout = 1 / (10µS + 10µS) = 1 / 20µS = 50 kΩ

Now the payoff. A stage's voltage gain is exactly gm · rout — strength times stiffness:

Av = gm · rout = 632µS · 50kΩ = 31.6   (≈ 30 dB)

The two guards matter: if a caller passes zero total conductance (a perfect, lossless node), the function returns inf rather than dividing by zero — the honest answer for "infinite gain, if only the world were ideal."

In one sentence: gd is the pinhole leak (∝ λ·I), rout parallels the top and bottom leaks into the node stiffness, and gm·rout is where a stage's gain is born.

Part 5 · vds_sat & vgs_from_ids — the voltage room

The one job: answer the two voltage questions — "how much drain room does this device need to keep working?" (vds_sat) and "how high must I hold the gate to push this current?" (vgs_from_ids).

Both are the square law solved for a voltage instead of a current. Start with the overdrive. Rearranging IDS = (µCox/2)(W/L)·overdrive² for the overdrive gives a square root:

def vds_sat(mu_cox, w_um, l_um, ids_a) -> float:
    return math.sqrt(2.0 * abs(ids_a) * l_um / (mu_cox * w_um))

Why is this called vds_sat — the drain saturation voltage — when we derived it as a gate overdrive? Because of a beautiful Level-1 identity: the minimum drain-to-source voltage a transistor needs to stay in saturation is exactly its overdrive. Same number, two roles. I really like that this one square root does double duty.

Vds,sat = VGSVth =overdrive

Traced with real numbers

Same device (µCox = 200 µA/V², W/L = 10, IDS = 100 µA):

vds_sat = √(2 · 100e-6 · L / (µCox · W))
        = √(2e-4 / 2e-3)      (since µCox·W/L = 2e-3)
        = √(0.1)
        = 0.316 V  =  316 mV

vgs_from_ids — stack the overdrive on top of the threshold

To actually turn the device on you need the threshold Vth first, then the overdrive on top. So the gate voltage is just the two stacked:

def vgs_from_ids(mu_cox, w_um, l_um, ids_a, vth) -> float:
    overdrive = math.sqrt(2.0 * abs(ids_a) * l_um / (mu_cox * w_um))
    return math.copysign(abs(vth) + overdrive, vth)
source = 0 V Vth = 0.40 V overdrive = 0.316 V VGS = 0.716 V = vds_sat
Threshold first, overdrive stacked on top. The gate sits Vth + overdrive above the source; the overdrive slice is simultaneously the drain room the device needs.
VGS = |Vth| + overdrive = 0.40 + 0.316 = 0.716 V   (this is the point marked in Part 2)

One subtlety worth naming: math.copysign(..., vth). The magnitude is always |Vth| + overdrive, but the sign is copied from Vth — positive for NMOS, negative for PMOS. It's a two-character trick that makes the same function correct for both device flavors. Elegant.

QuantityTypical value
Vth0.35 – 0.50 V
overdrive = Vds_sat0.10 – 0.30 V
VGS0.55 – 0.80 V

In one sentence: vds_sat is the overdrive square root (the room the device needs), and vgs_from_ids stacks that overdrive on the threshold (the gate voltage the device wants), with a sign flip that quietly handles PMOS.

Part 6 · Gain, GBW, and slew rate — the top shelf begins

We've finished the bottom shelf. Every function from here on is a topology formula that consumes primitives and spits out a spec number. They're mostly one-liners — the intuition is the whole game.

open_loop_gain_db — multiply the stages, then speak in decibels

The one job: multiply each stage's voltage gain into one total DC gain, then convert to dB.

def open_loop_gain_db(stage_gains) -> float:
    product = math.prod(stage_gains)
    return 20.0 * math.log10(abs(product)) if product != 0.0 else -math.inf

Gains cascade by multiplication — a ×32 stage feeding a ×20 stage gives ×632 overall (like gear ratios chaining). Each stage gain is a gm·rout from Part 4. Decibels turn that runaway product into a friendly additive scale:

product = 31.6 · 20 = 632
A0(dB)  = 20 · log10(632) = 20 · 2.80 = 56 dB

The product != 0 guard returns −∞ dB for a dead stage instead of crashing on log10(0) — the honest "no gain at all."

unity_gain_bw — how fast before the gain runs out

The one job: find the frequency where the amplifier's gain has fallen all the way to 1× (0 dB) — its speed limit.

def unity_gain_bw(gm1_a_v, cc_f) -> float:
    return gm1_a_v / (2.0 * math.pi * cc_f)

Analogy: the input pair pushes current gm1 into the compensation capacitor Cc; a bigger cap is a heavier flywheel that responds more slowly. Speed = push ÷ inertia.

GBW= gm1 2πCc
gain dB freq (log) 0 56 dominant pole GBW = 25 MHz −20 dB / decade
Flat at 56 dB, then a steady −20 dB/decade slide. Where the curve crosses 0 dB is the gain-bandwidth product — the amplifier's honest speed limit.
GBW = 632e-6 / (2π · 4e-12) = 632e-6 / 2.513e-11 = 2.51e7 Hz ≈ 25 MHz

slew_rate_vps — the large-signal speed limit

The one job: how fast can the output ramp when the input slams hard and the tail current is all you've got to charge Cc?

def slew_rate_vps(ibias_a, cc_f) -> float:
    return ibias_a / cc_f

This is the plainest capacitor law there is: dV/dt = I/C. When the input step is big, the input pair fully steers the tail current IBias into Cc, and the output can climb no faster than that fixed current allows — a bucket brigade moving at a fixed pour rate.

SR = 200e-6 / 4e-12 = 5e7 V/s = 50 V/µs

Notice the family resemblance: GBW and SR share the same Cc. Small-signal speed divides gm1 by it; large-signal speed divides the tail current by it. One capacitor sets both limits.

Part 7 · Phase margin — will it ring or settle?

The one job: measure how much "safety angle" is left before feedback turns the amplifier into an oscillator.

Feedback is a promise to correct errors. But every stage adds delay, and delay is phase lag. If the signal comes back a full 180° out of step while the loop still has gain, the correction becomes reinforcement and the circuit rings or oscillates. Phase margin is how far from that 180° cliff you are at the unity-gain frequency. Analogy: pushing a swing — push in time and it grows smoothly; push a half-cycle late and you fight yourself.

phase_margin_two_stage_deg

def phase_margin_two_stage_deg(gm1, gm2, cc_f, cl_f) -> float:
    return 90.0 - math.degrees(math.atan(gm1 * cl_f / (gm2 * cc_f)))
PM90° arctan ( gm1CL gm2Cc )

Read it as "start with an ideal 90°, then subtract the lag from the second, non-dominant pole." That second pole sits at gm2/CL; the closer it creeps to the unity-gain frequency, the bigger the arctan bite. Push the second stage harder (bigger gm2) or lean on more Cc, and the lag shrinks.

freq (log) −90° −180° at GBW PM ≈ 68°
At the unity-gain frequency the phase has fallen to about −112°. The green gap up to the −180° cliff is the phase margin — here a healthy 68°.
ratio = gm1·CL / (gm2·Cc) = (632e-6 · 5e-12) / (2e-3 · 4e-12) = 3.16e-15 / 8e-15 = 0.395
PM    = 90 − atan(0.395)°  = 90 − 21.5° = 68.5°     (healthy: 60° is the usual target)

phase_margin_three_stage_deg — two lags to subtract

A three-stage amplifier (nested-Miller compensated) has two non-dominant poles, so the same idea just subtracts two arctan lags instead of one, both referenced to the unity-gain frequency ωt = gm1/Cc1:

def phase_margin_three_stage_deg(gm1, gm2, gm3, cc1_f, cc2_f, cl_f) -> float:
    wt = gm1 / cc1_f
    lag2 = math.degrees(math.atan(wt * cc2_f / gm2))
    lag3 = math.degrees(math.atan(wt * cl_f / gm3))
    return 90.0 - lag2 - lag3
wt   = 632e-6 / 4e-12 = 1.58e8 rad/s     (Cc2 = Cc1/4 = 1 pF)
lag2 = atan(1.58e8 · 1e-12 / 2e-3)° = atan(0.079)° =  4.5°
lag3 = atan(1.58e8 · 5e-12 / 3e-3)° = atan(0.263)° = 14.7°
PM   = 90 − 4.5 − 14.7 = 70.8°

In one sentence: phase margin starts at the ideal 90° and subtracts one arctan lag per non-dominant pole — bigger gm in the later stages buys the lag back and keeps the amplifier settling instead of ringing.

Part 8 · CMRR, PSRR, power — and the hidden idea

Three quick metrics finish the top shelf, then we surface the big secret.

cmrr_db — how well it ignores common junk

The one job: how much better does the amplifier respond to the difference of its inputs than to a signal common to both?

def cmrr_db(gm1, gd_tail) -> float:
    if gd_tail == 0.0:
        return math.inf
    return 20.0 * math.log10(gm1 / (2.0 * gd_tail))

The differential pair rejects common-mode noise only as well as its tail current source is stiff. A perfect tail (gd_tail = 0) rejects it infinitely — hence the inf guard. Real tails leak, and the ratio gm1/(2·gd_tail) measures the stiffness. With a cascoded tail (gd_tail = 2 µS):

CMRR = 20 · log10(632e-6 / (2 · 2e-6)) = 20 · log10(158) = 44 dB

psrr_db_approx — how well it ignores a noisy supply

def psrr_db_approx(gm2, gd_bias) -> float:
    if gd_bias == 0.0:
        return math.inf
    return 20.0 * math.log10(gm2 / gd_bias)

Same shape, different players: the second-stage strength gm2 over the bias transistor's leak gd_bias. The docstring is refreshingly honest — it flags itself as a rough first-order estimate and says accurate PSRR needs simulation. gm2 = 2 mS, gd_bias = 20 µS: 20·log10(100) = 40 dB.

quiescent_power — the idle electricity bill

def quiescent_power(vdd, vss, supply_currents_a) -> float:
    return (vdd - vss) * sum(abs(i) for i in supply_currents_a)

The plainest formula in the file — P = V · I — summed over every branch drawing from the supply. Rails at 1.0 V and 0 V, branches drawing 100 + 100 + 200 µA:

P = (1.0 − 0.0) · (100 + 100 + 200)µA = 1.0 · 400µA = 400 µW

The hidden idea: the top shelf never asks where gm came from

Look back at all seven metrics. Every one is built from gm, gd, and a capacitor — and not one of them mentions µCox, W/L, or Vth. The device physics vanishes the moment you cross from the bottom shelf to the top. That is not an accident; it is the whole architecture.

metric= F( gm, gd, C) — independent of how gm was found

This is why the square-law primitives and the measured gm/Id lookup table can live behind one shared DeviceModel interface. For the card-less generic tech you compute gm from the square law in this file; for a real PTM node you read gm off a characterized table. The performance formulas above never notice the difference.

Square-law (this file) gm = √(2µCox·W/L·I) gm/Id LUT (PTM) gm read from a table DeviceModel .gm() .gd() Topology metrics A0, GBW, PM, CMRR — unchanged
Two ways to produce gm, one shared interface, the same topology formulas downstream. Only the left provider differs — the metric math never changes.

What this file honestly does not do

  • The primitives are Level-1 square law — a teaching model. Real short-channel devices (BSIM) show velocity saturation, mobility degradation, and a soft weak-to-strong transition the square law can't capture. That's exactly why the PTM path uses a measured LUT instead.
  • gm_ceiling is a blunt patch (a flat 25 /V cap) bolted on because the square law over-promises efficiency near weak inversion.
  • The metric formulas are single-/dominant-pole approximations: internal mirror poles are neglected, PSRR is explicitly labeled rough, and phase margin assumes a clean pole ordering. They size the first cut; SPICE grounds the final verdict.
  • Every function is pure arithmetic with SI-unit assumptions baked into the names — pass millimetres where micrometres are expected and it will happily return nonsense.

In one sentence: equations.py is a two-shelf calculator — the bottom shelf turns a transistor's current and size into the Level-1 square-law primitives gm, gd, and Vdsat, and the top shelf turns those three (plus a capacitor) into every op-amp spec — gain, bandwidth, phase margin, slew rate, CMRR, PSRR, power — with the deliberate design that the performance math is model-independent, so square-law and measured gm/Id devices flow through the exact same formulas.