Source code for circuitgenome.sizer.shared.spice.op

"""Feedback-biased operating-point reading and the DC bias-soundness verdict."""
from __future__ import annotations

import re

from ..models import SizingResult, SizingSpec, TechParams
from .deck import (
    _dev_prefix,
    _dut,
    _inject_sizes,
    _parse_subckt,
    _run_capture,
    ngspice_available,
)
from .rig import _Topo, _iref_sink, _rig, _xline


[docs] def read_op_operating_point( netlist_text: str, result: SizingResult, tech: TechParams, spec: SizingSpec, ) -> dict[str, dict[str, float]] | None: """Return ``{ref: {'id','vds','vdsat'}}`` from a feedback-biased ``.op``. Biases the sized circuit in unity feedback (``Lfb``/``Cfb`` rig, polarity auto-detected via the settled output), then reads each MOSFET's actual operating point through ``@m.xdut.<ref>[...]``. Single-ended only; returns ``None`` when ngspice is unavailable, the topology is fully-differential, or the bias doesn't settle. """ op, _fail = _read_op(netlist_text, result, tech, spec) return op
[docs] def _read_op( netlist_text: str, result: SizingResult, tech: TechParams, spec: SizingSpec, ) -> tuple[dict[str, dict[str, float]] | None, str | None]: """:func:`read_op_operating_point` plus the failure kind when it is ``None``. The failure kind separates "the simulation ran and the output **railed** at both polarities" (``"railed"``) from "ngspice never produced a usable run" (``"sim-failed"`` — crash, non-convergence, or nothing probed); it is ``None`` when an operating point is returned. """ if not ngspice_available(): return None, "sim-failed" name, ports, body = _parse_subckt(netlist_text) topo = _Topo(ports) if topo.fd: return None, "sim-failed" body_dut = _dut(tech, name, _inject_sizes(body, result)) refs = list(result.transistors) if not refs: return None, "sim-failed" sink = _iref_sink(body) vdd, ibias = spec.vdd, spec.ibias vcm = (spec.vdd + spec.vss) / 2.0 prefixes = {r: _dev_prefix(tech, r) for r in refs} probe = "".join( f"print {pre}[id]\nprint {pre}[vds]\nprint {pre}[vdsat]\n" for pre in prefixes.values() ) ran = False for inp, inn in (("in1", "in2"), ("in2", "in1")): netmap = {"ibias": "ibias", "vdd!": "vdd", "gnd!": "0", inp: "inp", inn: "inn", "out": "out"} fb = (f"Vcm cm 0 {vcm}\nLfb out inn 1e12\nCfb inn cm 1e3\n" f"Vid inp cm dc 0\n") deck = (body_dut.replace("__PORTS__", " ".join(ports)) + _rig(vdd, ibias, sink=sink) + fb + _xline(name, ports, netmap) + "\n" + ".control\nop\nprint v(out)\n" + probe + ".endc\n.end\n") txt = _run_capture(deck) if txt is None: continue mo = re.search(r"v\(out\)\s*=\s*([-\d.eE+]+)", txt) if not mo: continue ran = True if not (0.1 * vdd < float(mo.group(1)) < 0.9 * vdd): continue # wrong polarity → output railed op: dict[str, dict[str, float]] = {} for r, pre in prefixes.items(): for m in re.finditer(re.escape(pre) + r"\[(\w+)\]\s*=\s*([-\d.eE+]+)", txt): op.setdefault(r, {})[m.group(1)] = float(m.group(2)) if op: return op, None return None, ("railed" if ran else "sim-failed")
[docs] def _op_bias_problems(op: dict[str, dict[str, float]]) -> tuple[list[str], list[str]]: """Return ``(triode_refs, starved_refs)`` from an operating-point dict. Starved: drain current below 0.1 µA (device effectively off). Triode: ``|Vds| < |Vdsat|`` (a current source/amplifier device pushed out of saturation). """ triode, starved = [], [] for ref, d in op.items(): ida = abs(d.get("id", 0.0)) if ida < 1e-7: starved.append(ref) elif "vds" in d and "vdsat" in d and abs(d["vds"]) < abs(d["vdsat"]) - 1e-3: triode.append(ref) return triode, starved
[docs] def check_bias_soundness(netlist_text: str, result: SizingResult, tech: TechParams, spec: SizingSpec) -> tuple[bool, str | None]: """SPICE-grounded DC bias verdict: ``(sound, reason)``. Runs the feedback-biased ``.op`` (:func:`read_op_operating_point`) — which converges reliably, unlike the open-loop AC rig — and condemns the bias only on positive evidence: the operating point **rails** (no usable mid-rail bias) or a device is **starved/triode**. Conservative by design: returns ``(True, None)`` when it cannot check (ngspice absent, or a fully-differential topology the SE ``.op`` rig doesn't support), so it only ever downgrades a feasible verdict. """ if not ngspice_available(): return True, None _, ports, _ = _parse_subckt(netlist_text) if _Topo(ports).fd: return True, None op, fail = _read_op(netlist_text, result, tech, spec) if op is None: if fail == "railed": return False, ("SPICE bias check: the feedback operating point railed — " "the circuit does not establish a usable mid-rail bias " "point.") return False, ("SPICE bias check: the .op simulation failed or did not " "converge — no operating point to assess.") triode, starved = _op_bias_problems(op) if starved or triode: parts = [] if starved: parts.append(f"starved (<0.1µA): {', '.join(starved[:4])}" + ("…" if len(starved) > 4 else "")) if triode: parts.append(f"in triode: {', '.join(triode[:4])}" + ("…" if len(triode) > 4 else "")) return False, "SPICE bias check: " + "; ".join(parts) + " — bias not established." return True, None
[docs] def _bias_diagnostic(netlist_text: str, result: SizingResult, tech: TechParams, spec: SizingSpec) -> str | None: """One-line summary of devices in triode / starved, when AC found no gain. Reuses the feedback-biased ``.op`` reader to explain *why* a circuit doesn't amplify (the usual cause: stacked devices don't fit the supply headroom). """ try: op = read_op_operating_point(netlist_text, result, tech, spec) except Exception: op = None if not op: return None triode, starved = _op_bias_problems(op) parts = [] if triode: parts.append(f"in triode: {', '.join(triode[:4])}" + ("…" if len(triode) > 4 else "")) if starved: parts.append(f"starved (<0.1µA): {', '.join(starved[:4])}" + ("…" if len(starved) > 4 else "")) if not parts: return None return "bias diagnostic — " + "; ".join(parts) + " (insufficient headroom?)"