Source code for circuitgenome.sizer.gmid.resistors

"""Size the non-load resistor blocks of a gm/Id-sized op-amp.

The Level-1 sizer (and gm/Id phase 1) only sized rail-referenced ``load``
resistors; `resistor_bias`, `resistor_tail`, source-**degeneration** r1/r2, and
the **compensation** nulling/series resistors kept the 1 kΩ netlist placeholder,
mis-biasing those variants. This module sizes each per its role and reports the
two metric effects (degeneration on gm1, resistor-tail output conductance on
CMRR).

Sized values flow out through ``SizingResult.resistors`` and
``spice_sim._inject_sizes`` replaces the placeholder, exactly like the load
resistors.
"""
from __future__ import annotations

from dataclasses import dataclass

from circuitgenome.synthesizer.models import Device

from ..shared.models import SizingSpec, TechParams, TransistorSizing
from ..shared.taxonomy import RAILS, is_signal_device
from .blocks import OpAmpBlocks
from .intent import GmIdIntent


# Saturation margin (V) added per device when a cascode-rail walk budgets the
# stack's Vdsat sum.  Placing a node exactly at the planned saturation edge is
# a knife edge: the LUT's Vgs/Vdsat are characterized at Vds=Vdd/2, so the
# realized operating point lands a few tens of mV off and the SPICE bias check
# reads the device as triode.
_CASCODE_SAT_MARGIN_V = 0.1


