SPICE Verification

ngspice post-sizing verification of op-amp performance metrics.

Re-simulates a sized circuit (W/L from SizingResult) in ngspice using the same technology as initial sizing, to cross-check the closed-form metrics from _evaluate_metrics. For the card-less generic tech a Level-1 model is synthesised from mu_cox/vth/lam (so SPICE ≈ the analytical Level-1 formulas); for PTM nodes the BSIM4 .pm card is included (so the delta reflects the Level-1-vs-device gap).

This is best-effort verification, not sign-off: each metric is measured by an independent testbench and any that fails to converge/parse returns None (printed as n/a) instead of raising.

Package layout:

deck — model emission, netlist parsing/sizing, ngspice runners rig — port classification and the shared testbench rig measure — per-metric testbenches op — operating-point reading + DC bias-soundness verdict simulate — the simulate_metrics() entry point

circuitgenome.sizer.shared.spice.check_bias_soundness(netlist_text, result, tech, spec)[source]

SPICE-grounded DC bias verdict: (sound, reason).

Runs the DC .op (read_op_operating_point() — SE in unity feedback, FD with inputs/vcm_ref at Vcm, issue #162) and condemns the bias only on positive evidence: the operating point rails (no usable mid-rail bias; for FD also a split output CM) or, single-ended, a device is starved/triode. FD skips the per-device verdicts: with inputs at Vcm the CMFB amp’s tail (its inputs also sit at Vcm) and the main tail run in marginal triode by design at low supplies — degraded, still functional, and universal to the family — so a device-level condemnation would reject every low-voltage CMFB variant that measurably amplifies at this very operating point; a dead FD circuit rails/splits its outputs instead, which the .op verdict already catches (the benches quantify any marginality). Conservative by design: returns (True, None) when it cannot check (ngspice absent), so it only ever downgrades a feasible verdict.

Parameters:
Return type:

tuple[bool, str | None]

circuitgenome.sizer.shared.spice.ngspice_available()[source]

True if the ngspice binary is on PATH.

Return type:

bool

circuitgenome.sizer.shared.spice.pdk_netlist(sized_text, tech, base_dir=None)[source]

Rewrite a sized generic netlist into the PDK-native, simulatable form.

The generic *_sized.ckt stays the canonical interchange format (the recognizer parses only generic nmos/pmos M-lines); this sibling is for running directly against the PDK: the corner .lib header plus the same rewrite the verification decks use (m…x…, device_map subcircuit names, native W/L units via wl_units). Header paths are emitted relative to base_dir (the export directory) when given, absolute otherwise — either way they are resolved on the machine that exported the file.

Raises:

ValueError – for a tech without spice_lib + device_map — there is no PDK form to export.

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.read_op_operating_point(netlist_text, result, tech, spec)[source]

Return {ref: {'id','vds','vdsat'}} from a DC .op.

Single-ended: biases the sized circuit in unity feedback (Lfb/Cfb rig, polarity auto-detected via the settled output). Fully differential (issue #162): both inputs and vcm_ref sit at Vcm with no feedback loop — the CMFB / loads own the output CM, which is exactly the DC state the metric benches run at. Either way each MOSFET’s actual operating point is read through @m.xdut.<ref>[...]; returns None when ngspice is unavailable or the bias doesn’t settle.

Parameters:
Return type:

dict[str, dict[str, float]] | None

circuitgenome.sizer.shared.spice.simulate_metrics(netlist_text, result, tech, spec, corner=None)[source]

Return SPICE-measured metrics, mirroring _evaluate_metrics keys.

Keys: power_w, gain_db, gbw_hz, phase_margin_deg, slew_rate_vps, output_swing_max_v, output_swing_min_v, cmrr_db, psrr_db. Missing/failed measurements are None (slew rate and output swing are single-ended-only; CMRR/PSRR need a measured differential gain first). corner overrides the PDK library corner (foundry techs only); None uses the tech’s nominal corner.

Parameters:
Return type:

dict[str, float | None]

circuitgenome.sizer.shared.spice.sized_netlist(netlist_text, result)[source]

Return netlist_text with the sized values from result injected.

Each MOSFET in the single .subckt block gets its W=/L=, sized load resistors get their ohm value and the compensation cap(s) get cc_pf/cc2_pf — the same injection the verification decks use. Any lines before the .subckt (title/comments) are preserved, so the output is a standalone flat netlist ready for SPICE.

Raises:

ValueError – when a MOSFET has no sizing entry — an exported design must be fully sized (an unsized device would silently run at the simulator’s default W/L).

Parameters:
Return type:

str

Deck building

SPICE deck building blocks: models, netlist parsing/sizing, ngspice runs.

Everything here is measurement-agnostic: emitting the technology’s model block, parsing the DUT .subckt, injecting sized W/L/R/Cc values, and the two ngspice runners (table output via wrdata, text output via print).

circuitgenome.sizer.shared.spice.deck.ngspice_available()[source]

True if the ngspice binary is on PATH.

Return type:

bool

circuitgenome.sizer.shared.spice.deck._emit_model(tech, corner=None)[source]

Return the SPICE .model/.include/.lib block for tech.

  • spice_lib (foundry PDK, e.g. GF180MCU): .include the global design file (if any) then .lib "<file>" <corner> — devices are subcircuits.

  • spice_model (PTM): flat .include of .model nmos/pmos cards.

  • neither: synthesise a Level-1 .model from mu_cox/vth/lam.

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.deck._xref(ref)[source]

Subcircuit instance name for a MOSFET ref (m1_inx1_in).

Parameters:

ref (str)

Return type:

str

circuitgenome.sizer.shared.spice.deck._dev_prefix(tech, ref, model)[source]

ngspice operating-point handle prefix for device ref inside Xdut.

PTM/generic instantiate .model MOSFETs (flat @m.xdut.<ref>); a PDK with a device_map instantiates subcircuits, so the BSIM4 device sits one level deeper at the subckt’s internal instance — m0 by default (GF180), or tech.device_handle[model] when the PDK names it per cell (sky130’s msky130_fd_pr__nfet_01v8). model is the generic device type of ref ("nmos"/"pmos").

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.deck._emit_body(tech, body)[source]

Rewrite generic m… nmos/pmos lines as PDK subcircuit x… instances.

No-op unless tech.device_map is set. Maps the model token via the map (nmosnmos_3p3) and lowercases W=/L= to the subckt params. For a wl_units="um" PDK (sky130) the SI micro suffix is dropped — w=10.0uw=10.0 — because the library’s .option scale=1.0u already interprets bare instance W/L as microns.

Parameters:
Return type:

list[str]

circuitgenome.sizer.shared.spice.deck._parse_subckt(netlist_text)[source]

Return (name, ports, body_lines) for the single .subckt block.

Parameters:

netlist_text (str)

circuitgenome.sizer.shared.spice.deck.sized_netlist(netlist_text, result)[source]

Return netlist_text with the sized values from result injected.

Each MOSFET in the single .subckt block gets its W=/L=, sized load resistors get their ohm value and the compensation cap(s) get cc_pf/cc2_pf — the same injection the verification decks use. Any lines before the .subckt (title/comments) are preserved, so the output is a standalone flat netlist ready for SPICE.

Raises:

ValueError – when a MOSFET has no sizing entry — an exported design must be fully sized (an unsized device would silently run at the simulator’s default W/L).

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.deck.pdk_netlist(sized_text, tech, base_dir=None)[source]

Rewrite a sized generic netlist into the PDK-native, simulatable form.

The generic *_sized.ckt stays the canonical interchange format (the recognizer parses only generic nmos/pmos M-lines); this sibling is for running directly against the PDK: the corner .lib header plus the same rewrite the verification decks use (m…x…, device_map subcircuit names, native W/L units via wl_units). Header paths are emitted relative to base_dir (the export directory) when given, absolute otherwise — either way they are resolved on the machine that exported the file.

Raises:

ValueError – for a tech without spice_lib + device_map — there is no PDK form to export.

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.deck.unsized_mos_refs(body, result)[source]

MOSFET refs in body that _inject_sizes() would leave unsized.

Parameters:
Return type:

list[str]

circuitgenome.sizer.shared.spice.deck._inject_sizes(body, result)[source]

Set sized W/L (MOSFETs), Cc (comp caps) and R (sized load resistors).

Parameters:
Return type:

list[str]

circuitgenome.sizer.shared.spice.deck._dut(tech, name, body, corner=None)[source]

Title line + model block + the sized .subckt definition.

The leading comment is mandatory: SPICE always treats the first deck line as the title and ignores it, which would otherwise swallow the first .model. For a PDK tech the device lines are rewritten to subcircuit instances.

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.deck._run(deck, vectors)[source]

Run deck in ngspice -b; return the wrdata table (or None on failure).

deck must contain __OUT__ where the wrdata filename goes and a .control block ending in wrdata __OUT__ <vectors>.

Parameters:
Return type:

ndarray | None

circuitgenome.sizer.shared.spice.deck._run_capture(deck)[source]

Run deck in ngspice -b and return stdout (for print output).

Parameters:

deck (str)

Return type:

str | None

Testbench rig

Shared testbench rig: port classification, supplies, DUT instantiation.

Every measurement wraps the same sized DUT block in a small rig; the helpers here remove the per-testbench boilerplate: the port→net map for a chosen input polarity (_fb_netmap()), and full-deck assembly (_deck()). The input polarity — which of (in1, in2) is the non-inverting input — is detected once by the AC testbench (via the DC-settle check) and reused by the slew, swing, CMRR and PSRR benches.

The bias-current source direction is not fixed: it follows the DUT’s ibias reference-diode orientation (_iref_sink()), because an NMOS-referenced bias generator needs the reference current pushed into the pin while a PMOS-referenced one needs it pulled out.

class circuitgenome.sizer.shared.spice.rig._Topo(ports)[source]
Parameters:

ports (list[str])

circuitgenome.sizer.shared.spice.rig._xline(name, ports, netmap)[source]

X-instance wiring DUT ports to testbench nets via netmap.

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.rig._iref_sink(dut_lines)[source]

True when the external bias source must pull current out of ibias.

The DUT’s reference is the diode-connected MOSFET on the ibias net (d == g == ibias). With its source at gnd! (NMOS reference, e.g. diode_connected_mosfet_bias) the diode sinks an injected current; with its source at vdd! (PMOS reference, e.g. magic_battery_bias or resistor_bias) the diode conducts from the supply out of the pin, so the external source must sink the current to ground — injecting instead floats the pin above VDD and leaves the whole bias generator dead.

Works on raw and PDK-rewritten device lines alike (both keep the ref d g s b model token order). No reference diode, or conflicting diodes on both rails, falls back to inject (the historical behavior).

Return type:

bool

circuitgenome.sizer.shared.spice.rig._rig(vdd, ibias, sup_ac=False, sink=False)[source]

Supply + bias-current sources; sup_ac rides 1 V AC on VDD (PSRR).

sink pulls the reference current out of the ibias pin instead of injecting it — required by PMOS-referenced bias generators (see _iref_sink()).

Parameters:
Return type:

str

circuitgenome.sizer.shared.spice.rig._fb_netmap(topo, inp, inn)[source]

Port→net map for a feedback testbench with the given input polarity.

Parameters:
Return type:

dict

circuitgenome.sizer.shared.spice.rig._deck(name, ports, body_dut, vdd, ibias, fb, netmap, control, sup_ac=False)[source]

Assemble a full ngspice deck: DUT + supplies + testbench fb + control.

The bias-current direction adapts to the DUT block’s reference diode (_iref_sink()).

Parameters:
Return type:

str

Metric testbenches

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.

circuitgenome.sizer.shared.spice.measure._pols(polarity)[source]

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.

circuitgenome.sizer.shared.spice.measure._lf_mag(name, ports, body_dut, vdd, ibias, fb, netmap, outexpr, sup_ac=False)[source]

Low-frequency (1 mHz, matching the gain bench’s first AC point) magnitude of outexpr for a 1 V AC stimulus.

Parameters:

sup_ac (bool)

circuitgenome.sizer.shared.spice.measure._measure_power(name, ports, body_dut, topo, vdd, ibias, vcm)[source]

DC operating point → quiescent power.

circuitgenome.sizer.shared.spice.measure._pm_plausible(pm)[source]

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.

Parameters:

pm (float | None)

Return type:

bool

circuitgenome.sizer.shared.spice.measure._loop_fb(topo, vcm, drive)[source]

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.

Parameters:

drive (str)

Return type:

tuple[str, str]

circuitgenome.sizer.shared.spice.measure._measure_ac(name, ports, body_dut, topo, vdd, ibias, vcm)[source]

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.

circuitgenome.sizer.shared.spice.measure._edge_slew(t, vo, vdd)[source]

Standard 20%-80% slew of one output transition (robust to edge spikes): the average slope across the central 60% of the swing.

Return type:

float | None

circuitgenome.sizer.shared.spice.measure._measure_sr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity=None, sr_hint=None)[source]

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.

Parameters:

sr_hint (float | None)

circuitgenome.sizer.shared.spice.measure._measure_swing(name, ports, body_dut, topo, vdd, ibias, vcm, polarity=None)[source]

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.

circuitgenome.sizer.shared.spice.measure._measure_cmrr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity, adm_db)[source]

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.

