"""Model-independent pre-sizing: slot extraction, IDS assignment from KCL +
``spec.ibias``, topology checks, load-resistor sizing, and the gm/VDS_sat
requirements derived from the performance spec.
Shared by both the Level-1 analytical sizer and the gm/Id pipeline. Output
conductances come through a :class:`~.device_model.DeviceModel`, so the gm/Id
path gets LUT-accurate ``gds`` while Level-1 reproduces ``λ·Id`` exactly.
"""
from __future__ import annotations
import math
from circuitgenome.recognizer.models import FunctionalBlockRecognitionResult
from circuitgenome.synthesizer.models import Device
from . import equations as eq
from .device_model import CURRENT_SOURCE, SIGNAL, DeviceModel
from .models import SizingSpec, TechParams
from .taxonomy import (
FULL_BIAS_SLOTS,
HALF_BIAS_SLOTS,
RAILS,
SECOND_STAGE_SLOTS,
STAGE_SLOTS,
THIRD_STAGE_SLOTS,
is_signal_device,
)
# Overdrive (V) used when sizing a load resistor so its DC drop biases the
# driven device into conduction: V_node ≈ Vth + this.
_RESISTOR_LOAD_OVERDRIVE = 0.15
# Miller-compensation stability floor: Cc ≥ this fraction of CL keeps the
# pole split wide enough for a ~60° phase-margin budget (Allen-Holberg's
# Cc > 0.22·CL, rounded up for margin).
_CC_STABILITY_RATIO = 0.25
[docs]
def size_load_resistors(
slot_resistors: dict[str, list[Device]], spec: SizingSpec, tech: TechParams,
) -> dict[str, float]:
"""Size resistor-load devices so the first-stage output biases on.
Each load resistor carries the input-pair branch current ``ibias/2``. The
value is chosen so the DC drop places the first-stage output node at a level
that turns the driven device on:
* resistor to **gnd** (PMOS-input loads): ``V_node = vth_n + Vov`` →
``R = V_node / (ibias/2)``.
* resistor from **vdd** (NMOS-input loads): drop ``= |vth_p| + Vov`` →
``R = drop / (ibias/2)``.
Only the ``load`` slot is sized; other resistor roles keep their netlist
value. Returns ``{ref: ohms}``.
"""
out: dict[str, float] = {}
branch_i = spec.ibias / 2.0
if branch_i <= 0:
return out
vov = _RESISTOR_LOAD_OVERDRIVE
for r in slot_resistors.get("load", []):
nets = [str(n).lower() for n in r.terminals.values()]
if any("gnd" in n or n in ("0", "vss!") for n in nets):
v = tech.nmos.vth + vov
elif any("vdd" in n for n in nets):
v = abs(tech.pmos.vth) + vov
else:
continue # not a rail-referenced load resistor
if v > 0:
out[r.ref] = v / branch_i
return out
[docs]
def check_topology_match(
slot_transistors: dict[str, list[Device]], topology_name: str
) -> list[str]:
"""Warn when the netlist does not realise the chosen topology.
Every gain-stage slot of a valid circuit holds exactly one signal
transistor. A stage slot with **no** signal device (e.g. bias-generator
leftovers shoehorned into ``second_stage_p`` when a single-ended netlist is
sized against a fully-differential topology) signals a ``--topology``
mismatch — which would otherwise silently drop the gain/PM/PSRR metrics.
"""
warnings: list[str] = []
for slot in sorted(STAGE_SLOTS):
devs = slot_transistors.get(slot)
if devs and not any(is_signal_device(d) for d in devs):
warnings.append(
f"stage slot '{slot}' has no signal transistor — the netlist may "
f"not match topology '{topology_name}' (check --topology, e.g. "
f"single-ended vs fully-differential)."
)
return warnings
[docs]
def deduplicate_devices(
slot_transistors: dict[str, list[Device]],
) -> dict[str, tuple[Device, str]]:
"""Return {ref: (Device, slot_name)} with each ref appearing once.
When a transistor appears in multiple slots (e.g. the tail mirror
reference appears in both ``tail_current`` and ``bias_gen``), the
*first* slot encountered wins for the purpose of iDS assignment.
Priority order: input_pair > load > tail_current > second_stage > bias_gen.
"""
priority = ["input_pair", "load", "tail_current",
"second_stage", "second_stage_p", "second_stage_n",
"third_stage", "third_stage_p", "third_stage_n",
"output_stage", "output_stage_p", "output_stage_n", "bias_gen"]
ordered = sorted(
slot_transistors.keys(),
key=lambda s: priority.index(s) if s in priority else len(priority),
)
seen: dict[str, tuple[Device, str]] = {}
for slot in ordered:
for d in slot_transistors[slot]:
if d.ref not in seen:
seen[d.ref] = (d, slot)
return seen
[docs]
def _cascode_load_current_plan(
slot_transistors: dict[str, list[Device]], spec: SizingSpec,
) -> dict[str, float]:
"""Per-device IDS for folded/telescopic cascode loads, from KCL at the fold.
The generic HALF_BIAS rule (``ibias/n`` per same-type device) starves
cascode loads: at the folding node the bottom sinks must absorb the
input-pair branch current *plus* the cascode branch current, or the pair's
excess current has nowhere to go and the whole load rails. Structure,
read from the assembled netlist:
* **folding devices** — source on a supply rail, drain on an input-pair
drain net. They carry ``ibias/2`` (pair branch) + ``I_casc``.
* **cascode devices** — source on an input-pair drain net (stacked on the
folding node). They and every other load device carry the cascode
branch current ``I_casc``, chosen as ``ibias/2``.
* **telescopic** (cascode devices but no folding devices): no folding
node — the whole stack carries the pair branch current ``ibias/2``.
Simple loads (nothing stacked on the pair drains) return ``{}`` and keep
the generic rule.
"""
load = [d for d in slot_transistors.get("load", [])
if d.type in ("nmos", "pmos")]
pair = [d for d in slot_transistors.get("input_pair", [])
if d.type in ("nmos", "pmos")]
if not load or not pair:
return {}
pair_drains = {d.terminals.get("d") for d in pair}
cascode = {d.ref for d in load if d.terminals.get("s") in pair_drains}
if not cascode:
return {}
folding = {d.ref for d in load
if d.terminals.get("s") in RAILS
and d.terminals.get("d") in pair_drains}
i_pair = spec.ibias / 2.0
if not folding:
return {d.ref: i_pair for d in load} # telescopic
i_casc = spec.ibias / 2.0
return {d.ref: (i_pair + i_casc if d.ref in folding else i_casc)
for d in load}
[docs]
def assign_ids(
slot_transistors: dict[str, list[Device]],
all_transistors: dict[str, tuple[Device, str]],
spec: SizingSpec,
) -> dict[str, float]:
"""Assign quiescent IDS to each transistor from KCL + spec.ibias."""
ids_2 = spec.ibias * spec.second_stage_current_ratio
cascode_load = _cascode_load_current_plan(slot_transistors, spec)
ids_map: dict[str, float] = {}
for ref, (device, slot) in all_transistors.items():
if slot == "load" and ref in cascode_load:
ids_map[ref] = cascode_load[ref]
elif slot in HALF_BIAS_SLOTS:
# Each transistor in a 2-transistor group carries ibias/2.
# For n devices in the slot (e.g. degenerated pairs), divide equally.
n = len([d for d in slot_transistors[slot] if d.type == device.type])
ids_map[ref] = spec.ibias / max(n, 1)
elif slot in FULL_BIAS_SLOTS:
ids_map[ref] = spec.ibias
elif slot in SECOND_STAGE_SLOTS:
ids_map[ref] = ids_2
elif slot in THIRD_STAGE_SLOTS:
ids_map[ref] = spec.ibias * spec.third_stage_current_ratio
else:
ids_map[ref] = spec.ibias # conservative default
return ids_map
[docs]
def _first_stage_gain_factor(slot_transistors: dict[str, list[Device]]) -> float:
"""First-stage transconductance/gain factor ``k_fs``.
A differential pair tapped **single-ended** only delivers ``gm1·Rout1/2``
(and a Miller loop transconductance of ``gm1/2``) unless an active
**current-mirror** load combines both branches to recover the full
``gm1·Rout1``. So:
* ``1.0`` — current-mirror load (a ``load`` device is diode-connected,
``g == d``), or a fully-differential output (both branches feed the
next stage), and
* ``0.5`` — single-ended output with a resistor or plain current-source
(non-mirror) load.
Applied to the first-stage gain and to ``gm1``'s role in GBW/PM (not to the
raw-device ``gm1`` used for CMRR).
"""
if any(s in slot_transistors for s in
("second_stage_p", "second_stage_n", "third_stage_p", "third_stage_n")):
return 1.0 # fully-differential: both branches drive the next stage
mosfets = [d for d in slot_transistors.get("load", []) if d.type in ("nmos", "pmos")]
is_mirror = any(d.terminals.get("g") == d.terminals.get("d") for d in mosfets)
return 1.0 if is_mirror else 0.5
[docs]
def compute_requirements(
slot_transistors: dict[str, list[Device]],
all_transistors: dict[str, tuple[Device, str]],
ids_map: dict[str, float],
tech: TechParams,
spec: SizingSpec,
model: DeviceModel,
gd_load_r: float = 0.0,
) -> tuple[dict[str, float], dict[str, float], float | None, float | None, list[str]]:
"""Compute required gm and max VDS_sat per transistor; also Cc1 and Cc2.
Returns ``(gm_req_map, vod_max_map, cc_pf, cc2_pf, warnings)`` where
``warnings`` lists any gm requirement clamped to the weak-inversion
ceiling (the spec cannot be met at this bias current). Output conductances
come through ``model`` so the gm/Id path uses LUT-accurate gds; the Level-1
model reproduces the geometry-free ``λ·Id`` exactly.
"""
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
)
ids_2 = spec.ibias * spec.second_stage_current_ratio
# --- Output conductances at the operating point ---
ip_devices = slot_transistors.get("input_pair", [])
ld_devices = slot_transistors.get("load", [])
def _gds_est(device: Device, ids: float) -> float:
"""Pre-geometry gds estimate, role-aware (signal vs current source)."""
role = SIGNAL if is_signal_device(device) else CURRENT_SOURCE
return model.gds_estimate(device.type, ids, role)
gd_ip = _gds_est(ip_devices[0], spec.ibias / 2) if ip_devices else 0.0
gd_ld = _gds_est(ld_devices[0], spec.ibias / 2) if ld_devices else 0.0
# Resistor-load conductance (1/R) loads the first-stage output node.
rout1 = eq.rout(gd_ip, gd_ld + gd_load_r)
# Single-ended non-mirror loads halve the first-stage gm/gain (see helper).
k_fs = _first_stage_gain_factor(slot_transistors)
# For FD topologies use second_stage_p as the representative path (symmetric).
ss_devices = (
slot_transistors.get("second_stage")
or slot_transistors.get("second_stage_p")
or slot_transistors.get("second_stage_n")
or []
)
ss_nmos = next((d for d in ss_devices if d.type == "nmos"), None)
ss_pmos = next((d for d in ss_devices if d.type == "pmos"), None)
gd_n2 = _gds_est(ss_nmos, ids_2) if ss_nmos else 0.0
gd_p2 = _gds_est(ss_pmos, ids_2) if ss_pmos else 0.0
rout2 = eq.rout(gd_n2, gd_p2) if (gd_n2 + gd_p2) > 0 else float("inf")
# Third-stage output conductances (three-stage topologies only).
ids_3 = spec.ibias * spec.third_stage_current_ratio
ts_devices = (
slot_transistors.get("third_stage")
or slot_transistors.get("third_stage_p")
or slot_transistors.get("third_stage_n")
or []
)
ts_nmos = next((d for d in ts_devices if d.type == "nmos"), None)
ts_pmos = next((d for d in ts_devices if d.type == "pmos"), None)
gd_n3 = _gds_est(ts_nmos, ids_3) if ts_nmos else 0.0
gd_p3 = _gds_est(ts_pmos, ids_3) if ts_pmos else 0.0
rout3 = eq.rout(gd_n3, gd_p3) if (gd_n3 + gd_p3) > 0 else float("inf")
# --- gm1 lower bound from CMRR (independent of Cc — compute first) ---
gm1_req = 0.0
gm2_req = 0.0
gm3_req = 0.0
if spec.cmrr_min_db:
tc_devices = slot_transistors.get("tail_current", [])
if tc_devices:
gd_tail = _gds_est(tc_devices[0], spec.ibias)
cmrr_lin = 10.0 ** (spec.cmrr_min_db / 20.0)
gm1_req = max(gm1_req, cmrr_lin * 2.0 * gd_tail)
# --- Compensation cap determination (two-stage only) ---
cc_pf: float | None = None
cc_f: float = 0.0
if has_second_stage:
cc_min_f = tech.cap.min * 1e-12
cc_max_f = tech.cap.max * 1e-12
# Pick the *smallest* stable Cc: the pole-split floor ~0.25·CL keeps
# the output pole clear of GBW for a ~60° PM budget. The slew rate
# only *upper*-bounds Cc (SR = iBias/Cc — a smaller Cc slews faster),
# and every pF above the floor inflates the GBW-side gm1 requirement
# 2π·GBW·Cc/k_fs toward the weak-inversion ceiling (issue #108).
cc_ub_f = cc_max_f
if spec.slew_rate_min_vps:
cc_ub_f = min(cc_ub_f, spec.ibias / spec.slew_rate_min_vps)
cc_f = max(cc_min_f, min(_CC_STABILITY_RATIO * spec.cl, cc_ub_f))
# Cc2 for three-stage (inner cap = Cc1/4); None for two-stage and one-stage.
cc2_pf: float | None = None
cc2_f: float = 0.0
if has_second_stage and cc_f > 0:
# From GBW: GBW = k_fs·gm1 / (2π·Cc) (with the stability-floor Cc).
# This is the primary gm1 driver; Cc stays within the SR bound because
# we do NOT inflate Cc here to accommodate gain. A non-mirror load
# (k_fs<1) needs a proportionally larger device gm1.
if spec.gbw_min_hz:
gm1_req = max(gm1_req, 2.0 * math.pi * spec.gbw_min_hz * cc_f / k_fs)
# Recompute Cc only if CMRR pushed gm1 above the GBW baseline.
if spec.gbw_min_hz and gm1_req > 0.0:
cc_f = max(cc_f, k_fs * gm1_req / (2.0 * math.pi * spec.gbw_min_hz))
cc_f = min(cc_f, cc_max_f)
if is_three_stage:
# Three-stage: inner cap = Cc1/4.
cc2_f = cc_f / 4.0
cc2_pf = cc2_f * 1e12
# Phase margin (split phase budget equally between two non-dominant poles).
# Inner pole: ωp2 ≈ gm2/Cc2; output pole: ωp3 ≈ gm3/CL.
if spec.phase_margin_min_deg and gm1_req > 0.0:
half_lag = math.radians((90.0 - spec.phase_margin_min_deg) / 2.0)
t = math.tan(half_lag)
gm1_loop = k_fs * gm1_req # transconductance into the loop (ωt = gm1_loop/Cc1)
gm2_req = max(gm2_req, gm1_loop * cc2_f / (cc_f * t))
gm3_req = max(gm3_req, gm1_loop * spec.cl / (cc_f * t))
# Gain: A0 = k_fs·gm1·Rout1·gm2·Rout2·gm3·Rout3.
# With gm2_req now determined, solve for the gm3 needed for gain.
if (spec.gain_min_db
and rout1 < float("inf")
and rout2 < float("inf")
and rout3 < float("inf")
and gm1_req > 0.0
and gm2_req > 0.0):
A0 = 10.0 ** (spec.gain_min_db / 20.0)
gm3_from_gain = A0 / (k_fs * gm1_req * rout1 * gm2_req * rout2 * rout3)
gm3_req = max(gm3_req, gm3_from_gain)
else:
# Two-stage: gain A0 = k_fs·gm1·Rout1·gm2·Rout2.
if spec.gain_min_db and rout1 < float("inf") and rout2 < float("inf"):
A0 = 10.0 ** (spec.gain_min_db / 20.0)
if gm1_req > 0.0 and rout1 * rout2 > 0:
gm2_from_gain = A0 / (k_fs * gm1_req * rout1 * rout2)
gm2_req = max(gm2_req, gm2_from_gain)
else:
# Split gain evenly; k_fs applies to the first stage only.
per_stage = math.sqrt(A0 / (k_fs * rout1 * rout2))
gm1_req = max(gm1_req, per_stage)
gm2_req = max(gm2_req, per_stage)
# From PM: gm2 = gm1·CL / (Cc·tan(90°−PM)).
if spec.phase_margin_min_deg and gm1_req > 0.0 and ip_devices:
if model.is_gmid:
# Procedural geometry snaps then evaluates PM from the actual
# geometry, so use gm1_req directly (no grid-ceiling inflation).
gm1_eff = gm1_req
else:
# Level-1 + CP-SAT: anticipate W rounding up to the grid by
# using the worst-case (ceiling) gm1 from the integer W grid.
ip_dev = ip_devices[0]
ip_params = tech.nmos if ip_dev.type == "nmos" else tech.pmos
ids_ip = spec.ibias / max(
len([d for d in ip_devices if d.type == ip_dev.type]), 1
)
lhs = 2.0 * ip_params.mu_cox * ids_ip
l_min = tech.length.min
w_step = tech.width.step
w_um = math.ceil(gm1_req ** 2 * l_min / (lhs * w_step)) * w_step
w_um = min(max(w_um, tech.width.min), tech.width.max)
gm1_eff = math.sqrt(lhs * w_um / l_min)
pm_rad = math.radians(spec.phase_margin_min_deg)
gm2_req = max(
gm2_req,
k_fs * gm1_eff * spec.cl / (cc_f * math.tan(math.pi / 2.0 - pm_rad)),
)
cc_pf = cc_f * 1e12
else:
# One-stage: gain = k_fs·gm1·Rout1
if spec.gain_min_db and rout1 < float("inf"):
A0 = 10.0 ** (spec.gain_min_db / 20.0)
gm1_req = max(gm1_req, A0 / (k_fs * rout1))
# --- Cap gm requirements at the physical (weak-inversion) ceiling ---
# The square-law model has no gm ceiling, so without this the sizer would size
# for a gm the device can only reach by sliding into weak inversion (where the
# real gm is far lower). Clamp each requirement to gm ≤ gm_ceiling(IDS); a
# binding clamp means the spec needs more bias current than the device can
# physically deliver, surfaced as a warning (the shortfall also shows in the
# reported margins).
def _ceil(ids: float, devs: list[Device]) -> float:
sig = next((d for d in devs if is_signal_device(d)), devs[0] if devs else None)
dtype = sig.type if sig else "nmos"
return model.gm_ceiling(dtype, ids, tech.length.min)
gm_ceiling_warnings: list[str] = []
gm1_ceil = _ceil(spec.ibias / 2.0, ip_devices)
if gm1_req > gm1_ceil:
gm1_req = gm1_ceil
gm_ceiling_warnings.append(
"input-pair gm requirement exceeds the weak-inversion ceiling at "
"ibias/2 — increase ibias or relax GBW/gain (the design will fall short).")
gm2_ceil = _ceil(spec.ibias * spec.second_stage_current_ratio, ss_devices)
if gm2_req > gm2_ceil:
gm2_req = gm2_ceil
gm_ceiling_warnings.append(
"second-stage gm requirement exceeds the weak-inversion ceiling — "
"increase second_stage_current_ratio/ibias or relax gain.")
gm3_ceil = _ceil(spec.ibias * spec.third_stage_current_ratio, ts_devices)
if gm3_req > gm3_ceil:
gm3_req = gm3_ceil
gm_ceiling_warnings.append(
"third-stage gm requirement exceeds the weak-inversion ceiling — "
"increase third_stage_current_ratio/ibias or relax gain.")
# --- Map requirements to individual transistors ---
gm_req_map: dict[str, float] = {}
vod_max_map: dict[str, float] = {}
for ref, (device, slot) in all_transistors.items():
if slot == "input_pair":
gm_req_map[ref] = gm1_req
elif slot in SECOND_STAGE_SLOTS:
# Only the signal transistor (gate driven by first-stage output)
# needs a gm requirement; the load transistor is a current source.
gm_req_map[ref] = gm2_req if is_signal_device(device) else 0.0
elif slot in THIRD_STAGE_SLOTS:
gm_req_map[ref] = gm3_req if is_signal_device(device) else 0.0
# All other slots: no explicit gm requirement (sized by min W/L)
# --- VDS_sat upper bounds from output swing specs ---
vdd = spec.vdd
vss = spec.vss
# All second- and third-stage device lists (constrain output swing on every path).
all_ss_device_lists = [
slot_transistors[s]
for s in (*SECOND_STAGE_SLOTS, *THIRD_STAGE_SLOTS)
if s in slot_transistors
]
if spec.output_swing_max_v is not None:
vds_sat_max = vdd - spec.output_swing_max_v
if vds_sat_max > 0.0:
# Load transistors constrain the high-side swing.
for d in ld_devices:
vod_max_map[d.ref] = vds_sat_max
# Second-stage PMOS (current-source load) constrains swing on every path.
for devs in all_ss_device_lists:
for d in devs:
if d.type == "pmos":
vod_max_map[d.ref] = min(
vod_max_map.get(d.ref, float("inf")), vds_sat_max
)
if spec.output_swing_min_v is not None:
vds_sat_max_low = spec.output_swing_min_v - vss
if vds_sat_max_low > 0.0:
# Second-stage NMOS constrains the low-side swing on every path.
for devs in all_ss_device_lists:
for d in devs:
if d.type == "nmos":
vod_max_map[d.ref] = min(
vod_max_map.get(d.ref, float("inf")), vds_sat_max_low
)
return gm_req_map, vod_max_map, cc_pf, cc2_pf, gm_ceiling_warnings