Source code for circuitgenome.sizer.analytical.constraints

"""
OR-Tools CP-SAT model builder for transistor sizing.

Translates performance requirements into integer linear constraints over
discrete W and L variables (in integer multiples of the grid step).

Key linearisation: the gm lower-bound constraint

    gm ≥ gm_req
    ↔  √(2·µCox·(W/L)·IDS) ≥ gm_req
    ↔  2·µCox·IDS·W  ≥  gm_req²·L          [linear in W, L]

and the VDS_sat upper-bound constraint

    √(2·IDS·L/(µCox·W)) ≤ VDS_sat_max
    ↔  2·IDS·L  ≤  µCox·VDS_sat_max²·W     [linear in W, L]

are both linear once W and L are separate integer variables.  The integer
variables count grid steps (W_µm = W·width.step), so each side's step size
is folded into its coefficient.  All floating-point coefficients are scaled
by _SCALE = 10¹² before rounding to integers so that µA/V² and µA
magnitudes map to small integer values.
"""
from __future__ import annotations

from fractions import Fraction

from ortools.sat.python import cp_model

from ..shared.models import MosfetParams, TechParams

# Scale factor: converts A²/V² coefficient products to tidy integers.
# With µCox ≈ 90e-6 A/V², IDS ≈ 5e-6 A:
#   2·µCox·IDS·_SCALE = 2·90e-6·5e-6·1e12 = 900  (small integer ✓)
_SCALE = 10**12


def _coeff(value: float) -> int:
    """Round a physical coefficient to its integer CP-SAT representation."""
    return round(value * _SCALE)