circuitgenome.sizer.shared.spice.measure._measure_psrr(name, ports, body_dut, topo, vdd, ibias, vcm, polarity, adm_db)[source]

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.

Operating point & bias soundness

DC operating-point reading and the bias-soundness verdict (SE and FD).

circuitgenome.sizer.shared.spice.op.read_op_operating_point(netlist_text, result, tech, spec)[source]

Return {ref: {'id','vds','vdsat'}} from a DC .op.

Single-ended: biases the sized circuit in unity feedback (Lfb/Cfb rig, polarity auto-detected via the settled output). Fully differential (issue #162): both inputs and vcm_ref sit at Vcm with no feedback loop — the CMFB / loads own the output CM, which is exactly the DC state the metric benches run at. Either way each MOSFET’s actual operating point is read through @m.xdut.<ref>[...]; returns None when ngspice is unavailable or the bias doesn’t settle.

Parameters:
Return type:

dict[str, dict[str, float]] | None

circuitgenome.sizer.shared.spice.op._read_op(netlist_text, result, tech, spec)[source]

read_op_operating_point() plus the failure kind when it is None.

The failure kind separates “the simulation ran and the output railed” ("railed" — SE: at both feedback polarities; FD: an output at a rail or a split output CM) from “ngspice never produced a usable run” ("sim-failed" — crash, non-convergence, or nothing probed); it is None when an operating point is returned.

Parameters:
Return type:

tuple[dict[str, dict[str, float]] | None, str | None]

circuitgenome.sizer.shared.spice.op._parse_probes(prefixes, txt)[source]

Collect {ref: {param: value}} from printed @m...[param] probes.

Parameters:
Return type:

dict[str, dict[str, float]]

circuitgenome.sizer.shared.spice.op._FD_SPLIT_FRAC = 0.2

Largest |V(outp) − V(outn)| (fraction of the supply) an FD .op may show at zero differential input before the verdict is “railed”: a split output CM is the signature of an unregulated output stage latching apart.

circuitgenome.sizer.shared.spice.op._read_op_fd(name, ports, body, topo, result, tech, spec)[source]

FD .op in the metric benches’ DC state (#162, rig per #165).

Inputs and vcm_ref sit at Vcm with no feedback ties — post-#165 the CMFB owns the output CM, and this is exactly the DC state the FD benches measure in (_loop_fb’s FD branch anchors the inputs the same way). The old bench-tie network (outp``→``inn/outn``→``inp) is bistable against a real CM loop and converges to its degenerate all-off solution at tight headroom, falsely condemning healthy circuits. Verdict "railed" when either output leaves the 0.1–0.9·Vdd window or the outputs split apart at zero differential input; otherwise the per-device operating points feed the usual starved/triode checks.

Parameters:
circuitgenome.sizer.shared.spice.op._op_bias_problems(op)[source]

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).

Parameters:

op (dict[str, dict[str, float]])

Return type:

tuple[list[str], list[str]]

circuitgenome.sizer.shared.spice.op.check_bias_soundness(netlist_text, result, tech, spec)[source]

SPICE-grounded DC bias verdict: (sound, reason).

Runs the DC .op (read_op_operating_point() — SE in unity feedback, FD with inputs/vcm_ref at Vcm, issue #162) and condemns the bias only on positive evidence: the operating point rails (no usable mid-rail bias; for FD also a split output CM) or, single-ended, a device is starved/triode. FD skips the per-device verdicts: with inputs at Vcm the CMFB amp’s tail (its inputs also sit at Vcm) and the main tail run in marginal triode by design at low supplies — degraded, still functional, and universal to the family — so a device-level condemnation would reject every low-voltage CMFB variant that measurably amplifies at this very operating point; a dead FD circuit rails/splits its outputs instead, which the .op verdict already catches (the benches quantify any marginality). Conservative by design: returns (True, None) when it cannot check (ngspice absent), so it only ever downgrades a feasible verdict.

Parameters:
Return type:

tuple[bool, str | None]

circuitgenome.sizer.shared.spice.op._bias_diagnostic(netlist_text, result, tech, spec)[source]

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).

Parameters:
Return type:

str | None

Entry point

Public entry point: run every metric testbench on a sized circuit.

circuitgenome.sizer.shared.spice.simulate.simulate_metrics(netlist_text, result, tech, spec, corner=None)[source]

Return SPICE-measured metrics, mirroring _evaluate_metrics keys.

Keys: power_w, gain_db, gbw_hz, phase_margin_deg, slew_rate_vps, output_swing_max_v, output_swing_min_v, cmrr_db, psrr_db. Missing/failed measurements are None (slew rate and output swing are single-ended-only; CMRR/PSRR need a measured differential gain first). corner overrides the PDK library corner (foundry techs only); None uses the tech’s nominal corner.

Parameters:
Return type:

dict[str, float | None]