Source code for circuitgenome.sizer.shared.device_model

"""Small-signal device-model abstraction shared by both sizing paths.

The op-amp *topology* relationships (gain = ∏ gmᵢ·routᵢ, GBW = gm1/2πCc, phase
margin, CMRR, …) are model-independent — only the device *primitives* differ:

* the **Level-1** square law (``equations.gm``/``gd``/``vds_sat``), used for the
  card-less generic tech, and
* the **gm/Id** lookup table (:class:`~.gmid_lut.GmIdLut`), used for PTM nodes.

Both implement :class:`DeviceModel` so ``compute_requirements`` and
``_evaluate_metrics`` in :mod:`~circuitgenome.sizer.sizer` can stay single-source.
``Level1Model`` reproduces the existing numbers exactly (regression-safe);
``GmIdModel`` adds the LUT-backed primitives plus the geometry inversion and
L-selection policy used by the procedural geometry pass.

Two roles drive the gm/Id policy:

* ``"signal"`` — a gain/transconductance device (input pair, gain-stage signal
  transistor): its gm/Id is set by the required gm; L favours a balance of gain
  and ft.
* ``"current_source"`` — a bias/load device (tail, active load, bias gen): no gm
  target; sized at a nominal gm/Id and a longer L for high output resistance.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

from . import equations as eq
from .gmid_lut import GmIdLut
from .models import MosfetParams, TechParams

SIGNAL = "signal"
CURRENT_SOURCE = "current_source"
CASCODE = "cascode"


[docs] class DeviceModel(Protocol): """Small-signal primitives consumed by requirement-derivation and metrics. All geometry arguments are in µm and currents in A; ``dtype`` is ``"nmos"`` or ``"pmos"``. :class:`Level1Model` implements these from the square law and :class:`GmIdModel` from the LUT, so the topology math in :mod:`~circuitgenome.sizer.sizer` can call through one interface. """ is_gmid: bool
[docs] def gm(self, dtype: str, w_um: float, l_um: float, ids: float) -> float: """Transconductance gm in A/V at the device's operating point.""" ...
[docs] def gds(self, dtype: str, w_um: float, l_um: float, ids: float) -> float: """Output conductance gds in A/V at the device's operating point.""" ...
[docs] def vds_sat(self, dtype: str, w_um: float, l_um: float, ids: float) -> float: """Minimum \\|VDS\\| for saturation in V.""" ...
[docs] def vgs(self, dtype: str, w_um: float, l_um: float, ids: float) -> float: """Gate-source voltage in V (signed: +ve NMOS, −ve PMOS).""" ...
[docs] def gm_ceiling(self, dtype: str, ids: float, l_um: float) -> float: """Physical upper bound on gm in A/V (weak-inversion limit).""" ...
[docs] def gds_estimate(self, dtype: str, ids: float, role: str) -> float: """Geometry-free gds in A/V from the device's ``role``. Used during requirement derivation, before geometry is chosen, where Level-1 returns ``λ·Id`` and gm/Id uses the role's nominal operating point (see :data:`SIGNAL` / :data:`CURRENT_SOURCE`). """ ...
def _params(tech: TechParams, dtype: str) -> MosfetParams: """Return the :class:`~.models.MosfetParams` for ``dtype`` (nmos/pmos).""" return tech.nmos if dtype == "nmos" else tech.pmos # --------------------------------------------------------------------------- # # Level-1 (square law) — wraps equations.* with identical numerics # --------------------------------------------------------------------------- #
[docs] class Level1Model: """Shichman-Hodges primitives — byte-for-byte the current generic behaviour.""" is_gmid = False def __init__(self, tech: TechParams): """Bind the technology whose ``µCox``/``vth``/``λ`` drive the square law.""" self.tech = tech
[docs] def gm(self, dtype, w_um, l_um, ids): """Square-law gm ``√(2·µCox·(W/L)·|Id|)`` (:func:`~.equations.gm`).""" return eq.gm(_params(self.tech, dtype).mu_cox, w_um, l_um, ids)
[docs] def gds(self, dtype, w_um, l_um, ids): """Level-1 gds ``λ·|Id|`` — geometry-independent (W/L ignored).""" return eq.gd(_params(self.tech, dtype).lam, ids)
[docs] def vds_sat(self, dtype, w_um, l_um, ids): """Square-law saturation overdrive (:func:`~.equations.vds_sat`).""" return eq.vds_sat(_params(self.tech, dtype).mu_cox, w_um, l_um, ids)
[docs] def vgs(self, dtype, w_um, l_um, ids): """Square-law gate-source voltage (:func:`~.equations.vgs_from_ids`).""" p = _params(self.tech, dtype) return eq.vgs_from_ids(p.mu_cox, w_um, l_um, ids, p.vth)
[docs] def gm_ceiling(self, dtype, ids, l_um): """Weak-inversion gm ceiling ``25·|Id|`` (dtype/L-independent).""" return eq.gm_ceiling(ids)
[docs] def gds_estimate(self, dtype, ids, role): """Geometry-free gds ``λ·|Id|`` — ``role`` is irrelevant under the square law.""" return eq.gd(_params(self.tech, dtype).lam, ids)
# --------------------------------------------------------------------------- # # gm/Id (lookup table) # --------------------------------------------------------------------------- #
[docs] @dataclass class GmIdPolicy: """L / gm-Id selection policy for the procedural gm/Id sizer. Defaults are starting points tuned during SPICE validation. At L_min the 45 nm intrinsic gain gm/gds is only ~7, so gain-critical (``signal``) devices use a moderate L multiple; current sources use a longer L for output resistance and a low gm/Id for headroom. """ signal_l_mult: float = 2.0 # signal-device L as a multiple of L_min cs_l_mult: float = 4.0 # current-source L as a multiple of L_min cs_gmid: float = 10.0 # nominal current-source gm/Id (1/V) signal_nominal_gmid: float = 14.0 # gm/Id used for pre-geometry gds estimate cascode_l_mult: float = 3.0 # cascode-device L as a multiple of L_min cascode_gmid: float = 8.0 # cascode gm/Id (strong inversion → small Vdsat)
[docs] @dataclass class GeomResult: """Geometry computed for one device by :meth:`GmIdModel.geometry_for`. :param w_um: Gate width in µm. :param l_um: Gate length in µm (from the L-policy, grid-snapped). :param gm_id: Operating transconductance efficiency gm/Id in 1/V. :param gm_id_capped: ``True`` when the gm/Id target was clamped to the table's weak-inversion ceiling (the design will fall short of its gm). """ w_um: float l_um: float gm_id: float gm_id_capped: bool
[docs] class GmIdModel: """LUT-backed primitives + geometry inversion and L-policy.""" is_gmid = True def __init__(self, tech: TechParams, lut: GmIdLut, policy: GmIdPolicy | None = None): """Bind a tech, its gm/Id ``lut``, and an L-policy (default :class:`GmIdPolicy`).""" self.tech = tech self.lut = lut self.policy = policy or GmIdPolicy() # -- geometry / grid helpers ------------------------------------------- def _snap_l(self, l_um: float) -> float: """Snap a length to the tech length grid, clamped to its bounds.""" g = self.tech.length v = round(l_um / g.step) * g.step return float(min(max(v, g.min), g.max))
[docs] def role_length(self, role: str) -> float: """Channel length for ``role`` from the L-policy multiplier (µm, snapped).""" mult = {CURRENT_SOURCE: self.policy.cs_l_mult, CASCODE: self.policy.cascode_l_mult}.get(role, self.policy.signal_l_mult) return self.length_for(mult)
[docs] def length_for(self, l_mult: float) -> float: """Channel length for an explicit L multiple of ``length.min`` (µm, snapped).""" return self._snap_l(l_mult * self.tech.length.min)
def _role_gm_id(self, role: str) -> float: """Nominal gm/Id for a non-signal role (current source / cascode).""" return (self.policy.cascode_gmid if role == CASCODE else self.policy.cs_gmid) def _gm_id_at(self, dtype: str, w_um: float, l_um: float, ids: float) -> float: """Recover the operating gm/Id from a solved (W, L, Id) via the LUT inverse.""" if w_um <= 0: return self.policy.cs_gmid return self.lut.gm_id_from_idw(dtype, abs(ids) / w_um, l_um) # -- DeviceModel primitives (geometry known) ---------------------------
[docs] def gm(self, dtype, w_um, l_um, ids): """Transconductance ``(gm/Id)·|Id|`` at the geometry's operating point.""" return self._gm_id_at(dtype, w_um, l_um, ids) * abs(ids)
[docs] def gds(self, dtype, w_um, l_um, ids): """Output conductance ``gm/(gm/gds)`` read from the LUT at the operating gm/Id.""" gm_id = self._gm_id_at(dtype, w_um, l_um, ids) gm = gm_id * abs(ids) return gm / self.lut.gm_gds(dtype, gm_id, l_um)
[docs] def vds_sat(self, dtype, w_um, l_um, ids): """Saturation overdrive ``VDS,sat`` read from the LUT (V).""" return self.lut.vdsat(dtype, self._gm_id_at(dtype, w_um, l_um, ids), l_um)
[docs] def vgs(self, dtype, w_um, l_um, ids): """Gate-source voltage from the LUT, signed +ve NMOS / −ve PMOS.""" mag = self.lut.vgs(dtype, self._gm_id_at(dtype, w_um, l_um, ids), l_um) vth = _params(self.tech, dtype).vth return mag if vth >= 0 else -mag
[docs] def gm_ceiling(self, dtype, ids, l_um): """Weak-inversion gm ceiling ``max_gm_id·|Id|`` from the table.""" return self.lut.max_gm_id(dtype, l_um) * abs(ids)
[docs] def gds_estimate(self, dtype, ids, role): """Geometry-free gds from the role's nominal operating point.""" l_um = self.role_length(role) gm_id = (self.policy.signal_nominal_gmid if role == SIGNAL else self._role_gm_id(role)) gm = gm_id * abs(ids) return gm / self.lut.gm_gds(dtype, gm_id, l_um)
# -- geometry inversion (procedural sizer) -----------------------------
[docs] def geometry_for( self, dtype: str, ids: float, role: str, gm_target: float | None = None, *, gm_id: float | None = None, l_um: float | None = None, gm_id_min: float | None = None, ) -> GeomResult: """Compute (W, L) for a device from its role and (optional) gm target. ``current_source``/``cascode`` devices use the policy gm/Id (cascodes at a smaller-Vdsat region); ``signal`` devices set gm/Id = gm_target/Id, clamped to the table's weak-inversion ceiling. ``gm_id`` and ``l_um`` are per-device design-intent overrides (from the block registry): an explicit gm/Id region for a non-signal device and an explicit channel length. When ``None`` they fall back to the role policy, so the default behaviour is unchanged. ``gm_id`` is ignored for a signal device with a gm target — its gm/Id is always solved from the spec. ``gm_id_min`` floors the operating gm/Id regardless of role — used to cap VDS_sat for output-swing (weaker inversion = lower Vdsat). Raising a solved signal gm/Id is spec-safe: gm = (gm/Id)·Id only grows. """ l_um = l_um if l_um is not None else self.role_length(role) capped = False if role != SIGNAL or not gm_target or ids <= 0: gm_id = gm_id if gm_id is not None else self._role_gm_id(role) else: gm_id = gm_target / abs(ids) ceiling = self.lut.max_gm_id(dtype, l_um) if gm_id > ceiling: gm_id, capped = ceiling, True gm_id = max(gm_id, float(self.lut.gm_id_axis[0])) if gm_id_min is not None: gm_id = max(gm_id, gm_id_min) idw = self.lut.id_per_w(dtype, gm_id, l_um) w_um = abs(ids) / idw if idw > 0 else self.tech.width.max return GeomResult(w_um=w_um, l_um=l_um, gm_id=gm_id, gm_id_capped=capped)
[docs] def build_device_model(tech: TechParams) -> DeviceModel: """Select the gm/Id model when the tech carries a LUT, else Level-1.""" if getattr(tech, "gmid_lut", None): return GmIdModel(tech, GmIdLut(tech.gmid_lut)) return Level1Model(tech)