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

"""Per-metric ngspice testbenches.

Each testbench builds an independent deck around the sized DUT block and is
best-effort: a measurement that fails to converge/parse returns ``None``
instead of raising.

The AC bench detects the input polarity — which of (in1, in2) is the
non-inverting input — via a DC-settle check, and reports it so the slew,
swing, CMRR and PSRR benches reuse it instead of re-detecting.

Feedback rigs (all AC-coupled through huge L/C so the loop only sets the DC
operating point):

* **SE loop** (AC/CMRR/PSRR): ``Lfb`` closes out→inn at DC, ``Cfb`` AC-grounds
  the inverting input to the ``cm`` node.
* **FD loop** (AC/CMRR/PSRR): ``L1``/``L2`` close outp→inn / outn→inp at DC.
* **Unity buffer** (slew/swing): out→inn direct — the large-signal rigs.
"""
from __future__ import annotations

import numpy as np

from .deck import _run
from .rig import _POLARITIES, _deck, _fb_netmap

# Feedback L must be AC-OPEN even at the lowest sweep frequency (1 Hz):
# ωL = 2π·1·1e12 ≈ 6e12 Ω. C is an AC-short that grounds/couples at AC.
_LH, _CH = "1e12", "1e3"
_RH = "1e9"   # FD input-anchor resistors (zero DC drop into MOS gates)


