evaluate.py — explained

circuitgenome/sizer/gmid/evaluate.py — Phase 5: turn a solved sizing into performance numbers, with the gm/Id-specific corrections a generic evaluator can't know.

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.

solved sizing Phases 1–4 done evaluate.py (this file) analytical · deterministic · fast tests / callers CLI · spice_sim PTM ngspice · what humans see
Same solved sizing, two scorekeepers. This file is the reproducible analytical estimate; the CLI simulates and displays that instead.

The cast of characters

Five functions plus two shared collaborators it leans on. Hover any box to trace who it calls.

evaluate_circuit() _cascode_rout1() boosted stage-1 Rout _cascode_gd_tail() tail conductance (CMRR) _resistor_load_bias() is the DC bias valid? node_rout() shared · walks the stack _driven_cs_device() next CS stage's device _evaluate_metrics shared · the formulas
The orchestrator on top. Three correction helpers below it; two of them lean on the shared 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:

ro = 1/gds ≈ 100 kΩ

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):

Rout,casc gm roro ro

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
what the generic evaluator sees 100 kΩ (ro only) what a cascode actually delivers 10 MΩ = gm·ro·ro = 100 × ro bars not to scale — the real ratio is 100×, i.e. 40 dB of gain the single-device estimate would silently drop
Miss the cascode boost and you underreport a stage's gain by ~40 dB. That is exactly the correction this file injects.

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:

ModifierPhysical causeMetric it bends
gm1_factorsource degeneration on the input paireffective gm₁ (gain, GBW)
gd_tail_overridea resistor tail current sourcetail conductance (CMRR)
gd_out_extraCMFB averager resistors loading the outputoutput 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 None so 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.

Vdd rail cascode dev (top) ×gm·ro boost load dev (bottom) ro out_net (start here) walk up ↑ tail net = stop walk never climbs past this fence (AC ground)
Start at 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.

node_rout(tail) r ≈ 10 MΩ 1 / r gd_tail 0.1 µS (tiny) a stiff cascode tail → a tiny conductance → high CMRR
Same boosted walk as Part 3, but inverted: the huge resistance becomes a tiny 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 independentlyR 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
reference rail I·R held = 0.30 V output sits here |Vgs| needed = 0.60 V gate wants to be here gap 0.30 V gap 0.30 V > Vdsat 0.15 V → stage rails ✗
The resistor pins the output 0.30 V from the rail; the driven gate needs 0.60 V. The 0.30 V gap dwarfs the 0.15 V Vdsat, so the stage falls out of saturation.

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.

mismatch > Vdsat? yes no invalid drop gain metrics valid keep, note fragility
Either way a fragility note is emitted; only the left branch also gates the gain-derived metrics.

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.

rout1_override gm1_factor gd_tail_override gd_out_extra _evaluate_metrics the formulas bias valid? no → pop 5 metrics yes → keep all notes from _resistor_load_bias ride alongside the numbers
Corrections funnel in, the shared evaluator computes, and a final gate drops the gain-derived metrics whenever the operating point is invalid.

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.

metrics= generic(sizing, correctionsgm/Id) gatedinvalid bias

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 None unless 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.