Source code for circuitgenome.sizer.shared.metrics

"""Performance-metric evaluation from a solved sizing.

Small-signal parameters come through a :class:`~.device_model.DeviceModel`
evaluated at the *solved* geometry, so the same code is exact for both Level-1
(geometry-free ``λ·Id``) and gm/Id (LUT).  Shared by both sizers.
"""
from __future__ import annotations

from circuitgenome.synthesizer.models import Device

from . import equations as eq
from .device_model import DeviceModel
from .models import SizingSpec, TechParams, TransistorSizing
from .preprocess import _first_stage_gain_factor
from .taxonomy import SECOND_STAGE_SLOTS, THIRD_STAGE_SLOTS, is_signal_device


[docs] def _evaluate_metrics( transistor_sizing: dict[str, TransistorSizing], slot_transistors: dict[str, list[Device]], cc_pf: float | None, tech: TechParams, spec: SizingSpec, model: DeviceModel, cc2_pf: float | None = None, gd_load_r: float = 0.0, rout1_override: float | None = None, rout2_override: float | None = None, rout3_override: float | None = None, gm1_factor: float = 1.0, gd_tail_override: float | None = None, gd_out_extra: float = 0.0, ) -> tuple[dict[str, float], dict[str, float]]: """Compute performance metrics and safety margins from the solution. Small-signal parameters come through ``model`` evaluated at the *solved* geometry — exact for both Level-1 (geometry-free λ·Id) and gm/Id (LUT). ``rout{1,2,3}_override`` let a caller (the gm/Id pipeline) supply cascode-aware stage output resistances; when ``None`` (the Level-1 default) the single-device-gds estimate is used unchanged. """ metrics: dict[str, float] = {} margins: dict[str, float] = {} is_three_stage = any(s in slot_transistors for s in THIRD_STAGE_SLOTS) has_second_stage = ( any(s in slot_transistors for s in SECOND_STAGE_SLOTS) or is_three_stage ) def _sz(ref: str) -> TransistorSizing | None: return transistor_sizing.get(ref) def _gm(d: Device, s: TransistorSizing) -> float: return min(model.gm(d.type, s.w_um, s.l_um, s.ids_a), model.gm_ceiling(d.type, s.ids_a, s.l_um)) def _gds(d: Device, s: TransistorSizing) -> float: return model.gds(d.type, s.w_um, s.l_um, s.ids_a) # --- Input pair gm (gm1_factor < 1 for source degeneration) --- ip_devs = slot_transistors.get("input_pair", []) gm1 = 0.0 s_ip = _sz(ip_devs[0].ref) if ip_devs else None if s_ip: gm1 = _gm(ip_devs[0], s_ip) * gm1_factor # --- Load --- ld_devs = slot_transistors.get("load", []) gd_ld = 0.0 if ld_devs: s = _sz(ld_devs[0].ref) if s: gd_ld = _gds(ld_devs[0], s) gd_ip = _gds(ip_devs[0], s_ip) if s_ip else 0.0 if rout1_override is not None: rout1 = rout1_override else: rout1 = (eq.rout(gd_ip, gd_ld + gd_load_r) if (gd_ip + gd_ld + gd_load_r) > 0 else float("inf")) # --- Tail current --- tc_devs = slot_transistors.get("tail_current", []) gd_tail = 0.0 if tc_devs: s = _sz(tc_devs[0].ref) if s: gd_tail = _gds(tc_devs[0], s) if gd_tail_override is not None: # resistor tail: gd_tail = 1/R gd_tail = gd_tail_override # --- Second stage (SE: "second_stage"; FD: use second_stage_p as representative) --- ss_devs = ( slot_transistors.get("second_stage") or slot_transistors.get("second_stage_p") or slot_transistors.get("second_stage_n") or [] ) gm2 = 0.0 gd_ss_n, gd_ss_p = 0.0, 0.0 ss_load_gd = 0.0 # output conductance of the current-source load (for PSRR) ids_2 = spec.ibias * spec.second_stage_current_ratio if has_second_stage: # gm2 comes from the signal transistor, which may be the NMOS (NMOS-CS # stage) or the PMOS (PMOS-CS stage); the partner device is the load. for d in ss_devs: s = _sz(d.ref) if not s: continue g_d = _gds(d, s) if d.type == "nmos": gd_ss_n = g_d else: gd_ss_p = g_d if is_signal_device(d): gm2 = _gm(d, s) else: ss_load_gd = g_d rout2 = eq.rout(gd_ss_n, gd_ss_p) if (gd_ss_n + gd_ss_p) > 0 else float("inf") if rout2_override is not None: rout2 = rout2_override # Two-stage FD: the resistive-sense CMFB averager loads the output. if (not is_three_stage and gd_out_extra > 0.0 and rout2 < float("inf")): rout2 = 1.0 / (1.0 / rout2 + gd_out_extra) else: rout2 = float("inf") # --- Third stage (SE: "third_stage"; FD: "third_stage_p" representative) --- ts_devs = ( slot_transistors.get("third_stage") or slot_transistors.get("third_stage_p") or slot_transistors.get("third_stage_n") or [] ) gm3 = 0.0 gd_ts_n, gd_ts_p = 0.0, 0.0 ids_3 = spec.ibias * spec.third_stage_current_ratio if is_three_stage: # gm3 comes from the signal transistor (NMOS-CS or PMOS-CS output stage). for d in ts_devs: s = _sz(d.ref) if not s: continue g_d = _gds(d, s) if d.type == "nmos": gd_ts_n = g_d else: gd_ts_p = g_d if is_signal_device(d): gm3 = _gm(d, s) rout3 = eq.rout(gd_ts_n, gd_ts_p) if (gd_ts_n + gd_ts_p) > 0 else float("inf") if rout3_override is not None: rout3 = rout3_override # Three-stage FD: CMFB averager loads the (third-stage) output. if gd_out_extra > 0.0 and rout3 < float("inf"): rout3 = 1.0 / (1.0 / rout3 + gd_out_extra) else: rout3 = float("inf") # --- Gain --- # Single-ended non-mirror first stage delivers k_fs·gm1·Rout1 (k_fs=0.5); # mirror / fully-differential first stage delivers the full gm1·Rout1. k_fs = _first_stage_gain_factor(slot_transistors) if is_three_stage and rout2 < float("inf") and rout3 < float("inf"): stage_gains = [k_fs * gm1 * rout1, gm2 * rout2, gm3 * rout3] elif has_second_stage and rout2 < float("inf"): stage_gains = [k_fs * gm1 * rout1, gm2 * rout2] else: stage_gains = [k_fs * gm1 * rout1] if all(g > 0 for g in stage_gains): gain_db = eq.open_loop_gain_db(stage_gains) metrics["gain_db"] = gain_db if spec.gain_min_db is not None: margins["gain_db"] = gain_db - spec.gain_min_db # +ve → meets spec # --- GBW, PM, SR --- cc_f = (cc_pf * 1e-12) if cc_pf else None cc2_f = (cc2_pf * 1e-12) if cc2_pf else None if has_second_stage and cc_f and gm1 > 0: # k_fs·gm1 is the transconductance into the Miller loop (halved for a # single-ended non-mirror first stage). gm1_loop = k_fs * gm1 gbw = eq.unity_gain_bw(gm1_loop, cc_f) metrics["gbw_hz"] = gbw if spec.gbw_min_hz is not None: margins["gbw_hz"] = gbw - spec.gbw_min_hz if is_three_stage and gm2 > 0 and gm3 > 0 and cc2_f: pm = eq.phase_margin_three_stage_deg(gm1_loop, gm2, gm3, cc_f, cc2_f, spec.cl) elif gm2 > 0: pm = eq.phase_margin_two_stage_deg(gm1_loop, gm2, cc_f, spec.cl) else: pm = None if pm is not None: metrics["phase_margin_deg"] = pm if spec.phase_margin_min_deg is not None: margins["phase_margin_deg"] = pm - spec.phase_margin_min_deg sr = eq.slew_rate_vps(spec.ibias, cc_f) metrics["slew_rate_vps"] = sr if spec.slew_rate_min_vps is not None: margins["slew_rate_vps"] = sr - spec.slew_rate_min_vps # --- Power --- # Supply currents: tail (ibias), second stage (ids_2), bias_gen (ibias approx) bg_devs = slot_transistors.get("bias_gen", []) n_bias_legs = len([d for d in bg_devs if d.type in ("nmos", "pmos")]) supply_currents = [spec.ibias] # tail if has_second_stage: n_ss = sum(1 for s in SECOND_STAGE_SLOTS if s in slot_transistors) supply_currents.append(ids_2 * n_ss) if is_three_stage: n_ts = sum(1 for s in THIRD_STAGE_SLOTS if s in slot_transistors) supply_currents.append(ids_3 * n_ts) supply_currents.append(spec.ibias * max(n_bias_legs, 1)) # bias gen approx power = eq.quiescent_power(spec.vdd, spec.vss, supply_currents) metrics["power_w"] = power if spec.power_max_w is not None: margins["power_w"] = spec.power_max_w - power # +ve → meets spec # --- Output swing (from VDS_sat of second-stage or load transistors) --- if spec.output_swing_max_v is not None and ss_devs: sp = next((d for d in ss_devs if d.type == "pmos"), None) if sp and _sz(sp.ref): s = _sz(sp.ref) assert s is not None vout_max = spec.vdd - s.vds_sat_v metrics["output_swing_max_v"] = vout_max margins["output_swing_max_v"] = vout_max - spec.output_swing_max_v if spec.output_swing_min_v is not None and ss_devs: sn = next((d for d in ss_devs if d.type == "nmos"), None) if sn and _sz(sn.ref): s = _sz(sn.ref) assert s is not None vout_min = spec.vss + s.vds_sat_v metrics["output_swing_min_v"] = vout_min margins["output_swing_min_v"] = spec.output_swing_min_v - vout_min # --- CMRR --- if gm1 > 0 and gd_tail > 0: cmrr = eq.cmrr_db(gm1, gd_tail) metrics["cmrr_db"] = cmrr if spec.cmrr_min_db is not None: margins["cmrr_db"] = cmrr - spec.cmrr_min_db # --- PSRR (approximate, two-stage) --- if has_second_stage and gm2 > 0 and ss_load_gd > 0: psrr = eq.psrr_db_approx(gm2, ss_load_gd) metrics["psrr_db"] = psrr if spec.psrr_min_db is not None: margins["psrr_db"] = psrr - spec.psrr_min_db return metrics, margins