Source code for circuitgenome.sizer.gmid.blocks

"""Functional-block view of a recognised op-amp, for the gm/Id sizer.

Groups the FBR slot devices into typed blocks (input pair, first-stage load,
gain stages, tail, bias, compensation) and classifies the *kind* of each load /
tail (current-mirror, cascode, resistor, plain current-source).  This is the
structural layer beneath the Analyze phase (:mod:`.analyze`): the load kind
drives the first-stage gain factor, the cascode detection feeds the DC
headroom budget (:mod:`.bias`), and :func:`node_rout` gives the evaluation
phase its cascode-aware output resistance.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum

from circuitgenome.synthesizer.models import Device

from ..shared.taxonomy import (
    RAILS,
    SECOND_STAGE_SLOTS,
    THIRD_STAGE_SLOTS,
    is_signal_device,
)


[docs] class LoadKind(Enum): """How a stage load converts/combines current (sets gain & headroom).""" MIRROR = "mirror" # active current-mirror (diode-connected member) CASCODE = "cascode" # series-stacked devices RESISTOR = "resistor" # resistor load CURRENT_SOURCE = "current_source" # plain (non-mirror) current-source load NONE = "none"
def _has_diode(devs: list[Device]) -> bool: return any(d.type in ("nmos", "pmos") and d.terminals.get("g") == d.terminals.get("d") for d in devs) def _has_cascode(devs: list[Device]) -> bool: """True if any two same-type devices are series-stacked (source==drain).""" mos = [d for d in devs if d.type in ("nmos", "pmos")] drains = {d.terminals.get("d") for d in mos} return any(d.terminals.get("s") in drains for d in mos)
[docs] def cascode_device_refs(slot_transistors: dict[str, list[Device]]) -> set[str]: """Refs of cascode devices: a non-signal device whose *source* sits on another same-slot device's *drain* (i.e. it is stacked on a device below).""" out: set[str] = set() for devs in slot_transistors.values(): mos = [d for d in devs if d.type in ("nmos", "pmos")] drains = {d.terminals.get("d") for d in mos} for d in mos: src = d.terminals.get("s") if src not in RAILS and src in drains and not is_signal_device(d): out.add(d.ref) return out
# --------------------------------------------------------------------------- # # Cascode-aware output resistance # --------------------------------------------------------------------------- # def _looking_in_drain(device, by_drain, model, sizing, stop) -> float: """Resistance (Ω) looking into ``device``'s drain, cascode-aware. A cascode device (source on another device's drain) boosts its own ``ro`` by ``1 + gm·R_source`` where ``R_source`` is the resistance below it; a device whose source is a rail or in ``stop`` (e.g. the input-pair tail node, an AC ground for the differential half-circuit) contributes just ``ro``. Shallow recursion handles multi-high stacks. """ s = sizing.get(device.ref) if s is None: return float("inf") gds = model.gds(device.type, s.w_um, s.l_um, s.ids_a) ro = 1.0 / gds if gds > 0 else float("inf") src = device.terminals.get("s") if src in RAILS or src in stop or src is None: return ro below = by_drain.get(src) if below is not None and below.ref != device.ref and below.type == device.type: gm = model.gm(device.type, s.w_um, s.l_um, s.ids_a) r_src = _looking_in_drain(below, by_drain, model, sizing, stop) return ro * (1.0 + gm * r_src) if r_src != float("inf") else float("inf") return ro
[docs] def node_rout(out_net: str, mosfets: list[Device], model, sizing, stop: frozenset = frozenset()) -> float: """Cascode-aware output resistance (Ω) at ``out_net`` = parallel of every device whose drain is ``out_net`` (each looking-in, cascode-boosted). ``stop`` lists nets to treat as AC ground (typically the input-pair tail node) so the input pair contributes ``ro``, not a tail-degenerated cascode. """ by_drain: dict[str, Device] = {} for d in mosfets: if d.type in ("nmos", "pmos"): by_drain.setdefault(d.terminals.get("d"), d) g = 0.0 for d in mosfets: if d.type in ("nmos", "pmos") and d.terminals.get("d") == out_net: r = _looking_in_drain(d, by_drain, model, sizing, stop) if r > 0: g += 1.0 / r return 1.0 / g if g > 0 else float("inf")
[docs] def classify_load(mosfets: list[Device], resistors: list[Device]) -> LoadKind: """Classify a load slot by its devices.""" if _has_cascode(mosfets): return LoadKind.CASCODE if _has_diode(mosfets): return LoadKind.MIRROR if resistors: return LoadKind.RESISTOR if mosfets: return LoadKind.CURRENT_SOURCE return LoadKind.NONE
[docs] @dataclass class Block: """A functional group of devices from one or more FBR slots.""" name: str # "input_pair", "load", "second_stage", ... mosfets: list[Device] = field(default_factory=list) resistors: list[Device] = field(default_factory=list) load_kind: LoadKind = LoadKind.NONE @property def signal_device(self) -> Device | None: """The gm-contributing device (gate on a signal net), if any.""" return next((d for d in self.mosfets if is_signal_device(d)), None) @property def is_cascode(self) -> bool: return self.load_kind == LoadKind.CASCODE or _has_cascode(self.mosfets)
[docs] @dataclass class OpAmpBlocks: """The block decomposition of a recognised op-amp.""" blocks: dict[str, Block] is_fully_differential: bool n_stages: int @property def input_pair(self) -> Block | None: return self.blocks.get("input_pair") @property def load(self) -> Block | None: return self.blocks.get("load") @property def tail(self) -> Block | None: return self.blocks.get("tail_current")
[docs] def first_stage_gain_factor(self) -> float: """``k_fs``: 0.5 for a single-ended non-mirror first-stage load, else 1.0.""" if self.is_fully_differential: return 1.0 ld = self.load if ld and ld.load_kind in (LoadKind.MIRROR, LoadKind.CASCODE): return 1.0 return 0.5
[docs] def has_cascode_load(self) -> bool: ld = self.load return bool(ld and ld.load_kind == LoadKind.CASCODE)
[docs] def has_cascode_tail(self) -> bool: """True if the tail current source is a cascode (series-stacked) stack. The tail slot is never run through :func:`classify_load` (only the load slot is), so this relies on :attr:`Block.is_cascode`'s structural ``_has_cascode`` fallback rather than ``load_kind``. """ tail = self.tail return bool(tail and tail.is_cascode)
[docs] def first_stage_out_net(self) -> str | None: """First-stage output node = the next stage's signal-device gate. ``None`` for a one-stage opamp (no downstream stage to read it from). """ for slot in ("second_stage", "second_stage_p", "second_stage_n", "third_stage", "third_stage_p", "third_stage_n"): b = self.blocks.get(slot) sig = b.signal_device if b else None if sig is not None: return sig.terminals.get("g") return None
[docs] def tail_net(self) -> str | None: """The input-pair source (tail) node.""" ip = self.input_pair return ip.mosfets[0].terminals.get("s") if ip and ip.mosfets else None
[docs] def build_blocks(slot_transistors: dict[str, list[Device]], slot_resistors: dict[str, list[Device]]) -> OpAmpBlocks: """Build the :class:`OpAmpBlocks` view from the FBR slot device lists.""" blocks: dict[str, Block] = {} for slot, mosfets in slot_transistors.items(): rs = slot_resistors.get(slot, []) kind = classify_load(mosfets, rs) if slot in ("load",) else LoadKind.NONE blocks[slot] = Block(name=slot, mosfets=list(mosfets), resistors=list(rs), load_kind=kind) # Resistor-only slots (e.g. a resistor load with no mosfets) still need a block. for slot, rs in slot_resistors.items(): if slot not in blocks: blocks[slot] = Block(name=slot, resistors=list(rs), load_kind=classify_load([], rs) if slot == "load" else LoadKind.NONE) fd = any(s in slot_transistors for s in ("second_stage_p", "second_stage_n", "third_stage_p", "third_stage_n")) has_2 = any(s in slot_transistors for s in SECOND_STAGE_SLOTS) has_3 = any(s in slot_transistors for s in THIRD_STAGE_SLOTS) n_stages = 1 + (1 if has_2 else 0) + (1 if has_3 else 0) return OpAmpBlocks(blocks=blocks, is_fully_differential=fd, n_stages=n_stages)