[docs] def _pols(polarity): """Polarities to try, the AC-detected one first. The AC polarity is only a **hint**: ngspice's ``.op`` also converges on the *unstable* equilibrium of a positive-feedback loop, so the AC bench cannot always tell the two assignments apart (their small-signal magnitudes are symmetric for a differential pair). The large-signal benches keep their own validity check and fall back to the other polarity when the hint rails. """ if polarity is None: return _POLARITIES return (polarity, *(p for p in _POLARITIES if p != polarity))
[docs] def _lf_mag(name, ports, body_dut, vdd, ibias, fb, netmap, outexpr, sup_ac: bool = False): """Low-frequency (1 mHz, matching the gain bench's first AC point) magnitude of ``outexpr`` for a 1 V AC stimulus.""" control = (f"ac lin 1 1e-3 1e-3\nlet vod={outexpr}\n" "wrdata __OUT__ real(vod) imag(vod)") deck = _deck(name, ports, body_dut, vdd, ibias, fb, netmap, control, sup_ac=sup_ac) a = _run(deck, ["re", "im"]) if a is None or a.shape[1] < 4: return None row = a[0] return float(np.hypot(row[1], row[3]))
[docs] def _measure_power(name, ports, body_dut, topo, vdd, ibias, vcm): """DC operating point → quiescent power.""" netmap = {"ibias": "ibias", "vdd!": "vdd", "gnd!": "0", "in1": "cm", "in2": "cm"} for o in topo.out: netmap[o] = o if topo.has_vcm: netmap["vcm_ref"] = "cm" deck = _deck(name, ports, body_dut, vdd, ibias, f"Vcm cm 0 {vcm}\n", netmap, "op\nwrdata __OUT__ i(Vsup)") data = _run(deck, ["i(Vsup)"]) if data is None: return None i = abs(float(data.flatten()[-1])) return vdd * i
[docs] def _pm_plausible(pm: float | None) -> bool: """True unless ``pm`` is outside the physical ``(0°, 180°]`` range. A stable amplifier's phase at the 0-dB crossing lies below its DC phase, so ``PM = 180° + phase`` lands in ``(0°, 180°]``. A value **above 180°** (phase lead on a falling gain) is a non-minimum-phase — right-half-plane — response: a genuinely mis-compensated circuit, e.g. Miller-family compensation wrapped around a non-inverting second stage. A value ≤ 0° means the crossing came from a corrupted sweep — typically the wrong feedback polarity settling into a measurable but meaningless response, or a phase-unwrap glitch. Neither is a usable gain/GBW/PM measurement. ``None`` (no crossing found) carries no such evidence. """ return pm is None or 0.0 < pm <= 180.0
[docs] def _loop_fb(topo, vcm, drive: str) -> tuple[str, str]: """The DC-bias network + output expression, with ``drive`` lines. SE: ``Lfb`` (DC feedback) + ``Cfb`` (AC ground to ``cm``). FD (issue #165): inputs DC-anchored at Vcm through huge inductors (DC shorts, AC opens) with the CMFB owning the output CM — the standard FD bench. The old cross-feedback ties (``outp``→``inn``/``outn``→``inp``) were a pre-#165 crutch that invented an output CM for unregulated circuits; against a real CM loop they form a bistable DC network whose degenerate all-off solution ngspice picks at tight headroom (sky130: healthy circuits measured −79 dB), and they made the FD DC sanity check vacuous (ties force ``outp == outn``). With free outputs that check is meaningful again. """ if topo.fd: # Resistor anchors, not inductors: L anchors + the 0 V differential # drive would form a singular L–V loop (two DC shorts plus a source), # which ngspice regularizes into garbage bias. MOS gates draw no DC # current, so the R drop is zero and the inputs sit exactly at Vcm. fb = (f"Vocm ocm 0 {vcm}\nVicm icm 0 {vcm}\n" f"R1 icm inp {_RH}\nR2 icm inn {_RH}\n" + drive) return fb, "v(outp)-v(outn)" fb = (f"Vcm cm 0 {vcm}\n" f"Lfb out inn {_LH}\nCfb inn cm {_CH}\n" + drive) return fb, "v(out)"
[docs] def _measure_ac(name, ports, body_dut, topo, vdd, ibias, vcm): """Open-loop AC: returns ``(gain_db, gbw_hz, pm_deg, reason, polarity)``. AC-coupled feedback: huge L closes the loop at DC (sets bias ≈ CM), huge C AC-grounds the inverting input; a 1 V AC source drives the non-inverting input. The (in1,in2)->(non-inv,inv) assignment is auto-detected via the DC operating point (output must settle near CM, not a rail), and among the branches that settle the one with a *plausible* phase margin wins (higher gain breaks ties): a corrupted branch shows an implausible PM and must not outrank the honest measurement just because its gain reads higher. The winning ``polarity`` is returned for the other benches to reuse (``None`` when nothing usable settled). The **measured** low-frequency gain is reported even when it is ≤ 0 dB (a mis-biased circuit that does not amplify) — ``gbw``/``pm`` are then ``None`` (no 0-dB crossing) and ``reason`` explains why. When every settled branch is corrupt (PM outside ``(0°, 180°]``) the whole extraction is discarded: all three values are ``None`` and ``reason`` says so. ``reason`` is ``None`` on a normal (positive-gain) measurement. """ settled = False best: tuple[float, float | None, float | None] | None = None best_rank: tuple[bool, float] | None = None best_pol: tuple[str, str] | None = None for inp, inn in _POLARITIES: netmap = _fb_netmap(topo, inp, inn) # No AC-grounding caps in the FD drive — they would short out Vid. drive = ("Vid inp inn ac 1\n" if topo.fd else "Vid inp cm ac 1\n") fb, outexpr = _loop_fb(topo, vcm, drive) # DC check: output must settle near CM (negative feedback), else swap. dc = _deck(name, ports, body_dut, vdd, ibias, fb, netmap, f"op\nlet vchk={outexpr}\nwrdata __OUT__ vchk") d = _run(dc, ["vchk"]) if d is None: continue vchk = float(d.flatten()[-1]) ok = (abs(vchk) < 0.3 * vdd) if topo.fd else (0.1 * vdd < vchk < 0.9 * vdd) if not ok: continue settled = True # AC: dump real/imag of the (differential) output; mag/phase in numpy. # wrdata writes a scale (frequency) column per vector → [f, re, f, im]. # The sweep starts at 1 mHz so the first point sits below even a # sub-Hz dominant pole (high-gain designs): the phase baseline and the # reported low-frequency gain are then genuinely DC values — starting # at 1 Hz skews the phase reference (and thus PM) by tens of degrees. ac = _deck(name, ports, body_dut, vdd, ibias, fb, netmap, f"ac dec 30 1e-3 1e10\nlet vod={outexpr}\n" "wrdata __OUT__ real(vod) imag(vod)") a = _run(ac, ["re", "im"]) if a is None or a.shape[0] < 5 or a.shape[1] < 4: continue f, re, im = a[:, 0], a[:, 1], a[:, 3] mag = np.hypot(re, im) good = (f > 0) & (mag > 0) f, mag = f[good], mag[good] re, im = re[good], im[good] if len(f) < 5: continue gdb = 20 * np.log10(mag) # Phase relative to DC removes the unknown inverting-input baseline # (DC phase is ~0° for non-inverting drive, ~±180° otherwise). phase = np.degrees(np.unwrap(np.arctan2(im, re))) phase -= phase[0] gain_db = float(gdb[0]) gbw = pm = None below = np.where(gdb <= 0)[0] if below.size and below[0] > 0: i1 = int(below[0]); i0 = i1 - 1 lf0, lf1 = np.log10(f[i0]), np.log10(f[i1]) t = (0 - gdb[i0]) / (gdb[i1] - gdb[i0]) gbw = float(10 ** (lf0 + t * (lf1 - lf0))) ph_gbw = phase[i0] + t * (phase[i1] - phase[i0]) pm = float(180.0 + ph_gbw) # excess phase is negative → PM < 180 # Keep the plausible-PM branch, then the higher gain (the negative- # feedback one); report it even if ≤ 0 dB so a mis-biased circuit # isn't silently dropped. rank = (_pm_plausible(pm), gain_db) if best_rank is None or rank > best_rank: best, best_rank, best_pol = (gain_db, gbw, pm), rank, (inp, inn) if best is None: reason = ("open-loop AC did not settle (output railed) — gain not measurable" if not settled else "open-loop AC sweep did not converge") return None, None, None, reason, None gain_db, gbw, pm = best if not _pm_plausible(pm): if pm > 180.0: # Phase LEAD at the crossing on a falling gain = a non-minimum- # phase (right-half-plane) response — a genuinely mis-compensated # circuit (e.g. Miller-family compensation wrapped around a # non-inverting second stage), not a sweep artifact. return None, None, None, ( f"AC phase leads at the 0-dB crossing (PM {pm:.0f}° > 180°) — " "right-half-plane response; the stage-inversion/compensation " "combination is unsound"), None # Every settled branch was corrupt: its gain/GBW come from the same # meaningless sweep, so discard the extraction rather than report it. return None, None, None, ( f"AC phase at the 0-dB crossing is implausible (PM {pm:.0f}° " f"outside (0°, 180°]) — extraction artifact, discarded"), None reason = (None if gain_db > 0 else "measured gain ≤ 0 dB — circuit does not amplify as biased") return gain_db, gbw, pm, reason, best_pol
[docs] def _edge_slew(t, vo, vdd) -> float | None: """Standard 20%-80% slew of one output transition (robust to edge spikes): the average slope across the central 60% of the swing.""" if len(t) < 4: return None v0, vf = vo[0], vo[-1] swing = vf - v0 if abs(swing) < 0.05 * vdd: return None prog = (vo - v0) / swing # 0→1 fraction of the transition idx = np.where((prog >= 0.2) & (prog <= 0.8))[0] if idx.size < 2: return None dt = t[idx[-1]] - t[idx[0]] if dt <= 0: return None sr = abs(0.6 * swing) / dt return float(sr) if sr > 0 else None
[docs] def _measure_sr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity=None, sr_hint: float | None = None): """Unity-gain large-signal pulse → slew rate (V/s), the **min of the rising and falling edges**. SE only (best-effort). The transient starts from the DC operating point (no ``uic`` — a zero-state start would measure the power-up transient instead of the step response), and the pulse width is scaled so a design slewing at ≥ 1/3 of the spec target (``sr_hint``) completes its transition inside the window. """ if topo.fd: return None # FD direct-feedback SR harness omitted in this pass step = 0.3 * vdd # Window long enough for 3× the spec-implied transition time (default 1 µs # per edge when the spec does not constrain slew rate). t_edge = max(3.0 * step / sr_hint, 60e-9) if sr_hint else 1e-6 t0 = 0.02 * t_edge # settle margin before the step for inp, inn in _pols(polarity): netmap = _fb_netmap(topo, inp, inn) # unity buffer: out -> inverting input (direct), pulse the non-inverting input fb = (f"Rfb out inn 1\n" f"Vstep inp 0 pulse({vcm} {vcm + step} {t0} 10p 10p {t_edge} 1)\n") deck = _deck(name, ports, body_dut, vdd, ibias, fb, netmap, f"tran {(t0 + 2 * t_edge) / 2000} {t0 + 2 * t_edge}\n" "wrdata __OUT__ v(out)") a = _run(deck, ["v(out)"]) if a is None or a.shape[0] < 10: continue t, vo = a[:, 0], a[:, 1] if abs(vo[0] - vcm) > 0.4 * vdd: # unity buffer must start near CM continue rising = t < t0 + t_edge # pulse falls back at t0 + t_edge edges = [s for s in (_edge_slew(t[rising], vo[rising], vdd), _edge_slew(t[~rising], vo[~rising], vdd)) if s is not None] if edges: return float(min(edges)) return None
[docs] def _measure_swing(name, ports, body_dut, topo, vdd, ibias, vcm, polarity=None): """Inverting −1 DC sweep → ``(swing_max, swing_min)`` in V. SE only. The output is driven across the supply through an inverting gain −1 network while the non-inverting input stays at CM, so the input stage never leaves its common-mode range: the sweep measures **output** swing, not the intersection of output swing and ICMR (a unity buffer, whose input rides with the output, conflates the two — issue #126). Large feedback resistors keep the DUT's output unloaded. The output is read over the contiguous **tracking region** around CM (small-signal slope ≤ −0.7 — outside it the output stage has saturated and the output no longer follows). The region's edge values are the reachable output extremes. """ if topo.fd: return None, None step = max(vdd / 200.0, 1e-3) for inp, inn in _pols(polarity): netmap = _fb_netmap(topo, inp, inn) fb = (f"Rfb out inn 10meg\nRin src inn 10meg\n" f"Vp inp 0 dc {vcm}\nVin src 0 dc {vcm}\n") deck = _deck(name, ports, body_dut, vdd, ibias, fb, netmap, f"dc Vin 0 {vdd} {step}\nwrdata __OUT__ v(out)") a = _run(deck, ["v(out)"]) if a is None or a.shape[0] < 20 or a.shape[1] < 2: continue vin, vo = a[:, 0], a[:, 1] # ideal: vo = 2·vcm − vin slope = np.gradient(vo, vin) track = slope <= -0.7 icm = int(np.argmin(np.abs(vin - vcm))) if not track[icm]: continue # loop does not even track at CM → wrong polarity lo = hi = icm while lo > 0 and track[lo - 1]: lo -= 1 while hi < len(track) - 1 and track[hi + 1]: hi += 1 return float(vo[lo]), float(vo[hi]) # inverting: max at low vin return None, None
[docs] def _measure_cmrr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity, adm_db): """Common-mode rejection: ``CMRR = Adm − Acm`` in dB. Same DC feedback loop as the gain bench, but the 1 V AC rides on the input **common mode**: SE — the ``cm`` node itself carries the AC (the inverting input receives it through ``Cfb``, the non-inverting one through the 0 V ``Vid``); FD — both gates are AC-coupled to one source, and the common-mode → differential conversion is read at the output. """ if adm_db is None or polarity is None: return None inp, inn = polarity netmap = _fb_netmap(topo, inp, inn) if topo.fd: drive = (f"Vci ci 0 dc 0 ac 1\n" f"Cc1 inp ci {_CH}\nCc2 inn ci {_CH}\n") fb, outexpr = _loop_fb(topo, vcm, drive) else: fb, outexpr = _loop_fb(topo, vcm, "Vid inp cm dc 0\n") fb = fb.replace(f"Vcm cm 0 {vcm}\n", f"Vcm cm 0 dc {vcm} ac 1\n") acm = _lf_mag(name, ports, body_dut, vdd, ibias, fb, netmap, outexpr) if acm is None or acm <= 1e-12: # numerical zero → nothing was measured return None return adm_db - 20.0 * np.log10(acm)
[docs] def _measure_psrr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity, adm_db): """Positive-supply rejection: ``PSRR+ = Adm − Avdd`` in dB. Same DC feedback loop as the gain bench with a quiet input; the 1 V AC rides on VDD and the supply-to-output gain is read at low frequency. """ if adm_db is None or polarity is None: return None inp, inn = polarity netmap = _fb_netmap(topo, inp, inn) drive = ("Vid inp inn dc 0\n" if topo.fd else "Vid inp cm dc 0\n") fb, outexpr = _loop_fb(topo, vcm, drive) avdd = _lf_mag(name, ports, body_dut, vdd, ibias, fb, netmap, outexpr, sup_ac=True) if avdd is None or avdd <= 1e-12: # numerical zero → nothing was measured return None return adm_db - 20.0 * np.log10(avdd)