[docs] def build_model( 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 gm_req_map: dict[str, float], # ref → required gm in A/V (0 = unconstrained) vod_max_map: dict[str, float], # ref → max VDS_sat in V (inf = unconstrained) tech: TechParams, symmetry_slots: frozenset[str] = frozenset({"input_pair", "load", "tail_current"}), ) -> tuple[cp_model.CpModel, dict[str, cp_model.IntVar], dict[str, cp_model.IntVar]]: """Build the CP-SAT sizing model. :param transistors: Deduplicated map of all transistors to size, keyed by device reference. :param slot_transistors: Per-slot transistor lists (for symmetry grouping). :param ids_map: Quiescent drain-source current in A for each transistor. :param gm_req_map: Required transconductance lower bound in A/V. Entries with value ≤ 0 are skipped. :param vod_max_map: Maximum VDS_sat (overdrive) in V. Entries with value ≥ a large number are skipped. :param tech: Technology parameters supplying µCox, Vth, λ and grids. :param symmetry_slots: Slot names within which same-type transistors are constrained to equal W and equal L (matched pairs). :returns: ``(model, W_vars, L_vars)`` where ``W_vars`` and ``L_vars`` are dicts mapping device reference to the corresponding :class:`cp_model.IntVar` (in integer grid steps, not µm). """ model = cp_model.CpModel() w_step = tech.width.step l_step = tech.length.step w_min_int = round(tech.width.min / w_step) w_max_int = round(tech.width.max / w_step) l_min_int = round(tech.length.min / l_step) l_max_int = round(tech.length.max / l_step) # --- Decision variables --- W: dict[str, cp_model.IntVar] = {} L: dict[str, cp_model.IntVar] = {} for ref in transistors: W[ref] = model.new_int_var(w_min_int, w_max_int, f"W_{ref}") L[ref] = model.new_int_var(l_min_int, l_max_int, f"L_{ref}") # --- gm lower-bound constraints: 2·µCox·IDS·W ≥ gm_req²·L --- # W/L in µm = (W_int·w_step)/(L_int·l_step), so each side carries its step. for ref, (device, _slot) in transistors.items(): gm_req = gm_req_map.get(ref, 0.0) if gm_req <= 0.0: continue ids_a = ids_map.get(ref, 0.0) if ids_a == 0.0: continue params: MosfetParams = tech.nmos if device.type == "nmos" else tech.pmos lhs = _coeff(2.0 * params.mu_cox * abs(ids_a) * w_step) # coefficient of W rhs = _coeff(gm_req ** 2 * l_step) # coefficient of L if lhs > 0 and rhs > 0: model.add(lhs * W[ref] >= rhs * L[ref]) # --- VDS_sat upper-bound constraints: 2·IDS·L ≤ µCox·VDS_sat_max²·W --- for ref, (device, _slot) in transistors.items(): vod_max = vod_max_map.get(ref, float("inf")) if vod_max == float("inf") or vod_max <= 0.0: continue ids_a = ids_map.get(ref, 0.0) if ids_a == 0.0: continue params = tech.nmos if device.type == "nmos" else tech.pmos lhs = _coeff(2.0 * abs(ids_a) * l_step) # coefficient of L rhs = _coeff(params.mu_cox * vod_max ** 2 * w_step) # coefficient of W if lhs > 0 and rhs > 0: model.add(lhs * L[ref] <= rhs * W[ref]) # --- Symmetry constraints: matched pairs within designated slots --- for slot_name, devices in slot_transistors.items(): if slot_name not in symmetry_slots: continue # Group by (type, planned IDS); within each group all transistors are # matched. Devices at different currents (a folded-cascode load's # folding sinks vs its cascode devices) are not a matched pair — # forcing them equal would fight the mirror-ratio constraints. groups: dict[tuple, list] = {} 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) for group in groups.values(): for i in range(1, len(group)): model.add(W[group[i].ref] == W[group[0].ref]) model.add(L[group[i].ref] == L[group[0].ref]) # --- Cross-slot symmetry: p ↔ n halves of FD gain stages --- for p_slot, n_slot in (("second_stage_p", "second_stage_n"), ("third_stage_p", "third_stage_n")): p_devs = slot_transistors.get(p_slot, []) n_devs = slot_transistors.get(n_slot, []) for dtype in ("nmos", "pmos"): p_group = [d for d in p_devs if d.type == dtype and d.ref in W] n_group = [d for d in n_devs if d.type == dtype and d.ref in W] if p_group and n_group: anchor = p_group[0].ref for d in p_group[1:] + n_group: model.add(W[d.ref] == W[anchor]) model.add(L[d.ref] == L[anchor]) # --- Current-mirror ratio constraints (bias-current consistency) --- # Group MOSFETs by (gate-net, type). A diode-connected member (g == d) is # the mirror reference; the others are outputs. Without this, an output's # W/L is set only by its own gm/VDS_sat target, so the mirror sources an # arbitrary current (I_out = I_ref · (W/L)_out/(W/L)_ref). Pinning the ratio # makes the bias network actually produce the assumed currents. mirror_groups: dict[tuple, list[str]] = {} for ref, (device, _slot) in transistors.items(): if device.type not in ("nmos", "pmos"): continue gate = device.terminals.get("g") if gate: mirror_groups.setdefault((gate, device.type), []).append(ref) for members in mirror_groups.values(): if len(members) < 2: continue diodes = [m for m in members if transistors[m][0].terminals.get("g") == 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 # I_out / I_ref = (W/L)_out / (W/L)_ref; matched length, scaled width. frac = Fraction(i_m / i_ref).limit_denominator(100) model.add(L[m] == L[ref0]) model.add(frac.denominator * W[m] == frac.numerator * W[ref0]) # --- Objective: minimise total gate width (proxy for power and area) --- model.minimize(sum(W.values())) # --- Branching heuristic: bias_gen transistors first, then others --- bias_refs = [ ref for ref, (_d, slot) in transistors.items() if slot == "bias_gen" ] other_refs = [ref for ref in transistors if ref not in bias_refs] priority_vars = [v for ref in bias_refs for v in (W[ref], L[ref])] rest_vars = [v for ref in other_refs for v in (W[ref], L[ref])] if priority_vars: model.add_decision_strategy( priority_vars, cp_model.CHOOSE_FIRST, cp_model.SELECT_MIN_VALUE, ) if rest_vars: model.add_decision_strategy( rest_vars, cp_model.CHOOSE_FIRST, cp_model.SELECT_MIN_VALUE, ) return model, W, L