[docs] @dataclass(frozen=True) class MetricModifiers: """How the sized resistor network alters the metric evaluation (Phase 5). :param gm1_factor: multiplies the input-pair gm for source degeneration (``1/(1+gm1·R)`` = ``1/(1+factor)``); ``1.0`` when none. :param gd_tail_override: ``1/R`` of a resistor tail (finite output conductance for CMRR), or ``None``. :param gd_out_extra: output-node conductance added by a resistive-sense CMFB averager (``1/R_sense``), loading the FD output; ``0.0`` when none. """ gm1_factor: float = 1.0 gd_tail_override: float | None = None gd_out_extra: float = 0.0
[docs] def size_resistors( blocks: OpAmpBlocks, slot_resistors: dict[str, list], ids_map: dict[str, float], sizing: dict[str, TransistorSizing], model, spec: SizingSpec, tech: TechParams, intent: GmIdIntent, cc_pf: float | None = None, cc2_pf: float | None = None, ) -> tuple[dict[str, float], MetricModifiers]: """Return ``(extra_resistors, metric_modifiers)``. ``extra_resistors`` is ``{ref: ohms}`` for the degeneration / tail / bias / cmfb / compensation resistors; the :class:`MetricModifiers` carry their metric effects into the evaluation phase. ``cc_pf``/``cc2_pf`` are the planned compensation cap(s) the compensation-slot resistors pair with. """ out: dict[str, float] = {} gm1_factor = 1.0 gd_tail_override: float | None = None gd_out_extra = 0.0 vcm = (spec.vdd + spec.vss) / 2.0 # --- Source degeneration (input-pair-slot resistors) --- ip = blocks.input_pair ip_res = slot_resistors.get("input_pair", []) if ip_res and intent.degeneration_factor > 0 and ip and ip.mosfets: d = ip.mosfets[0] s = sizing.get(d.ref) if s: gm1 = model.gm(d.type, s.w_um, s.l_um, s.ids_a) if gm1 > 0: r_val = intent.degeneration_factor / gm1 for r in ip_res: out[r.ref] = r_val gm1_factor = 1.0 / (1.0 + intent.degeneration_factor) # --- Resistor tail (tail_current-slot resistors): set the tail current --- tail_res = slot_resistors.get("tail_current", []) if tail_res and ip and ip.mosfets: ip_dev = ip.mosfets[0] s = sizing.get(ip_dev.ref) vgs = abs(model.vgs(ip_dev.type, s.w_um, s.l_um, s.ids_a)) if s else 0.0 if ip_dev.type == "pmos": # source toward vdd v_tail, vrail = vcm + vgs, spec.vdd else: # source toward vss v_tail, vrail = vcm - vgs, spec.vss i_tail = spec.ibias r_val = abs(vrail - v_tail) / i_tail if i_tail > 0 else 0.0 for r in tail_res: out[r.ref] = r_val if r_val > 0: gd_tail_override = 1.0 / r_val # --- Tunable bias legs (bias_gen-slot resistors): out_i = ibias·R_i --- # Each leg's rail level is derived from what actually consumes the rail # (issue #100): a cascode consumer needs Vgs above its stack's saturation # floor, a supply-referenced gate needs one Vgs from its supply. Rails # whose consumers give no derivable level keep the old representative # value. bias_res = slot_resistors.get("bias_gen", []) if bias_res and spec.ibias > 0: consumers = [d for name, b in blocks.blocks.items() if name != "bias_gen" for d in b.mosfets] v_fallback = _representative_bias_vgs(blocks, sizing) or 0.5 * (spec.vdd - spec.vss) for r in bias_res: rail = next((t for t in (r.terminals.get("t1"), r.terminals.get("t2")) if t and t not in RAILS), None) v_abs = (_bias_rail_target_v(rail, consumers, sizing, spec) if rail else None) v_gate = v_abs - spec.vss if v_abs is not None and v_abs > spec.vss \ else v_fallback out[r.ref] = v_gate / spec.ibias # --- CMFB resistive-sense averager (cmfb-slot resistors): large, low-load --- cmfb_res = slot_resistors.get("cmfb", []) if cmfb_res: for r in cmfb_res: out[r.ref] = intent.cmfb_sense_r # Each output node is loaded by one sense resistor to the (virtual-ground) # sense node → adds 1/R_sense to the differential output conductance. gd_out_extra = 1.0 / intent.cmfb_sense_r if intent.cmfb_sense_r > 0 else 0.0 # --- Compensation-slot resistors (nulling / indirect series R) --- # The synthesizer emits every resistor at a 1 kΩ placeholder; the series # R of `miller_cap_with_nulling_resistor` / `indirect_compensation` must # instead track the stage it bridges: R = (Cc+CL)/(gm_out·Cc) places the # compensation zero on the output pole (issue #108). comp_slots = [(name, rs) for name, rs in slot_resistors.items() if name.startswith("comp")] if comp_slots and cc_pf: gm_out = _output_stage_gm(blocks, sizing, model) if gm_out > 0: for name, rs in comp_slots: slot_cc_f = (cc2_pf if "comp2" in name and cc2_pf else cc_pf) * 1e-12 r_val = (slot_cc_f + spec.cl) / (gm_out * slot_cc_f) for r in rs: out[r.ref] = r_val return out, MetricModifiers(gm1_factor=gm1_factor, gd_tail_override=gd_tail_override, gd_out_extra=gd_out_extra)
def _output_stage_gm(blocks: OpAmpBlocks, sizing: dict[str, TransistorSizing], model) -> float: """Realized gm of the output gain stage's signal device (0.0 if unknown). The compensation network wraps the last gain stage, so its resistor is sized against that stage's gm — third stage when present, else second. """ for name in ("third_stage", "third_stage_p", "third_stage_n", "second_stage", "second_stage_p", "second_stage_n"): b = blocks.blocks.get(name) for d in (b.mosfets if b else []): s = sizing.get(d.ref) if s and is_signal_device(d): g = model.gm(d.type, s.w_um, s.l_um, s.ids_a) if g > 0: return g return 0.0 def _bias_rail_target_v(rail_net: str, consumers: list[Device], sizing: dict[str, TransistorSizing], spec: SizingSpec) -> float | None: """Absolute voltage the consumers of *rail_net* need it to sit at. Per consumer MOSFET gated by the rail (diode-connected consumers are a current interface, not a voltage demand — skipped): - source on a supply: one planned ``|Vgs|`` from that supply; - source on an internal node (cascode gates): the stack's saturation floor/ceiling (:func:`_stack_node_v`) plus the consumer's ``|Vgs|``. Returns the mean over the derivable consumers (a shared rail with conflicting demands gets the compromise a single resistor can offer), or ``None`` when no consumer yields a level. """ targets: list[float] = [] for dev in consumers: if dev.terminals.get("g") != rail_net or dev.terminals.get("d") == rail_net: continue s = sizing.get(dev.ref) src = dev.terminals.get("s") if not (s and s.vgs_v and src): continue sign = 1.0 if dev.type == "nmos" else -1.0 if src in RAILS: base = spec.vdd if src == "vdd!" else spec.vss else: base = _stack_node_v(src, dev, consumers, sizing, spec) if base is None: continue targets.append(base + sign * abs(s.vgs_v)) return sum(targets) / len(targets) if targets else None def _stack_node_v(node: str, consumer: Device, mosfets: list[Device], sizing: dict[str, TransistorSizing], spec: SizingSpec) -> float | None: """Saturation floor (NMOS) / ceiling (PMOS) of internal *node*. Walks the same-type stack from *node* toward the consumer's back rail, summing each device's planned ``Vdsat`` plus a saturation margin (:data:`_CASCODE_SAT_MARGIN_V`) so everything below (NMOS) / above (PMOS) the cascode stays saturated with slack. A signal device in the stack (telescopic loads: the cascode sits on the input pair) anchors the walk a margin inside its own saturation edge with the gate at the input common mode, ``Vcm ∓ (|Vgs| - |Vdsat| - margin)``. Returns ``None`` when the stack cannot be traced (no device below, sizing missing, or wrong terminating rail). """ sign = 1.0 if consumer.type == "nmos" else -1.0 vcm = (spec.vdd + spec.vss) / 2.0 acc = 0.0 seen: set[str] = set() while node not in RAILS: if node in seen: return None seen.add(node) dev = next((d for d in mosfets if d.type == consumer.type and d.ref != consumer.ref and d.terminals.get("d") == node), None) s = sizing.get(dev.ref) if dev else None if not (dev and s and s.vgs_v and s.vds_sat_v): return None # The Vcm anchor is for an input-pair device riding the tail node; it # only applies when the signal device's source is an internal node. # A current-mirror's bottom device is gated by its self-biased mirror # node (also a non-bias net, so is_signal_device misfires) but sources # straight to a supply -- the wide-swing telescopic mirror (issue #129): # fall through and terminate at that rail with its Vdsat floor. if is_signal_device(dev) and dev.terminals.get("s") not in RAILS: return (vcm - sign * (abs(s.vgs_v) - abs(s.vds_sat_v) - _CASCODE_SAT_MARGIN_V) + sign * acc) acc += abs(s.vds_sat_v) + _CASCODE_SAT_MARGIN_V node = dev.terminals.get("s") if (consumer.type == "nmos") == (node == "vdd!"): return None # stack terminated on the wrong supply return (spec.vdd if node == "vdd!" else spec.vss) + sign * acc def _representative_bias_vgs(blocks: OpAmpBlocks, sizing: dict[str, TransistorSizing]) -> float | None: """A current-source |Vgs| to bias the resistor-bias rails to (approximate).""" bg = blocks.blocks.get("bias_gen") devs = bg.mosfets if bg else [] for d in devs: s = sizing.get(d.ref) if s and s.vgs_v: return abs(s.vgs_v) return None