Full-circuit ngspice oracles (FreePDK45 and TSMC28HPC+)¶
Status: maintained advanced reference. These helpers are explicit comparison paths. They are not the default TSMC28 native backend and do not turn an ngspice result into foundry sign-off.
FreePDK45 and TSMC28HPC+ normally use CircuitOpt's native BSIM4.5 backend.
ngspice is retained as an independent regression oracle. The oracle helpers
render the complete circuit and let ngspice run the original compact-model
deck with full charge. They live in
circuitopt/ngspice_ac.py and share the deck renderer in circuitopt/ngspice_render.py
with the .tran backend, so device M/X lines, R/C, E/G/F/H controlled sources,
rails, per-polarity corner routing, temperature and supply bias render identically.
The historical FreePDK45 characterization grid remains registered as
freepdk45_ngspice.*. New circuits should use freepdk45.*; use the explicit
aliases or the full-circuit helpers when reproducing old grid results or checking
native results against an external simulator.
All four honor: temperature (temperature= in Kelvin → .options temp=),
corner (corner= including the mixed sf/fs, via binding.at_corner(...) or
directly), and supply (through bias). A multistable OTA DC point is seeded with
x0_guess={node: V} → .nodeset (use the circuit's dc_guesses[0]).
Process selection¶
The registered model types choose the renderer:
| Model type | SPICE instance | Model setup | ngspice arguments |
|---|---|---|---|
freepdk45.nmos / .pmos or freepdk45_ngspice.* |
flat M device |
per-polarity .include cards |
default |
tsmc28hpcp_ngspice.nmos / .pmos |
X wrapper using nch_mac / pch_mac |
explicit five-section .lib closure |
-D ngbehavior=hsa |
A complete circuit must use one process adapter for every MOS. Mixed foundry setup semantics in one full-circuit ngspice deck are rejected instead of silently selecting the wrong cards.
TSMC28HPC+ resolves its licensed model from TSMC28_MODEL_DIR,
TSMC28_PDK_ROOT, the portable project-local
PDK/tsmc28hpcp/models/hspice/ entry, then PDK_ROOT/tsmc28hpcp. The adapter reads
hierarchical @m.x*.main[...] operating-point vectors and passes NF to the foundry
wrapper. See TSMC28HPC+ Local Adapter.
Mixed per-polarity corners: sf / fs¶
FreePDK45 ships models_{nom,ss,ff}/ card directories. The corner name now selects a
directory per polarity (circuitopt.freepdk45_model.corner_card_dir):
| corner | NMOS card dir | PMOS card dir |
|---|---|---|
nom / tt |
models_nom |
models_nom |
ss |
models_ss |
models_ss |
ff |
models_ff |
models_ff |
sf |
models_ss |
models_ff |
fs |
models_ff |
models_ss |
sf = NMOS slow + PMOS fast; fs the reverse; tt is an alias of nom. The native
card loader, historical characterization grid, and full-circuit ngspice renderer all
honor this mapping. When the two polarities differ (sf/fs), the rendered deck
.includes both card files.
nom/ss/ff decks are byte-identical to the pre-change renderer (golden-locked).
Corner names are case-insensitive ("SF" behaves as sf) and strictly
validated on both paths: None/"" mean nom, but an unknown name (a typo like
"sx") raises ValueError naming the valid set — it never silently falls back to
nominal, so a misspelled corner cannot poison a PVT campaign
(circuitopt.freepdk45_model.normalize_corner).
ac_ngspice — small-signal transfer¶
res = ac_ngspice(sizes, bias, topo=topo,
acmag={"VINP": (0.5, 0), "VINN": (0.5, 180)}, # differential drive
fstart=1e3, fstop=1e11, points=15, # .ac dec <points> ...
out_nodes=["OUTP", "OUTN"], nf=nf,
model_types=..., device_kwargs=..., corner="nom",
temperature=300.15, x0_guess=seed)
H = ac_response(res, "OUTP", "OUTN", vin=1.0) # differential transfer / diff-input mag
peak_gain_db(res["freq"], H) # passband gain (use for AC-coupled / band-pass)
dc_gain_db(res["freq"], H) # f->0 gain
unity_gain_freq(res["freq"], H) # UGBW [Hz]
phase_margin(res["freq"], H) # PM [deg], referenced to the passband phase
gain_margin_db(res["freq"], H) # GM [dB] at the -180 deg crossing
acmag maps a stimulus source (a rail name or an ideal-vsource name) to
(magnitude, phase_deg); a differential drive is two sources with opposite phase.
res["nodes"] holds the complex voltage of every recorded solved node.
Validated on the FD-OTA example: 58.9 dB / 119.9 MHz / 84 deg — matching §4.5.
noise_ngspice — output & input-referred noise¶
n = noise_ngspice(sizes, bias, topo=topo, out="OUTP", ref="OUTN", # v(outp,outn)
src="VINP", fstart=1e3, fstop=1e9, points=20,
band=(1e4, 1e8), ...)
n["onoise_psd"], n["inoise_psd"] # V^2/Hz over n["freq"]
n["onoise_rms"], n["inoise_rms"] # sqrt(integral PSD df) over n["band"]
src (the ngspice .noise input source) is driven ac 1 automatically so inoise
is meaningful. out/ref give single-ended v(out) or differential v(out,ref).
PSD is the ngspice *noise_spectrum amplitude density squared. A bare resistor reads
4kTR to <2 %.
op_ngspice — operating point + saturation check¶
op = op_ngspice(sizes, bias, topo=topo, margin=0.0, ...)
op["M1"] # {"vds","vgs","vdsat","id","gm","gds","region_ok"}
region_ok = |vds| >= |vdsat| + margin (absolute values handle NMOS/PMOS uniformly) —
the saturation-region test for a bias-point audit across the PVT grid.
For a charge-transfer circuit, replacing the final transient state with a new DC
solve can discard sampled charge. Pass op_devices=["M1", ...] to
transient_ngspice instead: device_op then contains the same six quantities as
waveforms and device_op_final contains their final-sample values. This is the
MDAC campaign's saturation oracle after maximum residue and major-carry settling.
loop_gain_ngspice — loop gain & phase margin¶
Chosen method: Middlebrook single voltage injection. It needs only one testbench-side ideal voltage source in series in the loop (no loop-breaking inductor — our renderer already supports ideal vsources and E/G/F/H controlled sources), and it preserves the DC operating point because the injection source is 0 V at DC.
Recipe: insert an ideal vsource Vinj in series in the loop, at a high-impedance /
low-impedance boundary — a transistor gate is ideal. Its p terminal faces the
DUT input (high-Z gate), its q terminal faces the driver (low-Z output); DC value 0.
lg = loop_gain_ngspice(sizes, bias, topo=topo, inject="Vinj",
fstart=1e3, fstop=1e10, points=30, ...)
lg["loop_gain"] # complex T(f); lg["ugf"], lg["pm"], lg["gm_db"]
The source is driven ac 1 and T = -V(q)/V(p) at the break (exact at a high-Z/low-Z
boundary). Validated against an analytic single-pole feedback loop (PM within a few
degrees). To apply it to the FD-OTA:
- Differential loop — split an input-pair gate node into two (
G1→G1a/G1b), putVinjbetween them, and close the amplifier in unity feedback in the testbench. - CMFB loop — split the common-mode control gate node (the
CTRLnet driving the PMOS-load gates) and inject there; the sense pairs + mirror close the CM loop.
Because PM/UGBW/GM are magnitude/relative-phase quantities, the loop-gain sign convention (which depends on ngspice's controlled-source current sense) does not affect them.
loop_gain_tian_ngspice — exact loop gain at a capacitive break¶
Single voltage injection is exact only while the p side stays high-impedance
relative to the q side across the whole sweep. At a MOS gate this fails at RF:
a large input pair's Cgg (pF-class) falls to hundreds of ohms right around loop
crossover, and the reported PM becomes a probe artifact — we measured a two-stage
MDAC OTA loop reporting PM ≈ 98° while its closed-loop transient rang at ~500 MHz
(true margin ≈ 25°); gm_db = nan in the sweep was the tell.
loop_gain_tian_ngspice (same signature/return shape) removes the impedance
condition with Tian's double injection (IEEE Circuits & Devices 17(1), 2001): a
second AC run drives a testbench current source into the break's p node with the
0 V vsource left in place, and the two runs' v(p)/i(Vinj) combine as
T = -1/(1 - 1/(2*(i1*v2 - v1*i2) + v1 + i2)) — exact for arbitrary impedances on
both sides, orientation-symmetric, validated to machine precision against a
two-pole analytic loop (tests/test_pvt_machinery.py). PM sign-off for any loop
broken at a capacitive gate must use this variant. By default the v- and
i-injection sweeps are CHAINED into one ngspice process (the current sources sit
in the deck at ac 0, an alter @src[acmag] swaps the drive between sweeps), so
the deck's fixed model-expansion cost is paid once — measured bit-identical to
the two-process path on both analytic and foundry decks. Set
CIRCUITOPT_NGSPICE_CHAIN=0 (or chain=False) to force the historical
one-process-per-sweep behaviour.