DC Operating-Point Solve — literature review + design guidance

Research distilled for the pedalkernel WDF engine. Motivating problem: a multi-BJT discrete amp (Neve BA283) whose WDF DC operating point disagrees with ngspice — ngspice’s .op is provably not a fixed point of our DC equations (seeding it makes our solver walk away to a different root).

0. The diagnostic that reframes everything

Newton-Raphson cannot leave a true zero: if F(x*) = 0, a full NR step from x* has length zero. So if our solver moves away from a seeded ngspice op-point, then ngspice’s operating point is not a zero of our residual F — our DC equations differ from ngspice’s (device model, a Norton/source term, a stamp sign, or a Vt/temperature mismatch). This is exactly the Norton/source-term confusion flagged in our solver-convention memory (I_port = a/(2Rp) + i_device).

Consequence: root-selection aids (.nodeset/.ic, homotopy) only steer NR among the roots of your own equations; they cannot rescue a formulation whose root set doesn’t contain the physical bias. Verify F(ngspice_op) ≈ 0 first; if not, fix the equations before touching convergence/root strategy.

1. The core algorithm

2. Why plain NR fails on junctions, and the aids (in order tried)

Junction current i = Is(exp(v/Vt)−1) changes by e per ~26 mV → linear extrapolation overshoots / overflows → oscillation/divergence. SPICE layers:

  1. Junction/voltage limiting (always on; pnjlim, limvds): clamp per-iter ΔVbe near the critical voltage vcrit = Vt·ln(Vt/(√2·Is)). (This is #185’s pnjlim.) Cures single-step overshoot; not global convergence by itself.
  2. Gmin stepping (homotopy): shunt every node to ground with gmin, ramp it large → tiny warm-starting each solve. Cures high-Z high-gain nets with no good cold basin.
  3. Source/supply stepping (continuation): ramp independent sources 0 → nominal, warm-starting each step. At 0 V everything is 0 (trivial root); the bias propagates stage-by-stage as rails rise. Textbook fit for multi-stage discrete amps. Doubles as the correct-branch selector (tracks the power-on branch).
  4. Pseudo-transient continuation (“dynamic gmin”): attach fictitious 1 F caps, integrate a damped transient to steady state. Most robust, slowest; last resort. Convergence theory: Kelley & Keyes, SIAM J. Numer. Anal. 35(2), 1998.

Representative order (SIMetrix): junction-init NR → source stepping → diagonal gmin → junction gmin → pseudo-transient. ngspice: NR(+limiting) → gmin → source → transient op. All are continuation/homotopy methods parameterizing F by a scalar λ and tracking from an easy λ to the real circuit.

3. Root selection in multi-stable circuits

NR lands the root in its starting basin. Bistable circuits have ≥2 stable nodes + an unstable saddle between them; SPICE can report a saddle or the wrong branch.

4. Compile-time vs runtime (the VA/WDF consensus)

The DC operating point is a property of topology + bias network, not the audio signal. Solve it once (offline/at instantiation); seed it; do NOT re-find it at runtime. Runtime = a local per-sample correction warm-started from the previous sample (~1 NR step), not a global homotopy.

Recommendation for pedalkernel

  1. Compile/instantiation time: solve the group DC op-point with full robust machinery — NR + junction limiting (have pnjlim) + source stepping from 0 V (primary globalizer and correct-branch selector for discrete amps), gmin / pseudo-transient as fallbacks. Afford 150+ iters here; it’s off the audio thread. This is solve_bjt_group_dc_qpoint’s job.
  2. Persist that op-point as the seed: initial wave variables, cap states, and the linearization/tabulation point.
  3. Runtime: never re-find DC. Run audio as a perturbation; if a port needs per-sample iteration, warm-start from last sample (limiting still on); prefer explicit scattering where possible.
  4. Trade-off: solve-once-and-seed is the sweet spot for fixed-topology pedals. A control that changes the bias network (sag, bias trim) → off-thread re-solve + re-seed, not a runtime continuation that risks the audio deadline.

Two-layer plan for the BA283 (and the discrete-amp family)

2026-07-01 — the op is a discrete-time REPELLER; confirmed cause + cure design

Follow-up to the map-Jacobian probe (ba283_persample_map_jacobian, PK_BA283_JMAP=1). The probe linearizes the WDF per-sample map at ngspice’s correct op (servo OFF, full op{}+nodes{} seed) and prints the cap-state Jacobian Jcap; offline numpy.linalg.eig gives the spectral radius ρ.

Baseline reproduced (this branch): ρ(Jcap) = 2743, dominant eigenvector on Cmil (0.67) ⊕ Ccmp (0.74) (the 220p/330p HF caps around TR1’s Miller loop); the other two caps are benign (Cfb small; Cin = 10µF gives λ ≈ 1.000, a marginal coupling-cap mode). One-sample drift of the seeded op ≈ 4.7 V; static per-port DC-balance residual max|F(seed)| = 0.0404 V at the TR1-collector port. So the correct op is a stable fixed point of the continuous circuit but a repeller of the WDF sample-to-sample map, localized to the two stiff HF caps.

Mechanism, pinned to code (confirmed, not hypothesised):

Why the earlier Backward-Euler damping only reached ρ ≈ 261 (10×, insufficient): softening the cap’s discretization constant while it remains a delayed wave source feeding known_a leaves the z⁻¹ in the loop — it only lowers the gain, it does not remove the pole. The delay must be removed, i.e. the stiff caps must be solved simultaneously with the devices.

Cure — Part 1, implicit stiff-cap fold (runtime-feasible; NO adaptor rebuild):

Move the stiff caps (C < 1 nF) out of the delayed passive_one_ports set and into the grouped-NR as coupled unknowns with a linear Backward-Euler companion device: i_c(v) = G_c·(v − v_prev), G_c = C·fs_eff, di/dv = G_c. Key point that makes this tractable without rebuilding the R-adaptor scattering S:

Concrete implementation surface:

  1. Extended system size m = n_nl + n_stiff. The solver needs the S sub-blocks over the stiff rows too — S[stiff][nl], S[stiff][stiff], S[stiff][other_passive], S[stiff][adapted] — which MultiNlStage does not currently retain (only the n_nl rows: s_nl, s_nl_passive, s_nl_adapted, stage.rs:4686-4693). Either (a) retain the extra rows at build time from the full matrix before from_full_matrix, or (b) reconstruct raw S at runtime from RTypeAdaptor.power_scattering + sqrt_r/inv_sqrt_r (tree.rs:207-214, currently private — add accessors; confirm the adaptor’s port ordering is [NL, passive, (vcc?), adapted], the same order from_full_matrix assumes).
  2. New solver path multi_port_nr_solve_grouped_with_caps_into (or append a LinearCapCompanion 1-port NlDeviceGroupIv after the real device groups). Keep it a separate function reached only when stiff caps are present ⇒ the whole non-BA283 corpus stays byte-identical.
  3. State: keep v_c in the cap’s existing wave_cache[slot].wave_state (re-interpreted as v_prev), updated to the solved v_c after the NR. This leaves the map-Jacobian probe’s cap enumeration unchanged, so re-running PK_BA283_JMAP=1 measures the new map’s ρ directly (before→after comparable).
  4. Gate: a multi-BJT (≥2 BJT device groups) DC-coupled-feedback group that contains ≥1 stiff cap (C < 1 nF). Non-matching stages take the existing path. Verify byte-identical corpus with the gate off.

Cure — Part 2, bias reformulation (unchanged direction): make F(ngspice_op) = 0 by fixing the Norton dc_bias source-term extraction at the feedback-coupled base (localized by the probe residual to the TR1 collector / Q3 base, R2-56k). HARD CONSTRAINT (documented regression trap): do not fold device small-signal Jacobians into the linear source term. With F(op)=0 and the implicit caps (ρ<1), the op is a stable discrete fixed point ⇒ the DC servo becomes removable.

2026-07-01 (final) — BA283 DC bias CLOSED (ΔIc 0.0%); AC gap localized to output loading

The “base-current precision” hypothesis was tested and the DC gap fully closed — but the mechanism was NOT a 1-2µA Ib formulation error. Diagnostic-first (pedalkernel-validate/tests/ba283_ib_diagnostic.rs, permanent):

  1. Ib diagnostic (Layer 1/2): our Gummel-Poon Ib at ngspice’s exact .op junction voltages. The Ib EQUATIONS were already SPICE-exact (Ibe1/BF + ISE· (exp(vbe/(NE·Vt))−1) + Ibc1/BR + ISC-term, no qb division; RB/RE/RC terminal decomposition correct). The entire diff was thermal voltage: vt: 0.02585 (25 °C) vs ngspice’s TEMP=TNOM=27 °C with CODATA-2018 constants ⇒ Vt = 1.380649e-23·300.15/1.602176634e-19 ≈ 25.8649 mV. With that Vt the per-device diff collapses to |ΔIb| ≤ 0.0006 µA, |ΔIc| ≤ 0.001 % (gate was 0.2 µA). Fixed in model_lookup::bjt_from_spice (SPICE_VT_27C).
  2. Deck-KCL audit + standalone deck DC solve (Layer 3/4): with the exact model, a 6-node Newton solve of the DECK’s DC lands ngspice’s op to <0.1 mV — so the remaining compile/runtime offset was NETWORK, not device. Two bugs:
    • solve_bjt_group_dc_qpoint stamped the pot RU1 at the FULL 4.7k track (Potentiometer::resistance() = max_r) instead of position-scaled 2350 (the make_leaf/spqr_build 0.5 convention) → q-point ~4 mV off.
    • apply_bjt_dc_qpoint’s resolve() treated the input node as floating ⇒ Cin seeded + inverted at 0 V instead of −1.03 V. F(seed) was self-consistently 0 at compile but NOT a steady state of the cap dynamics: Cin charged over ~0.2 s and dragged the stage from the exact q-point to the starved root (Q3 0.6417→0.663, ΔIc +46/−75 %) — the cold-path twin of the nodes{}-seed Cin bug fixed in apply_cap_seed. Fixed: in/out nodes DC-reference ground; caps seeded with wdf_seed_dc_voltage (a=b=v) and passive_b re-read from wdf_reflected (mirrors the proven hold path).

Result: cold compile + silence, servo untouched OR PK_SERVO_DISABLE=1: Q3 Vbe pinned at 0.64167 bit-stable for 48 000 samples; bias_accuracy MATCH ΔVbe 0.0000 V, ΔIc 0.0 % on all three devices (verdict BIAS OK); F(ngspice_op) = 0.0001 V; ρ(Jcap) = 0.9994 < 1; deck-KCL at the settled root ≤ 0.016 µA.

AC verdict — the THD gap is NOT bias/device/solver: it is the missing output load. ngspice tran (0.1 V @ 1 kHz) shows the −16 dB THD comes from Q1/Q2 being driven into cutoff clipping every cycle (vbe1 swings +0.547→−0.152 V) by the 10k load current demand through Cout. Our compiled BA283 puts Cout+RL in a downstream stage whose impedance never reflects back into the MultiNl stage — and ngspice with RL=1G reproduces our sim to 3-4 decimals (vbe swings identical; vout ±0.94 vs our ±0.92; THD −52.4 vs our −52.9 dB; fundamental 0.938 vs 0.926 V). So the WDF engine is now quantitatively exact at the topology it simulates; the remaining ac_accuracy LEVEL +2.7 dB / ΔTHD ~37 dB / tilt ~4.8 dB is entirely the cross-stage output-loading architecture gap (same family as the LA-2A GAP F transformer step-down). Fix path: reflect the downstream stage’s input impedance into the NL group (or fuse Cout/RL into it) — a compiler partitioning/boundary change, out of scope for the bias workstream.

2026-07-01 (superseded) — DC-solve root: Early-effect model fix + residual localization

Pursuing the remaining Layer-B gap (compile-time BJT DC solve lands Q3 over- / Q1-Q2 under-conducting, ΔIc 46-74% vs ngspice). Decisive diagnostics (temporary, now removed):

  1. Model vs ngspice at ngspice’s exact op (bjt_currents_terminal at the .op voltages): our Ic matched ngspice to 1-13%. The 13% (BC184C @ Vce=18.8) traced to the base-charge Early factor being linearized: q1 = 1 + vbc/VAF + vbe/VAR instead of SPICE’s q1 = 1/(1 − vbc/VAF − vbe/VAR). At vbc/VAF≈−0.37 the linear form over-predicts Ic ~15%. Fixed (commit fix(bjt): SPICE Gummel-Poon base-charge q1): all three devices now match ngspice Ic to ~1%; corpus lib failure set byte-identical (79); BA283 fold ρ=0.9994 and servo-OFF hold (ΔVbe 0.0054V) preserved; cap-seeded residual 0.0402→0.0344V.

  2. Nodal KCL residual at ngspice’s node voltages (solve_bjt_group_dc_qpoint, PK_DC_DIAG_NODES): after the q1 fix, ngspice’s op is a ~2µA root of our nodal equations (the resistor network — incl. RU1 4.7k pot as a single a-b edge and Rfb 56k — is complete and correct; the earlier “1mA” output-node imbalance was a diagnostic node-mapping artifact at R7’s midpoint). Our solve converges to a TRUE root (KCL 2.5e-11) that sits ~40mV away, starving the Q1/Q2 Darlington.

Why it’s not fully closed: the residual is dominated by base-current (ISE/ISC recombination) — BC184C runs β≈2-6 here, so Ib is recombination- not transport-dominated — and a ~1-2µA Ib discrepancy is amplified by the 56k (Rfb) / 68k (R3) bias resistors and the shunt feedback into the ~40mV node offset (a 2µA error × 56k ≈ 110mV). Matching ngspice’s bias to <10% ΔIc therefore requires matching the base current to <~0.2µA — a base-current-recombination calibration that is a deeper, global-BJT change with regression risk, deferred. The collector model and the nodal formulation are now correct; the gap is base-current precision.

2026-07-01 (later) — LANDED: implicit stiff-cap fold + seed self-consistency → ρ<1

Both parts implemented and validated against the probe. ρ(Jcap) = 0.9994 < 1 (was 2743). The dominant eigenvalues collapsed from [2743, −312, 154, 0.9998] to [0.002, 0.014, 0.881, 0.9994]: the Cmil ⊕ Ccmp stiff modes are gone; the surviving 0.9994 is the benign Cin coupling-cap mode (the marginal λ≈1.000 the baseline already had).

Gate results: ρ=0.9994 (✓<1); servo-OFF hold: HOLDS, max|ΔVbe|=0.0064 V over 48000 samples (was 4.73 V drift) ⇒ servo removable; AC LEVEL +2.40 dB (was −25.13); bias residual 0.0402 V (Layer A→B); corpus 1050/79 unregressed (79 = baseline, no fold/seed-related failures). Still open (Layer B / deeper): AC THD −36 vs −16 dB and tilt 5 dB, and bias MATCH ΔIc 46-73% (Q1/Q2 starved vs ngspice) — the compiler’s own BJT DC solve (dc_qpoint_v) still lands a root ~0.04 V off ngspice’s device operating point; the fold makes that op stable and the seed self-consistent, but closing ΔIc needs the compile-time DC solve to match ngspice (source-stepping / a better group DC solve), not the runtime map. Do not tune Rp/k_p/BE-damping to move THD; that masks.

2026-06-30 — the “curvature loss” is the DC bias, not the runtime embedding

Investigation of the BA283 large-signal under-distortion (THD −36 vs ngspice −16 dB @1 kHz, h2 ~20 dB weak, ratio dIc/(gm·dVbe)=0.547). The hypothesis under test was that the runtime WDF port embedding attenuates the reflected nonlinear wave (MultiNlStage/grouped-NR). Instrumented and DISPROVEN as a runtime solver bug:

The divergence is entirely the quiescent DC operating point (bias_accuracy):

devVbe ours/ngVce ours/ngIc ours/ngΔIc
Q3 (TR1 in)0.663 / 0.6423.61 / 5.35379 / 259 µA+46 %
Q1 (TR2 drv)0.498 / 0.54019.8 / 18.85.0 / 19.1 µA−74 %
Q2 (TR3 out)0.376 / 0.40619.9 / 19.212.1 / 36.7 µA−67 %

Q3 (input) over-conducts; the output Darlington Q1/Q2 starve. F(ngspice_op) is ~40 mV/port — “small”, but on an exp junction 40 mV ≈ 4.6× current, which is the ±50–70 % Ic error. So ngspice’s op is not a fixed point of our DC eqns (§0), confirmed empirically: with the DC servo OFF the solver walks away and the stage collapses (AC level −38 dB). At the wrong bias the large-signal curvature/compression is ~10× too weak while small-signal nearly matches (a gm-starvation cancellation, per the servo note in rigid/general.rs).

Verdict: DEFER — not a clean localized runtime fix. Root cause = Layer A above (the DC-bias formulation / Norton dc_bias source term at the feedback-coupled base). Fix path unchanged: make F(ngspice_op) ≈ 0, then Layer B source-stepping. Do not tune k_p/Rp to move the AC metric — it masks a starved bias (tracked: bd pedalkernel-opi6).

Primary sources