Source code for circuitgenome.sizer.gmid.evaluate

"""Phase 5 — Evaluate: analytical performance metrics from the solved sizing.

Wraps the shared (model-injected) metric evaluation with the gm/Id-specific
corrections: the cascode-aware first-stage output resistance (the
single-device-gds estimate misses the gm·ro·ro boost of a cascode load) and the
resistor-network metric modifiers from Phase 4 (degeneration on gm1, resistor
tail on gd_tail, CMFB averager loading on the output).

Analytical (ngspice-free) estimate: a deterministic sizing-quality signal for
tests/programmatic callers.  The CLI measures PTM performance with ngspice
(``spice_sim.simulate_metrics``) and displays that instead of these numbers.
"""
from __future__ import annotations

from ..shared.device_model import GmIdModel
from ..shared.metrics import _evaluate_metrics
from ..shared.models import SizingSpec, TechParams, TransistorSizing
from ..shared.taxonomy import RAILS
from .analyze import CircuitView
from .blocks import node_rout
from .plan import CurrentPlan, SizingPlan
from .resistors import MetricModifiers

# Gain-derived metrics that a railed open-loop output makes unmeasurable — the
# small-signal formula sits on an operating point that does not exist (#148).
_RESISTOR_GATED_METRICS = ("gain_db", "gbw_hz", "phase_margin_deg",
                           "cmrr_db", "psrr_db")


def _cascode_rout1(view: CircuitView, model: GmIdModel,
                   sizing: dict[str, TransistorSizing]) -> float | None:
    """Cascode-aware first-stage output resistance, or ``None`` when the load
    is not a cascode (the single-device estimate is then already right)."""
    blocks = view.blocks
    if not blocks.has_cascode_load():
        return None
    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)


def _cascode_gd_tail(view: CircuitView, model: GmIdModel,
                     sizing: dict[str, TransistorSizing]) -> float | None:
    """Cascode-aware tail output conductance, or ``None`` when the tail is not a
    cascode current source (the single-device estimate is then already right).

    Mirrors :func:`_cascode_rout1` for the CMRR path: ``node_rout`` at the tail
    node walks the cascode stack down to the rail, so the ~``gm·ro`` boost that a
    cascode tail gives (invisible to the single-device ``gds``) reaches CMRR.
    """
    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


def _driven_cs_device(view: CircuitView):
    """The next stage's signal device when it is a common-source stage (source
    on a rail), else ``None`` — the device the first-stage output must bias."""
    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


def _resistor_load_bias(
    view: CircuitView, currents: CurrentPlan,
    sizing: dict[str, TransistorSizing], spec: SizingSpec,
) -> tuple[bool, list[str]]:
    """DC validity of a single-ended, rail-referenced resistor first-stage load.

    A fixed load resistor holds the first-stage output ``I·R`` from its
    reference rail, but the driven common-source stage needs its gate exactly
    ``|Vgs|`` from that *same* rail to carry its quiescent current in
    saturation.  The two are sized independently (``R`` from the nominal
    threshold + a fixed overdrive, ``|Vgs|`` from the gm/Id target), so when
    they disagree by more than the driven device's own ``Vdsat`` the second
    stage is pushed out of its intended operating regime and its high-impedance
    output rails open-loop — yet the small-signal formula would still report an
    optimistic ``gm·Rout`` gain (issue #148).

    Returns ``(invalid, notes)``: ``invalid`` gates the gain-derived metrics;
    ``notes`` always carries a corner-fragility advisory because a fixed
    resistor cannot track ``Vth`` across PVT the way an active load can.  Only
    single-ended resistor loads feeding a common-source stage are in scope (a
    fully-differential node is held by CMFB, not the driven gate).
    """
    if currents.gd_load_r <= 0 or view.blocks.is_fully_differential:
        return False, []
    drv = _driven_cs_device(view)
    s = sizing.get(drv.ref) if drv is not None else None
    if s is None:
        return False, []
    v_off = (spec.ibias / 2.0) / currents.gd_load_r  # I·R across the load R
    mismatch = abs(v_off - abs(s.vgs_v))
    fragility = (
        "first-stage load is a fixed rail-referenced resistor: its DC drop is "
        "sized from the nominal threshold and cannot track Vth across PVT "
        "corners, so the inter-stage bias is corner-fragile — verify gain "
        "across corners in SPICE.")
    if mismatch > s.vds_sat_v:
        rail = "gnd" if drv.type == "nmos" else "vdd"
        return True, [
            f"resistor-loaded first stage cannot bias the driven "
            f"{drv.type.upper()} second stage: the load holds the first-stage "
            f"output {v_off:.2f} V from {rail} but the stage needs "
            f"{abs(s.vgs_v):.2f} V (off by {mismatch:.2f} V > Vdsat "
            f"{s.vds_sat_v:.2f} V) — the open-loop output rails; "
            f"gain/GBW/PM/CMRR/PSRR reported as unmeasurable (issue #148).",
            fragility,
        ]
    return False, [fragility]


[docs] def evaluate_circuit( view: CircuitView, currents: CurrentPlan, plan: SizingPlan, sizing: dict[str, TransistorSizing], modifiers: MetricModifiers, spec: SizingSpec, tech: TechParams, ) -> tuple[dict[str, float], dict[str, float], list[str]]: """Return ``(metrics, margins, notes)`` for the solved sizing. ``notes`` surfaces the resistor-load DC-bias advisories (:func:` _resistor_load_bias`); when that operating point is invalid the gain-derived metrics are dropped rather than reported optimistically. """ # Cascode tail conductance (CMRR) wins over the resistor-tail modifier when # present; the two tail kinds are mutually exclusive. gd_tail_override = _cascode_gd_tail(view, plan.model, sizing) if gd_tail_override is None: gd_tail_override = modifiers.gd_tail_override 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), gm1_factor=modifiers.gm1_factor, gd_tail_override=gd_tail_override, gd_out_extra=modifiers.gd_out_extra, ) invalid, notes = _resistor_load_bias(view, currents, sizing, spec) if invalid: for k in _RESISTOR_GATED_METRICS: metrics.pop(k, None) margins.pop(k, None) return metrics, margins, notes