Part 1 · Why this file even exists
The one job: given a fully solved sizing (every transistor's current and width already chosen), produce the analytical performance metrics — gain, GBW, CMRR, and friends — but corrected for the two things a generic, model-agnostic evaluator gets wrong on gm/Id circuits.
Let's tell the whole story before touching a line of code.
The sizing pipeline ends with a scorecard. There is one shared, model-agnostic function — _evaluate_metrics — that knows the textbook formulas: gain is gm₁·Rout, GBW is gm₁ / (2π·Cc), and so on. It is deliberately generic so every sizing flow can reuse it.
But "generic" means it makes generic assumptions. When it needs an output resistance, it reaches for the single-device estimate: one transistor's own ro = 1/gds. That is fine for a plain current-source load — and badly wrong for the two structures gm/Id circuits love: a cascode load and a resistor network.
Think of the shared evaluator as a generic tax calculator that always applies the standard deduction. This file is the gm/Id-specific accountant who knows the special credits (a cascode multiplies your output resistance) and the special surcharges (source degeneration, a resistor tail, CMFB averager loading) that apply to this particular circuit. It doesn't rewrite the tax code — it just injects the right numbers before the calculator runs.
Analytical, not simulated — and that's the point
Everything here is ngspice-free. It is a fast, deterministic, closed-form estimate — a sizing-quality signal that tests and programmatic callers can trust to be reproducible. The CLI, when a human is watching, throws the netlist at a real PTM SPICE simulation and shows those numbers instead. This file is the quick tape-measure; ngspice is the structural lab.
The cast of characters
Five functions plus two shared collaborators it leans on. Hover any box to trace who it calls.
node_rout, and the whole thing feeds the shared _evaluate_metrics (dashed = lives in another module).We'll learn them in the order that teaches best, not the file's order: first the physics (Part 2), then the two cascode corrections (Parts 3–4), then the resistor DC gate (Part 5), and finally the orchestrator that wires them together (Part 6).
Looking ahead: Part 2 builds the one idea that everything else corrects for — why a cascode's output resistance is not just bigger than a single device's, but multiplied by a factor of a hundred.
Part 2 · The physics primer (the gm·ro·ro boost)
One idea carries this whole file. It is not as scary as it sounds.
Output resistance — why we even care
A gain stage's voltage gain is gm₁ · Rout: transconductance times the output resistance it drives into. So Rout is a direct multiplier on gain. The stiffer (higher-resistance) the load looking back into the output node, the more gain you get. Everything in Parts 3–4 is about estimating this one number correctly.
A single transistor — modest resistance
Left alone, a transistor acting as a current source has an output resistance of just its own ro = 1/gds — the channel-length-modulation resistance. Call it a leaky bucket. A typical value:
A cascode — resistance multiplied
Now stack a second transistor on top, its gate held at a fixed bias. This is a cascode. Any wobble in the output voltage tries to change the current, but the top device fights back through its own gm — a little local feedback loop. The effect is that the bottom device's resistance is seen through the top device and multiplied by roughly gm·ro (the intrinsic gain, ~50–100):
Analogy: a single resistor to the rail is a plain valve. A cascode is a valve with power steering bolted on — a servo that senses any change and pushes back with amplified force. The generic evaluator only weighs the valve; it never sees the servo, so it undercounts the resistance by the whole gm·ro factor.
Trace it with real numbers
Take ro₁ = ro₂ = 100 kΩ and gm₂ = 1 mS, so the intrinsic gain is gm·ro = 1e-3 · 1e5 = 100:
single-device estimate: Rout ≈ ro₁ = 100 kΩ
cascode reality: Rout ≈ gm₂·ro₂·ro₁
= (1e-3)·(1e5)·(1e5) = 10 MΩ
miss factor = 10 MΩ / 100 kΩ = 100× → 20·log₁₀(100) = 40 dB of gain
The other half: resistor-network surcharges
Phase 4 may have wrapped the circuit in resistors, and each one nudges a metric. This file passes three pre-computed modifiers straight through to the evaluator:
| Modifier | Physical cause | Metric it bends |
|---|---|---|
gm1_factor | source degeneration on the input pair | effective gm₁ (gain, GBW) |
gd_tail_override | a resistor tail current source | tail conductance (CMRR) |
gd_out_extra | CMFB averager resistors loading the output | output conductance (gain) |
These are the "surcharges." The cascode boost is the "credit." With both ideas in hand, the code reads like a checklist.
Part 3 · _cascode_rout1() — the boosted stage-1 resistance
The one job: if the first stage's load is a cascode, compute its true gm·ro·ro-boosted output resistance; if it isn't a cascode, return
Noneso the caller keeps the single-device estimate that is already correct.
def _cascode_rout1(view, model, sizing) -> float | None:
blocks = view.blocks
if not blocks.has_cascode_load():
return None # single-device estimate already right
out_net = blocks.first_stage_out_net()
if not out_net:
return None
tail = blocks.tail_net()
all_mos = [device for device, _slot in view.all_transistors.values()]
stop = frozenset({tail}) if tail else frozenset()
return node_rout(out_net, all_mos, model, sizing, stop)
Read it as a doorway with a guard. The first two return None lines are the guard: "unless there's genuinely a cascode load at a real output net, don't touch anything — the plain estimate is fine." Returning None is the code's way of saying "no correction needed."
The single most important line
The last line does the real work. node_rout(out_net, …, stop) stands at the output net and walks every device stacked above it up toward the rail, cascode-boosting each one, and returns their parallel combination in ohms.
Why stop matters — the AC-ground fence
The stop set names one net — the input-pair tail node — that the walk must treat as an AC ground and not climb past. Analogy: you're measuring how tall a stack of books reaches, but you stop at the shelf; you don't keep counting into the floor below. Without the fence, the walk would wrongly credit the input pair with a tail-degenerated cascode boost it doesn't have.
out_net, climb the cascode stack multiplying by gm·ro, and halt at the tail fence — the boosted Rout comes back in ohms.The result — say the 10 MΩ we computed in Part 2 — is handed to the evaluator as rout1_override, replacing its naive 100 kΩ. When there's no cascode, None flows through and the override is simply skipped.
Part 4 · _cascode_gd_tail() — the same trick, for CMRR
The one job: if the tail current source is itself a cascode, compute its boosted output conductance so the common-mode-rejection path sees the gm·ro stiffening; otherwise return
None.
def _cascode_gd_tail(view, model, sizing) -> float | None:
blocks = view.blocks
if not blocks.has_cascode_tail():
return None
tail_net = blocks.tail_net()
if not tail_net:
return None
all_mos = [device for device, _slot in view.all_transistors.values()]
r = node_rout(tail_net, all_mos, model, sizing, frozenset())
return 1.0 / r if r and r != float("inf") else None
This is a near-mirror of Part 3, so most of it should feel familiar. Same guard pattern, same node_rout walk. Two deliberate differences are worth naming.
Difference 1 — it returns conductance, not resistance
The CMRR formula wants gd_tail, a conductance (siemens), where Part 3's gain formula wanted resistance (ohms). So this function inverts the walk's result: 1/r. A cascode tail has a huge resistance, which flips to a tiny conductance — and a tiny tail conductance is exactly what gives excellent common-mode rejection.
Difference 2 — no stop fence
Here the tail is the very thing being measured, so the walk climbs the whole cascode stack down to the rail with an empty frozenset() — there's no input pair above it to protect from a spurious boost.
The 1/r guard, traced
Watch the tail-end of the return, r and r != inf. If node_rout found no devices it returns float("inf"); dividing 1/inf would give 0.0 — a physically-wrong "perfect" tail. The guard catches that and returns None instead, so the caller falls back to the resistor-tail modifier or the plain estimate.
gd_tail, which is what CMRR rewards.Part 5 · The resistor-load DC gate
The one job: decide whether a fixed resistor first-stage load can actually DC-bias the next common-source stage. If it can't, the stage rails open-loop and the gain-derived metrics are unmeasurable — so we drop them rather than report an optimistic lie (issue #148).
Two small functions here. The first just finds the victim; the second judges it.
_driven_cs_device() — who does the load have to bias?
def _driven_cs_device(view):
for slot in ("second_stage", "second_stage_p", "second_stage_n",
"third_stage", "third_stage_p", "third_stage_n"):
b = view.blocks.blocks.get(slot)
sig = b.signal_device if b else None
if sig is not None:
return sig if sig.terminals.get("s") in RAILS else None
return None
It scans the downstream stage slots for a signal device, and returns it only if its source sits on a rail — the signature of a common-source stage. A common-source stage is fragile precisely because its gate voltage must land in a narrow window; that's why it's the one we must protect.
_resistor_load_bias() — the collision test
Here is the physics. A fixed load resistor holds the first-stage output at I·R volts from its reference rail. But the driven common-source stage needs its gate exactly |Vgs| from that same rail to carry its quiescent current. Those two voltages are sized independently — R from a nominal threshold, |Vgs| from the gm/Id target — so they can disagree.
v_off = (spec.ibias / 2.0) / currents.gd_load_r # the I·R drop across the load
mismatch = abs(v_off - abs(s.vgs_v))
if mismatch > s.vds_sat_v:
... return True, [# invalid: gain metrics gated ...]
return False, [fragility]
The rule: if the two voltages disagree by more than the driven device's own Vdsat, it's pushed out of saturation. Below Vdsat of slack the stage still bends but survives; beyond it, the high-impedance output rails to a supply and the small-signal gm·Rout gain is a fiction sitting on an operating point that doesn't exist.
Trace it with real numbers
Take ibias/2 = 6 µA, load conductance gd_load_r = 20 µS (so R = 50 kΩ), a driven device with |Vgs| = 0.60 V and Vdsat = 0.15 V:
v_off = 6 µA / 20 µS = 0.30 V (what the resistor holds)
mismatch = |0.30 − 0.60| = 0.30 V (what the gate actually needs)
0.30 V > Vdsat 0.15 V → INVALID → drop gain/GBW/PM/CMRR/PSRR
The two return shapes
The function returns (invalid, notes). Even on the healthy branch it always appends a fragility advisory, because a fixed resistor cannot track Vth across PVT corners the way an active load can — the bias is honest today but corner-fragile. And it only engages at all for single-ended resistor loads feeding a common-source stage; a fully-differential node is held by CMFB, not by a driven gate, so it bails early.
Part 6 · evaluate_circuit() — the orchestrator
The one job: gather every gm/Id correction, hand them to the shared evaluator, then withhold any gain-derived metric that sits on an invalid operating point — returning
(metrics, margins, notes).
Now the top of the call graph reads like plain English, because every helper is already understood.
Beat 1 — the two tail kinds compete for one slot
gd_tail_override = _cascode_gd_tail(view, plan.model, sizing)
if gd_tail_override is None:
gd_tail_override = modifiers.gd_tail_override
A tail is either a cascode or a resistor — never both. So the cascode conductance from Part 4 wins when it exists (it returns a number); only when it returns None does the resistor-tail modifier from Phase 4 fill the slot. One clean precedence rule, no double-counting.
Beat 2 — inject every correction into the shared evaluator
metrics, margins = _evaluate_metrics(
sizing, view.slot_transistors, plan.cc_pf, tech, spec, plan.model,
cc2_pf=plan.cc2_pf,
gd_load_r=currents.gd_load_r,
rout1_override=_cascode_rout1(view, plan.model, sizing), # Part 3
gm1_factor=modifiers.gm1_factor, # degeneration
gd_tail_override=gd_tail_override, # Part 4 / resistor tail
gd_out_extra=modifiers.gd_out_extra, # CMFB loading
)
This is the payload. Every keyword argument is one correction we've met: the boosted stage-1 resistance, the degeneration factor on gm₁, the tail conductance, and the CMFB output loading. The shared function owns the formulas; this file owns the physics the formulas can't see on their own.
Beat 3 — withhold what can't honestly be measured
invalid, notes = _resistor_load_bias(view, currents, sizing, spec)
if invalid:
for k in _RESISTOR_GATED_METRICS: # gain_db, gbw_hz, phase_margin_deg, cmrr_db, psrr_db
metrics.pop(k, None)
margins.pop(k, None)
return metrics, margins, notes
If Part 5 flagged the bias invalid, the five gain-derived metrics are deleted, not reported. The module constant _RESISTOR_GATED_METRICS names exactly which ones: they're the small-signal quantities that only mean anything if the operating point exists. Removing them is the code refusing to publish an optimistic number it knows is fiction.
The hidden idea
Though it's never written as a line, the whole file implements one principle: a correction layer wrapped around a generic evaluator. It computes no metric itself. It contributes exactly the physics the shared formulas cannot infer from a lone device — the gm·ro·ro cascode boost and the resistor-network modifiers — and it deletes any result that would sit on an operating point that doesn't exist.
What it honestly does not do
- It never touches ngspice — every number is a closed-form estimate; the CLI's PTM simulation is the real authority.
- It corrects only two structures: cascodes and Phase-4 resistor networks. Anything else falls back to the single-device estimate untouched.
- The cascode helpers return
Noneunless there is genuinely a cascode — "no correction" is a first-class answer, never a guessed one. - The DC gate is conservative: it drops the whole gain family the moment the bias is invalid, rather than trying to salvage a partial number.
In one sentence: evaluate.py is Phase 5's gm/Id correction layer — it feeds the shared metric evaluator the cascode-aware output resistance (the gm·ro·ro boost a single-device gds estimate misses) and the Phase-4 resistor modifiers, then withholds the gain-derived metrics whenever a resistor-loaded stage can't actually be biased, giving tests and callers a fast, honest, ngspice-free sizing-quality signal.