Source code for circuitgenome.sizer.gmid.geometry

"""Procedural geometry assignment for the gm/Id sizing path.

Replaces CP-SAT for PTM nodes: geometry is *computed*, not searched.  With ``Id``
fixed by KCL and ``gm/Id = gm_req/Id`` fixed, the LUT gives ``Id/W → W``
directly, so this is a deterministic forward pass:

1. per-device ``(W, L)`` from :meth:`GmIdModel.geometry_for`;
2. snap ``W`` to the width grid (``L`` is already grid-aligned by the L-policy);
3. **symmetry** — matched pairs get the anchor's geometry (plain assignment);
4. **mirror ratios** — each output's ``W`` is the *exact* current ratio times the
   diode-connected reference's ``W`` at matched ``L`` (no ``Fraction`` rounding);
5. **load margin** — a single-ended plain current-source load balancing a
   mirrored tail gets ``_LOAD_CS_MARGIN`` extra width (see its docstring).

Symmetry is applied before mirror ratios; on the rare device that is both, the
mirror constraint wins (it is the bias-current-correctness fix).  Both rules are
self-consistent for matched pairs, so the order only matters for resolution, not
for the result.
"""
from __future__ import annotations

from typing import TYPE_CHECKING

from ..shared.device_model import CASCODE, SIGNAL, GmIdModel
from ..shared.models import TechParams, TransistorSizing
from ..shared.taxonomy import STAGE_SLOTS
from .blocks import LoadKind, classify_load

if TYPE_CHECKING:
    from .intent import TransistorIntent

# Same matched-pair slots CP-SAT treats as symmetric (constraints.build_model).
_SYMMETRY_SLOTS = frozenset({"input_pair", "load", "tail_current"})
# FD cross-slot matched pairs (second_stage_p ↔ _n, third_stage_p ↔ _n).
_FD_PAIRS = (("second_stage_p", "second_stage_n"),
             ("third_stage_p", "third_stage_n"))

#: Deliberate strength margin for a plain current-source load that balances a
#: mirrored tail current.  A single-ended first stage with a non-mirror
#: current-source load has no feedback fixing the load-vs-tail current balance
#: (no diode in the signal path, no CMFB): at an exact mirror ratio the fold
#: node drifts until the input pair triodes or the output rails.  Sizing the
#: load mirror slightly strong settles the node toward the load's supply rail,
#: keeping the input pair saturated with the load at the edge of saturation.
_LOAD_CS_MARGIN = 1.05

#: Fraction of the swing-derived Vdsat budget (``vod_max_map``) the plan
#: targets for output-path devices.  The swing bench reads the tracking
#: region out to small-signal slope ≥ 0.7, which cuts off noticeably before
#: the Vdsat point — a device sized exactly at the budget measures ~50 mV
#: short of the spec (issue #126).  Half the budget is the validated margin;
#: devices whose solved/policy Vdsat already fits are untouched.
_SWING_VDSAT_FRACTION = 0.5


