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.
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:
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.
Here's the trick that generates the entire bottom shelf: every device primitive is this one equation, solved for a different letter.
| Primitive | Is really the square law, solved for… |
|---|---|
vgs_from_ids | VGS (invert the parabola: given I, what gate?) |
vds_sat | the overdrive VGS−Vth (same square root, no Vth) |
gm | the 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:
gmanswers "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:
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
gmthat 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.
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:
gdmeasures how much a transistor "leaks" (its current sags with output voltage instead of staying constant), androutcombines 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")
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.
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)
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.
| Quantity | Typical value |
|---|---|
Vth | 0.35 – 0.50 V |
overdrive = Vds_sat | 0.10 – 0.30 V |
VGS | 0.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 = 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)))
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.
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.
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.
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_ceilingis a blunt patch (a flat25 /Vcap) 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.