[docs] def swing_gm_id_floor( model: GmIdModel, dtype: str, l_um: float, vod_max: float, ) -> tuple[float, bool]: """gm/Id floor so ``vds_sat ≤ _SWING_VDSAT_FRACTION · vod_max``. Returns ``(floor, fits_raw_budget)``. ``floor`` is the smallest LUT gm/Id meeting the margin target (weaker inversion = lower Vdsat), or the table's weakest-inversion point as best effort when no point reaches the target. ``fits_raw_budget`` is ``False`` when even the weakest inversion cannot fit the *raw* budget — the output stage cannot meet the swing spec at all. """ axis = [float(g) for g in model.lut.gm_id_axis] target = vod_max * _SWING_VDSAT_FRACTION for g in axis: if model.lut.vdsat(dtype, g, l_um) <= target: return g, True weakest = axis[-1] return weakest, model.lut.vdsat(dtype, weakest, l_um) <= vod_max
def _output_stage_slots(slot_transistors: dict[str, list]) -> frozenset[str]: """Slots whose devices sit on the amplifier output node. ``compute_requirements`` puts swing bounds on second/third-stage devices *and* on the first-stage load; for a multi-stage circuit only the last stage touches the output, so the load's entry must not re-bias the first stage here. One-stage circuits keep the load as the output path. """ if any(s in slot_transistors for s in STAGE_SLOTS): return STAGE_SLOTS return frozenset({"load"}) def _mirror_tied_refs(all_transistors: dict[str, tuple]) -> set[str]: """Refs whose W is later overwritten by :func:`_apply_mirror_ratios`. A per-device gm/Id floor cannot stick on a mirror *output* — its geometry tracks the diode reference's inversion level — so such devices are only checked against the raw budget after the mirror pass. """ groups: dict[tuple, list[str]] = {} diodes: dict[tuple, str] = {} for ref, (device, _slot) in all_transistors.items(): gate = device.terminals.get("g") if not gate: continue key = (gate, device.type) groups.setdefault(key, []).append(ref) if device.terminals.get("d") == gate: diodes.setdefault(key, ref) tied: set[str] = set() for key, members in groups.items(): if len(members) >= 2 and key in diodes: tied.update(m for m in members if m != diodes[key]) return tied def _apply_symmetry( W: dict[str, float], L: dict[str, float], slot_transistors: dict[str, list], ids_map: dict[str, float], ) -> None: """Matched pairs share the anchor device's geometry (plain assignment). Devices match only at the same planned IDS — a folded-cascode load's folding sinks (pair + cascode current) and its cascode devices (cascode current only) are distinct groups. """ def equalize(refs: list[str]) -> None: anchor = refs[0] for r in refs[1:]: W[r], L[r] = W[anchor], L[anchor] for slot, devices in slot_transistors.items(): if slot not in _SYMMETRY_SLOTS: continue groups: dict[tuple, list[str]] = {} for d in devices: if d.type in ("nmos", "pmos") and d.ref in W: groups.setdefault((d.type, ids_map.get(d.ref)), []).append(d.ref) for grp in groups.values(): equalize(grp) for sp, sn in _FD_PAIRS: p_devs, n_devs = slot_transistors.get(sp, []), slot_transistors.get(sn, []) if not (p_devs and n_devs): continue for dtype in ("nmos", "pmos"): grp = [d.ref for d in (*p_devs, *n_devs) if d.type == dtype and d.ref in W] if grp: equalize(grp) def _apply_mirror_ratios( W: dict[str, float], L: dict[str, float], all_transistors: dict[str, tuple], ids_map: dict[str, float], snap_w, ) -> None: """Current-mirror outputs track the reference W by the exact current ratio. MOSFETs are grouped by (gate-net, type); a diode-connected member (g == d) is the reference. Output W is the current ratio times the reference's W at matched L (no ``Fraction`` rounding). """ groups: dict[tuple, list[str]] = {} for ref, (device, _slot) in all_transistors.items(): gate = device.terminals.get("g") if gate: groups.setdefault((gate, device.type), []).append(ref) for members in groups.values(): if len(members) < 2: continue diodes = [m for m in members if all_transistors[m][0].terminals.get("g") == all_transistors[m][0].terminals.get("d")] if not diodes: continue ref0 = diodes[0] i_ref = ids_map.get(ref0, 0.0) if i_ref <= 0: continue for m in members: if m == ref0: continue i_m = ids_map.get(m, 0.0) if i_m <= 0: continue L[m] = L[ref0] W[m] = snap_w((i_m / i_ref) * W[ref0]) def _apply_load_current_margin( W: dict[str, float], slot_transistors: dict[str, list], snap_w ) -> None: """Give a knife-edge current-source load ``_LOAD_CS_MARGIN`` extra width. Applies only to the single-ended, no-CMFB case: a ``load`` slot classified :attr:`~.blocks.LoadKind.CURRENT_SOURCE` balancing a MOSFET tail's fixed current. This is an explicit design-intent margin — the old uncascoded pref branch's ~4% current surplus provided it by accident (issue #103). """ fd = any(s in slot_transistors for s in ("second_stage_p", "second_stage_n", "third_stage_p", "third_stage_n")) load = [d for d in slot_transistors.get("load", []) if d.type in ("nmos", "pmos")] tail = [d for d in slot_transistors.get("tail_current", []) if d.type in ("nmos", "pmos")] if fd or not tail or classify_load(load, []) is not LoadKind.CURRENT_SOURCE: return for d in load: if d.ref in W: W[d.ref] = snap_w(W[d.ref] * _LOAD_CS_MARGIN)
[docs] def assign_geometry_gmid( model: GmIdModel, all_transistors: dict[str, tuple], # ref → (Device, slot_name) slot_transistors: dict[str, list], # slot_name → [Device, ...] ids_map: dict[str, float], # ref → IDS in A intents: dict[str, "TransistorIntent"], # ref → resolved per-device design intent gm_target_map: dict[str, float], # ref → required gm in A/V (signal devices) tech: TechParams, vod_max_map: dict[str, float] | None = None, # ref → max VDS_sat in V (swing) ) -> tuple[dict[str, TransistorSizing], list[str], bool]: """Return ``({ref: TransistorSizing}, warnings, feasible)`` for the gm/Id path. Geometry follows each device's :class:`~.intent.TransistorIntent`: its role, its (per-block) gm/Id region and channel length. Signal devices ignore the intent's gm/Id and solve it from ``gm_target_map``. ``vod_max_map`` carries the output-swing Vdsat budgets: output-path devices get a gm/Id floor targeting ``_SWING_VDSAT_FRACTION`` of their budget. Excluded from the floor (but still checked against the raw budget): * **mirror outputs** — their inversion tracks the diode reference; * **signal devices under a cascode first-stage load** — their ``V_GS`` is the stage-interface pin (:mod:`.stage_interface`), and moving it for swing breaks the knife-edge saturation window (measured: 18 accepted folded-cascode + common_source_nmos candidates lost to a blanket floor). Those candidates' high loop gain keeps the swing bench tracking well past the Vdsat point, so the floor is not needed there. ``feasible`` is ``False`` when an output-path device cannot fit its raw budget even at the weakest inversion. """ g = tech.width warnings: list[str] = [] feasible = True vod_max_map = vod_max_map or {} out_slots = _output_stage_slots(slot_transistors) mirror_tied = _mirror_tied_refs(all_transistors) cascode_load = any( intents[d.ref].role == CASCODE for d in slot_transistors.get("load", []) if d.ref in intents) def snap_w(w_um: float) -> float: v = round(w_um / g.step) * g.step return float(min(max(v, g.min), g.max)) # --- 1+2: per-device geometry from the LUT (block intent → gm/Id, L), W snapped --- W: dict[str, float] = {} L: dict[str, float] = {} for ref, (device, slot) in all_transistors.items(): ti = intents[ref] gm_id_min = None vod = vod_max_map.get(ref) if (vod is not None and slot in out_slots and ref not in mirror_tied and not (cascode_load and ti.role == SIGNAL)): gm_id_min, fits = swing_gm_id_floor( model, device.type, model.length_for(ti.l_mult), vod) if not fits: feasible = False warnings.append( f"{ref}: output stage cannot meet the swing spec — even " f"weak-inversion VDS_sat exceeds the {vod:.2f} V budget " f"(rail-to-spec headroom); relax the swing spec or raise " f"the supply.") geo = model.geometry_for( device.type, ids_map[ref], ti.role, gm_target_map.get(ref), gm_id=ti.gm_id, l_um=model.length_for(ti.l_mult), gm_id_min=gm_id_min, ) W[ref] = snap_w(geo.w_um) L[ref] = geo.l_um if geo.gm_id_capped: warnings.append( f"{ref}: required gm/Id exceeds the weak-inversion ceiling at its " f"bias current — increase the stage current or relax GBW/gain " f"(the design will fall short).") # --- 3: symmetry (matched pairs share the anchor's geometry) --- _apply_symmetry(W, L, slot_transistors, ids_map) # --- 4: current-mirror ratios (exact, no Fraction approximation) --- _apply_mirror_ratios(W, L, all_transistors, ids_map, snap_w) # --- 5: deliberate margin for a knife-edge current-source load --- _apply_load_current_margin(W, slot_transistors, snap_w) # --- 6: build TransistorSizing with final geometry --- sizing: dict[str, TransistorSizing] = {} for ref, (device, _slot) in all_transistors.items(): ids = ids_map[ref] sizing[ref] = TransistorSizing( ref=ref, w_um=W[ref], l_um=L[ref], ids_a=ids, vgs_v=model.vgs(device.type, W[ref], L[ref], ids), vds_sat_v=model.vds_sat(device.type, W[ref], L[ref], ids), ) # --- 7: raw swing budget on mirror-tied output devices (post-mirror W) --- for ref, (_device, slot) in all_transistors.items(): vod = vod_max_map.get(ref) if (vod is not None and slot in out_slots and ref in mirror_tied and sizing[ref].vds_sat_v > vod): feasible = False warnings.append( f"{ref}: output stage cannot meet the swing spec — its mirror " f"group's VDS_sat {sizing[ref].vds_sat_v:.2f} V exceeds the " f"{vod:.2f} V budget (rail-to-spec headroom).") return sizing, warnings, feasible