Skip to main content

dune_core/
diode.rs

1//! Junction diode, the 3f5 model (`dev/dio/dioload.c`, `diotemp.c`,
2//! `diosetup.c`, `dioconv.c`, `diotrunc.c`, `devsup.c`).
3//!
4//! DC current (`dioload.c`, with `vte = N·k·T/q`):
5//!
6//! - `vd >= -3·vte`:        `cd = csat·(exp(vd/vte) - 1) + gmin·vd`
7//! - no/pre-breakdown:      `cd = -csat·(1 + (3·vte/(vd·e))³) + gmin·vd`
8//! - below breakdown:       `cd = -csat·exp(-(bv+vd)/vte) + gmin·vd`
9//!
10//! Depletion + diffusion charge (transient/AC):
11//!
12//! - `vd < FC·VJt`: `qd = TT·cd + VJ·cz·(1-arg·sarg)/(1-M)`,
13//!   `capd = TT·gd + cz·sarg`, `sarg = (1-vd/VJ)^-M`
14//! - else the `f2`/`f3` linearized form of `dioload.c`.
15
16use crate::consts::{BOLTZ, CHARGE, KOVERQ, REFTEMP, ROOT2};
17use crate::disto::{
18    DistoCtx, DistoKernels, DistoMode, DistoSetupCtx, d1i2f1, d1i2f12, d1i3f1, d1if12, d1n2f1,
19    d1n2f12, d1n3f1, d1nf12,
20};
21use crate::matrix::{AnKind, InitMode, LoadCtx, MatrixBuilder, Slot, TRASH, Var};
22
23/// `.model … d(…)` parameters, 3f5 names and defaults
24/// (`spice.txt` §Junction Diodes; `diompar.c`).
25#[derive(Clone, Debug)]
26pub struct DiodeModel {
27    /// `IS`: saturation current, default 1e-14 A.
28    pub is: f64,
29    /// `RS`: ohmic resistance, default 0.
30    pub rs: f64,
31    /// `N`: emission coefficient, default 1.
32    pub n: f64,
33    /// `TT`: transit time, default 0.
34    pub tt: f64,
35    /// `CJO`: zero-bias junction capacitance, default 0.
36    pub cjo: f64,
37    /// `VJ`: junction potential, default 1 V.
38    pub vj: f64,
39    /// `M`: grading coefficient, default 0.5 (clamped to 0.9).
40    pub m: f64,
41    /// `EG`: activation energy, default 1.11 eV (clamped to ≥ 0.1).
42    pub eg: f64,
43    /// `XTI`: saturation-current temperature exponent, default 3.
44    pub xti: f64,
45    /// `KF`/`AF`: flicker noise (noise analysis only).
46    pub kf: f64,
47    pub af: f64,
48    /// `FC`: forward-bias depletion coefficient, default 0.5
49    /// (clamped to 0.95).
50    pub fc: f64,
51    /// `BV`: reverse breakdown voltage (positive number), unset by
52    /// default.
53    pub bv: Option<f64>,
54    /// `IBV`: current at breakdown voltage, default 1e-3 A.
55    pub ibv: f64,
56    /// `TNOM` override in kelvin (card value is °C).
57    pub tnom: Option<f64>,
58}
59
60impl Default for DiodeModel {
61    fn default() -> Self {
62        DiodeModel {
63            is: 1e-14,
64            rs: 0.0,
65            n: 1.0,
66            tt: 0.0,
67            cjo: 0.0,
68            vj: 1.0,
69            m: 0.5,
70            eg: 1.11,
71            xti: 3.0,
72            kf: 0.0,
73            af: 1.0,
74            fc: 0.5,
75            bv: None,
76            ibv: 1e-3,
77            tnom: None,
78        }
79    }
80}
81
82/// Temperature-adjusted per-instance values (`DIOtemperature`), plus
83/// the model-level `f2`/`f3` of `diosetup.c`.
84pub(crate) struct DiodeTemp {
85    pub csat: f64,
86    pub gspr: f64,
87    /// `vt = CONSTKoverQ·T` (`DIOdSetup` uses it in breakdown).
88    pub vt: f64,
89    pub vte: f64,
90    pub vcrit: f64,
91    pub brkdwn: Option<f64>,
92    /// `DIOtJctCap·area` is applied at load; store per-area value.
93    pub t_jct_cap: f64,
94    pub t_dep_cap: f64,
95    pub t_f1: f64,
96    pub f2: f64,
97    pub f3: f64,
98    /// Model values after the setup clamps.
99    pub m: f64,
100    pub vj: f64,
101    pub tt: f64,
102    /// Messages for the caller to surface as warnings.
103    pub notes: Vec<String>,
104}
105
106/// `egfet(T)` as `diotemp.c` computes it.
107fn egfet(t: f64) -> f64 {
108    1.16 - (7.02e-4 * t * t) / (t + 1108.0)
109}
110
111impl DiodeTemp {
112    /// `DIOtemperature` (`diotemp.c`) + the `diosetup.c` clamps.
113    /// `area` is the instance AREA and `mult` the ngspice multiplier
114    /// `m` (1 on cards): both scale the saturation current and the
115    /// series conductance, but only `area` enters the breakdown match
116    /// (`IBV` is absolute, `M·IBV` in ngspice).
117    #[allow(clippy::too_many_arguments)]
118    pub fn new(
119        name: &str,
120        model: &DiodeModel,
121        area: f64,
122        mult: f64,
123        temp: f64,
124        default_tnom: f64,
125        reltol: f64,
126    ) -> Self {
127        let mut notes = Vec::new();
128        let mut m = model.m;
129        if m > 0.9 {
130            notes.push(format!(
131                "{name}: grading coefficient too large, limited to 0.9"
132            ));
133            m = 0.9;
134        }
135        let mut eg = model.eg;
136        if eg < 0.1 {
137            notes.push(format!(
138                "{name}: activation energy too small, limited to 0.1"
139            ));
140            eg = 0.1;
141        }
142        let mut fc = model.fc;
143        if fc > 0.95 {
144            notes.push(format!("{name}: coefficient Fc too large, limited to 0.95"));
145            fc = 0.95;
146        }
147        let xfc = f64::ln(1.0 - fc);
148        let f2 = f64::exp((1.0 + m) * xfc);
149        let f3 = 1.0 - fc * (1.0 + m);
150
151        let tnom = model.tnom.unwrap_or(default_tnom);
152        let vt = KOVERQ * temp;
153        let vtnom = KOVERQ * tnom;
154
155        // Junction potential and capacitance temperature adjustment
156        // ("this part gets really ugly").
157        let fact2 = temp / REFTEMP;
158        let eg_t = egfet(temp);
159        let arg = -eg_t / (2.0 * BOLTZ * temp) + 1.1150877 / (BOLTZ * (REFTEMP + REFTEMP));
160        let pbfact = -2.0 * vt * (1.5 * f64::ln(fact2) + CHARGE * arg);
161        let eg_nom = egfet(tnom);
162        let arg1 = -eg_nom / (BOLTZ * 2.0 * tnom) + 1.1150877 / (2.0 * BOLTZ * REFTEMP);
163        let fact1 = tnom / REFTEMP;
164        let pbfact1 = -2.0 * vtnom * (1.5 * f64::ln(fact1) + CHARGE * arg1);
165        let pbo = (model.vj - pbfact1) / fact1;
166        let gmaold = (model.vj - pbo) / pbo;
167        let mut t_jct_cap = model.cjo / (1.0 + m * (400e-6 * (tnom - REFTEMP) - gmaold));
168        let t_jct_pot = pbfact + fact2 * pbo;
169        let gmanew = (t_jct_pot - pbo) / pbo;
170        t_jct_cap *= 1.0 + m * (400e-6 * (temp - REFTEMP) - gmanew);
171
172        let t_sat_cur = model.is
173            * f64::exp(
174                ((temp / tnom) - 1.0) * eg / (model.n * vt)
175                    + model.xti / model.n * f64::ln(temp / tnom),
176            );
177        let t_f1 = t_jct_pot * (1.0 - f64::exp((1.0 - m) * xfc)) / (1.0 - m);
178        let t_dep_cap = fc * t_jct_pot;
179        let vte = model.n * vt;
180        let vcrit = vte * f64::ln(vte / (ROOT2 * t_sat_cur));
181        let gspr = if model.rs == 0.0 { 0.0 } else { 1.0 / model.rs } * area * mult;
182
183        // Match the exponential and linear breakdown regions: find
184        // `xbv` with `IS·(exp((BV−xbv)/(N·vt)) − 1 + xbv/vt) = IBV`, so
185        // that the breakdown branch of the load,
186        // `cd = −csat·exp(−(xbv+vd)/(N·vt))`, carries IBV at `vd = −BV`.
187        // Two corrections of `diotemp.c` that ngspice-47 carries
188        // (corpus/KNOWN_DIFFS.md, gates in `regress/diobv_*`):
189        // - the emission coefficient: the 3f5 matches with plain `vt`
190        //   while its load uses `N·vt`, so for `N ≠ 1` the current at
191        //   −BV is `IS·(IBV/IS)^(1/N)`, not IBV; ngspice's `NBV·vt`
192        //   (`NBV` defaults to `N`, `diosetup.c`) is used here.
193        // - the area: the 3f5 matches the per-area `DIOtSatCur` against
194        //   the absolute IBV, so the current at −BV is `AREA·IBV`
195        //   (`spice.txt` scales only IS/RS/CJO); ngspice matches
196        //   `IS·AREA·M` against `M·IBV`.
197        let brkdwn = model.bv.map(|bv| {
198            let mut cbv = model.ibv;
199            let sat = t_sat_cur * area;
200            if cbv < sat * bv / vt {
201                cbv = sat * bv / vt;
202                notes.push(format!(
203                    "{name}: breakdown current increased to {cbv:.3e} to resolve \
204                     incompatibility with specified saturation current"
205                ));
206                bv
207            } else {
208                let tol = reltol * cbv;
209                let vtebrk = model.n * vt;
210                let mut xbv = bv - vtebrk * f64::ln(1.0 + cbv / sat);
211                let mut matched = false;
212                for _ in 0..25 {
213                    xbv = bv - vtebrk * f64::ln(cbv / sat + 1.0 - xbv / vt);
214                    let xcbv = sat * (f64::exp((bv - xbv) / vtebrk) - 1.0 + xbv / vt);
215                    if (xcbv - cbv).abs() <= tol {
216                        matched = true;
217                        break;
218                    }
219                }
220                if !matched {
221                    notes.push(format!(
222                        "{name}: unable to match forward and reverse diode regions: \
223                         bv = {xbv:.5e}, ibv = {cbv:.3e}"
224                    ));
225                }
226                xbv
227            }
228        });
229
230        DiodeTemp {
231            csat: t_sat_cur * area * mult,
232            gspr,
233            vt,
234            vte,
235            vcrit,
236            brkdwn,
237            t_jct_cap,
238            t_dep_cap,
239            t_f1,
240            f2,
241            f3,
242            m,
243            vj: model.vj,
244            tt: model.tt,
245            notes,
246        }
247    }
248}
249
250pub(crate) struct DiodeI {
251    pos: Var,
252    neg: Var,
253    /// Internal node past RS; equals `pos` when RS is zero.
254    pos_prime: Var,
255    t: DiodeTemp,
256    area: f64,
257    off: bool,
258    /// `IC=` initial junction voltage, for UIC.
259    init_cond: f64,
260    /// State offsets: voltage, current, conductance, capCharge,
261    /// capCurrent (`DIOvoltage` …, `diosetup.c`).
262    st: usize,
263    slots: [Slot; 7],
264    /// `DIOdCoeffs` (`diodefs.h`): `id_x2, id_x3, cdif_x2, cdif_x3,
265    /// cjnc_x2, cjnc_x3`, set by `DIOdSetup`.
266    d: DioDistoCoeffs,
267    /// What `DIOtemp` is recomputed from when `.sens` perturbs a
268    /// parameter: the model, the instance temperature (K), the
269    /// ngspice multiplier `m` (1 on cards; it scales like the area
270    /// except in the breakdown match, where `M·IBV` cancels it)
271    /// and the circuit `TNOM`/`RELTOL`.
272    sens: DiodeSensBase,
273}
274
275/// The inputs of [`DiodeTemp::new`] a sensitivity perturbation can
276/// move.
277pub(crate) struct DiodeSensBase {
278    pub name: String,
279    pub model: DiodeModel,
280    pub temp: f64,
281    pub m: f64,
282    pub default_tnom: f64,
283    pub reltol: f64,
284}
285
286/// The diode parameters `.sens` walks: ngspice-47's `DIOmPTable`
287/// order restricted to the entries the 3f5 model has (the AC-only
288/// `tt`/`cjo` only in an AC walk), then the whole `DIOpTable` walk.
289/// ngspice's own model extensions (`jsw`, `ikf`, `isr`, `jtun`, …)
290/// are not listed: several only switch on once *given*, so their
291/// ngspice sensitivities are artifacts of the perturbation itself
292/// (`corpus/KNOWN_DIFFS.md`). Of the instance entries only `temp`,
293/// `area` and `m` act on Dune's equations; `dtemp` is listed inert
294/// because it is inert in the C too — `temp` comes first in the
295/// table and perturbing it marks it given, after which `DIOtemp`
296/// ignores `dtemp` — and `pj`/`w`/`l`/`lm`/`lp`/`wm`/`wp` act only
297/// through the sidewall/level-3 terms the 3f5 model lacks (zero at
298/// their defaults).
299const DIO_SENS_DC: &[&str] = &[
300    ":is", ":rs", ":n", ":vj", ":m", ":eg", ":xti", ":kf", ":af", ":fc", "_temp", "_dtemp",
301    "_area", "_pj", "_w", "_l", "_m", "_lm", "_lp", "_wm", "_wp",
302];
303const DIO_SENS_AC: &[&str] = &[
304    ":is", ":rs", ":n", ":tt", ":cjo", ":vj", ":m", ":eg", ":xti", ":kf", ":af", ":fc", "_temp",
305    "_dtemp", "_ic", "_area", "_pj", "_w", "_l", "_m", "_lm", "_lp", "_wm", "_wp",
306];
307
308/// Taylor coefficients of the diode nonlinearities at the operating
309/// point (`diodset.c`): conductance (`id_x*`), diffusion (`cdif_x*`)
310/// and junction (`cjnc_x*`) capacitance, 2nd and 3rd order.
311#[derive(Clone, Copy, Debug, Default)]
312struct DioDistoCoeffs {
313    id_x2: f64,
314    id_x3: f64,
315    cdif_x2: f64,
316    cdif_x3: f64,
317    cjnc_x2: f64,
318    cjnc_x3: f64,
319}
320
321const VOLTAGE: usize = 0;
322const CURRENT: usize = 1;
323const CONDUCT: usize = 2;
324const CAP_CHARGE: usize = 3;
325
326impl DiodeI {
327    #[allow(clippy::too_many_arguments)]
328    pub fn new(
329        pos: Var,
330        neg: Var,
331        pos_prime: Var,
332        t: DiodeTemp,
333        area: f64,
334        off: bool,
335        init_cond: f64,
336        st: usize,
337        sens: DiodeSensBase,
338    ) -> Self {
339        DiodeI {
340            pos,
341            neg,
342            pos_prime,
343            t,
344            area,
345            off,
346            init_cond,
347            st,
348            slots: [TRASH; 7],
349            d: DioDistoCoeffs::default(),
350            sens,
351        }
352    }
353
354    /// The current value of a sensitivity parameter.
355    fn sens_value(&self, suffix: &str) -> f64 {
356        let m = &self.sens.model;
357        match suffix {
358            ":is" => m.is,
359            ":rs" => m.rs,
360            ":n" => m.n,
361            ":tt" => m.tt,
362            ":cjo" => m.cjo,
363            ":vj" => m.vj,
364            ":m" => m.m,
365            ":eg" => m.eg,
366            ":xti" => m.xti,
367            ":kf" => m.kf,
368            ":af" => m.af,
369            ":fc" => m.fc,
370            // `DIOask(DIO_TEMP)` reports °C.
371            "_temp" => self.sens.temp - crate::consts::CTOK,
372            "_area" => self.area,
373            "_m" => self.sens.m,
374            "_ic" => self.init_cond,
375            _ => 0.0,
376        }
377    }
378}
379
380impl crate::devices::Device for DiodeI {
381    fn par_load(&self) -> bool {
382        true
383    }
384
385    /// `DIOgetic` (`diogetic.c`): `if(!DIOinitCondGiven) DIOinitCond
386    /// = rhs[pos] − rhs[neg]`. `DIO_IC` in `dioparam.c` stores the
387    /// value without ever raising `DIOinitCondGiven` (3f5 and
388    /// ngspice-47 alike), so under UIC the card's `IC=` is always
389    /// replaced by the node values (0 without a `.ic`/`.nodeset`).
390    fn set_ic(&mut self, rhs: &[f64]) {
391        self.init_cond = rhs[self.pos] - rhs[self.neg];
392    }
393
394    /// `DIOconvTest`: predicted vs computed junction current,
395    /// evaluated at the previous solution (`CKTrhsOld`).
396    fn conv_test(&self, ctx: &crate::matrix::ConvCtx) -> bool {
397        let vd = ctx.sol[self.pos_prime] - ctx.sol[self.neg];
398        let delvd = vd - ctx.states.get(0, self.st + VOLTAGE);
399        let cd = ctx.states.get(0, self.st + CURRENT);
400        let cdhat = cd + ctx.states.get(0, self.st + CONDUCT) * delvd;
401        let tol = ctx.reltol * f64::max(cdhat.abs(), cd.abs()) + ctx.abstol;
402        (cdhat - cd).abs() <= tol
403    }
404
405    fn alloc(&mut self, mb: &mut MatrixBuilder) {
406        self.slots = [
407            mb.slot(self.pos, self.pos),
408            mb.slot(self.neg, self.neg),
409            mb.slot(self.pos_prime, self.pos_prime),
410            mb.slot(self.pos, self.pos_prime),
411            mb.slot(self.neg, self.pos_prime),
412            mb.slot(self.pos_prime, self.pos),
413            mb.slot(self.pos_prime, self.neg),
414        ];
415    }
416
417    fn load(&mut self, ctx: &mut LoadCtx) {
418        let vte = self.t.vte;
419        let csat = self.t.csat;
420        let st = self.st;
421        let mut check = true;
422
423        let vd = match ctx.mode {
424            InitMode::Smsig => ctx.states.get(0, st + VOLTAGE),
425            InitMode::Tran => ctx.states.get(1, st + VOLTAGE),
426            InitMode::Jct if ctx.kind == AnKind::TranOp && ctx.uic => self.init_cond,
427            InitMode::Jct if self.off => 0.0,
428            InitMode::Jct => self.t.vcrit,
429            InitMode::Fix if self.off => 0.0,
430            _ => {
431                let mut vd = if ctx.mode == InitMode::Pred {
432                    // `dioload.c` (`#ifndef PREDICTOR`): carry the
433                    // last timepoint's operating point into state0
434                    // and extrapolate — and the extrapolation is then
435                    // *limited* like any other iterate, against that
436                    // carried voltage.
437                    for o in [VOLTAGE, CURRENT, CONDUCT] {
438                        let v = ctx.states.get(1, st + o);
439                        ctx.states.set(0, st + o, v);
440                    }
441                    ctx.pred(st + VOLTAGE)
442                } else {
443                    ctx.sol[self.pos_prime] - ctx.sol[self.neg]
444                };
445                // Per-iteration junction limiting (`DEVpnjlim`, with
446                // the separate breakdown-region branch).
447                let vd_old = ctx.states.get(0, st + VOLTAGE);
448                match self.t.brkdwn {
449                    Some(bv) if vd < f64::min(0.0, -bv + 10.0 * vte) => {
450                        let vdtemp =
451                            pnjlim(-(vd + bv), -(vd_old + bv), vte, self.t.vcrit, &mut check);
452                        vd = -(vdtemp + bv);
453                    }
454                    _ => {
455                        vd = pnjlim(vd, vd_old, vte, self.t.vcrit, &mut check);
456                    }
457                }
458                vd
459            }
460        };
461
462        // DC current and conductance by region.
463        let (cd0, gd0);
464        if vd >= -3.0 * vte {
465            let evd = f64::exp(vd / vte);
466            cd0 = csat * (evd - 1.0) + ctx.gmin * vd;
467            gd0 = csat * evd / vte + ctx.gmin;
468        } else if self.t.brkdwn.is_none() || vd >= -self.t.brkdwn.unwrap_or(0.0) {
469            let arg = 3.0 * vte / (vd * crate::consts::E);
470            let arg = arg * arg * arg;
471            cd0 = -csat * (1.0 + arg) + ctx.gmin * vd;
472            gd0 = csat * 3.0 * arg / vd + ctx.gmin;
473        } else {
474            let bv = self.t.brkdwn.unwrap_or(0.0);
475            let evrev = f64::exp(-(bv + vd) / vte);
476            cd0 = -csat * evrev + ctx.gmin * vd;
477            gd0 = csat * evrev / vte + ctx.gmin;
478        }
479        let mut cd = cd0;
480        let mut gd = gd0;
481
482        // Charge storage (transient, UIC transient op, and the
483        // small-signal pass before `.ac`).
484        if ctx.kind == AnKind::Tran
485            || (ctx.kind == AnKind::TranOp && ctx.uic)
486            || ctx.mode == InitMode::Smsig
487        {
488            let czero = self.t.t_jct_cap * self.area;
489            #[allow(clippy::needless_late_init)] // mirrors dioload.c
490            let capd;
491            if vd < self.t.t_dep_cap {
492                let arg = 1.0 - vd / self.t.vj;
493                let sarg = f64::exp(-self.t.m * f64::ln(arg));
494                ctx.states.set(
495                    0,
496                    st + CAP_CHARGE,
497                    self.t.tt * cd + self.t.vj * czero * (1.0 - arg * sarg) / (1.0 - self.t.m),
498                );
499                capd = self.t.tt * gd + czero * sarg;
500            } else {
501                let czof2 = czero / self.t.f2;
502                ctx.states.set(
503                    0,
504                    st + CAP_CHARGE,
505                    self.t.tt * cd
506                        + czero * self.t.t_f1
507                        + czof2
508                            * (self.t.f3 * (vd - self.t.t_dep_cap)
509                                + (self.t.m / (self.t.vj + self.t.vj))
510                                    * (vd * vd - self.t.t_dep_cap * self.t.t_dep_cap)),
511                );
512                capd = self.t.tt * gd + czof2 * (self.t.f3 + self.t.m * vd / self.t.vj);
513            }
514
515            // `MODEINITSMSIG`: store the small-signal capacitance
516            // into the capCurrent slot and stop — no stamps
517            // (`dioload.c` does `continue` here; `DIOacLoad` reads
518            // it back as `xceq = capd·ω`).
519            if ctx.mode == InitMode::Smsig {
520                ctx.states.set(0, st + CAP_CHARGE + 1, capd);
521                return;
522            }
523            if ctx.kind == AnKind::Tran {
524                if ctx.mode == InitMode::Tran {
525                    let q0 = ctx.states.get(0, st + CAP_CHARGE);
526                    ctx.states.set(1, st + CAP_CHARGE, q0);
527                }
528                let (geq, ceq_int) = ctx.integrate(capd, st + CAP_CHARGE);
529                let _ = ceq_int;
530                gd += geq;
531                cd += ctx.states.get(0, st + CAP_CHARGE + 1);
532                if ctx.mode == InitMode::Tran {
533                    let c0 = ctx.states.get(0, st + CAP_CHARGE + 1);
534                    ctx.states.set(1, st + CAP_CHARGE + 1, c0);
535                }
536            }
537        }
538
539        // Convergence: limiting counts as non-convergence (the
540        // current-agreement test lives in `DIOconvTest` under
541        // NEWCONV and only fires with device bypass, which Dune does
542        // not implement — see docs/dune-core.md).
543        if !(ctx.mode == InitMode::Fix && self.off) && check {
544            ctx.noncon = true;
545        }
546
547        ctx.states.set(0, st + VOLTAGE, vd);
548        ctx.states.set(0, st + CURRENT, cd);
549        ctx.states.set(0, st + CONDUCT, gd);
550
551        let cdeq = cd - gd * vd;
552        ctx.add_rhs(self.neg, cdeq);
553        ctx.add_rhs(self.pos_prime, -cdeq);
554
555        let gspr = self.t.gspr;
556        ctx.add(self.slots[0], gspr);
557        ctx.add(self.slots[1], gd);
558        ctx.add(self.slots[2], gd + gspr);
559        ctx.add(self.slots[3], -gspr);
560        ctx.add(self.slots[4], -gd);
561        ctx.add(self.slots[5], -gspr);
562        ctx.add(self.slots[6], -gd);
563    }
564
565    fn trunc(&self, t: &crate::integ::TerrCtx, time_step: &mut f64) {
566        // `DIOtrunc`: `CKTterr` on the junction charge.
567        crate::integ::terr(self.st + CAP_CHARGE, t, time_step);
568    }
569
570    fn sens_params(&self, ac: bool) -> Option<Vec<crate::devices::SensParam>> {
571        let list = if ac { DIO_SENS_AC } else { DIO_SENS_DC };
572        Some(
573            list.iter()
574                .map(|&suffix| crate::devices::SensParam {
575                    suffix,
576                    value: self.sens_value(suffix),
577                })
578                .collect(),
579        )
580    }
581
582    /// `DIOmParam`/`DIOparam` + `DIOtemp`. The junction capacitance
583    /// and the stored small-signal values are not recomputed: the C
584    /// sets the zero-bias capacitance in `DIOsetup` and the AC load
585    /// reads `gd`/`capd` back from the state vector, so neither moves
586    /// under a perturbation there either.
587    fn sens_set(&mut self, suffix: &str, value: f64) {
588        let b = &mut self.sens;
589        match suffix {
590            ":is" => b.model.is = value,
591            // `DIOconductance = 1/RS` is set in `DIOsetup`, which the
592            // walk runs *before* the perturbation: moving RS changes
593            // nothing in the C, so it is inert here too.
594            ":n" => b.model.n = value,
595            ":tt" => b.model.tt = value,
596            ":cjo" => b.model.cjo = value,
597            ":vj" => b.model.vj = value,
598            ":m" => b.model.m = value,
599            ":eg" => b.model.eg = value,
600            ":xti" => b.model.xti = value,
601            ":kf" => b.model.kf = value,
602            ":af" => b.model.af = value,
603            ":fc" => b.model.fc = value,
604            "_temp" => b.temp = value + crate::consts::CTOK,
605            "_area" => self.area = value,
606            "_m" => b.m = value,
607            _ => return,
608        }
609        let b = &self.sens;
610        let t = DiodeTemp::new(
611            &b.name,
612            &b.model,
613            self.area,
614            b.m,
615            b.temp,
616            b.default_tnom,
617            b.reltol,
618        );
619        // Keep the setup-time capacitance/charge parameters.
620        self.t.csat = t.csat;
621        self.t.gspr = t.gspr;
622        self.t.vt = t.vt;
623        self.t.vte = t.vte;
624        self.t.vcrit = t.vcrit;
625        self.t.brkdwn = t.brkdwn;
626    }
627
628    /// `DIOnoise` (ngspice): thermal on RS, shot on the junction
629    /// current, flicker `KF·|Id/m|^AF/f·m`, then ngspice's three
630    /// sidewall generators — identically zero here (no `RSW` model,
631    /// the vectors exist in the golden all the same) — and the total.
632    fn noise_suffixes(&self) -> Option<&'static [&'static str]> {
633        Some(&["_rs", "_id", "_1overf", "_rsw", "_idsw", "_1overfsw", ""])
634    }
635
636    fn noise_dens(&self, ctx: &crate::matrix::NoiseCtx) -> Vec<f64> {
637        const N_MINLOG: f64 = 1e-38;
638        let cd = ctx.states.get(0, self.st + CURRENT);
639        let m = self.sens.m;
640        // `dionoise.c` (ngspice-47): RS at the instance temperature.
641        let rs = ctx.thermal_at(self.pos_prime, self.pos, self.t.gspr, self.sens.temp);
642        let id = ctx.shot(self.pos_prime, self.neg, cd);
643        let model = &self.sens.model;
644        let flicker = ctx.flicker(
645            self.pos_prime,
646            self.neg,
647            model.kf * f64::exp(model.af * f64::ln(f64::max((cd / m).abs(), N_MINLOG))) * m,
648            1.0,
649        );
650        vec![rs, id, flicker, 0.0, 0.0, 0.0, rs + id + flicker]
651    }
652
653    /// `DIOacLoad`: `gd` from the operating point plus `jω·capd`.
654    fn load_ac(&mut self, ctx: &mut crate::matrix::AcCtx) {
655        let gspr = self.t.gspr;
656        let geq = ctx.states.get(0, self.st + CONDUCT);
657        let xceq = ctx.states.get(0, self.st + CAP_CHARGE + 1) * ctx.omega;
658        ctx.add_g(self.slots[0], gspr);
659        ctx.add_g(self.slots[1], geq);
660        ctx.add_b(self.slots[1], xceq);
661        ctx.add_g(self.slots[2], geq + gspr);
662        ctx.add_b(self.slots[2], xceq);
663        ctx.add_g(self.slots[3], -gspr);
664        ctx.add_g(self.slots[4], -geq);
665        ctx.add_b(self.slots[4], -xceq);
666        ctx.add_g(self.slots[5], -gspr);
667        ctx.add_g(self.slots[6], -geq);
668        ctx.add_b(self.slots[6], -xceq);
669    }
670
671    /// `DIOdSetup` (`dev/dio/diodset.c`): the 2nd/3rd-order Taylor
672    /// coefficients at the operating point in `CKTrhsOld`.
673    fn disto_setup(&mut self, ctx: &DistoSetupCtx) -> Result<(), &'static str> {
674        let csat = self.t.csat;
675        let vt = self.t.vt;
676        let vte = self.t.vte;
677        let gmin = ctx.gmin;
678        let vd = ctx.sol[self.pos_prime] - ctx.sol[self.neg];
679
680        // Derivatives of the junction current.
681        let (g2, g3, cdiff2, cdiff3);
682        if vd >= -5.0 * vte {
683            let evd = f64::exp(vd / vte);
684            let gd = csat * evd / vte + gmin;
685            g2 = 0.5 * (gd - gmin) / vte;
686            cdiff2 = g2 * self.t.tt;
687            g3 = g2 / 3.0 / vte;
688            cdiff3 = g3 * self.t.tt;
689        } else if self.t.brkdwn.is_none_or(|bv| bv == 0.0 || vd >= -bv) {
690            // off
691            g2 = 0.0;
692            g3 = 0.0;
693            cdiff2 = 0.0;
694            cdiff3 = 0.0;
695        } else {
696            // Reverse breakdown ("why using csat instead of
697            // breakdowncurrent?" — the C's own question; kept).
698            let bv = self.t.brkdwn.unwrap_or(0.0);
699            let evrev = f64::exp(-(bv + vd) / vt);
700            let gd = csat * evrev / vt;
701            g2 = -gd / 2.0 / vt;
702            g3 = -g2 / 3.0 / vt;
703            cdiff2 = 0.0;
704            cdiff3 = 0.0;
705        }
706
707        // Junction (depletion) charge storage.
708        let czero = self.t.t_jct_cap * self.area;
709        let (cjunc2, cjunc3);
710        if czero != 0.0 {
711            let (m, vj) = (self.t.m, self.t.vj);
712            if vd < self.t.t_dep_cap {
713                let arg = 1.0 - vd / vj;
714                let sarg = f64::exp(-m * f64::ln(arg));
715                let cjunc1 = czero * sarg;
716                cjunc2 = cjunc1 / 2.0 / vj * m / arg;
717                cjunc3 = cjunc2 / 3.0 / vj / arg * (m + 1.0);
718            } else {
719                let czof2 = czero / self.t.f2;
720                cjunc2 = czof2 / 2.0 / vj * m;
721                cjunc3 = 0.0;
722            }
723        } else {
724            cjunc2 = 0.0;
725            cjunc3 = 0.0;
726        }
727
728        self.d = DioDistoCoeffs {
729            id_x2: g2,
730            id_x3: g3,
731            cdif_x2: cdiff2,
732            cdif_x3: cdiff3,
733            cjnc_x2: cjunc2,
734            cjnc_x3: cjunc3,
735        };
736        Ok(())
737    }
738
739    /// `DIOdisto` (`dev/dio/diodisto.c`), the load modes: the
740    /// memoryless current term plus `jω×` the charge term, both
741    /// contracted with the lower-order kernels of `v(pos') − v(neg)`.
742    fn disto_load(&self, ctx: &mut DistoCtx) {
743        let d = &self.d;
744        let (pp, nn) = (self.pos_prime, self.neg);
745        let h = &ctx.h;
746        let omega = ctx.omega;
747        let g2 = d.id_x2;
748        let g3 = d.id_x3;
749        let c2 = d.cdif_x2 + d.cjnc_x2;
750        let c3 = d.cdif_x3 + d.cjnc_x3;
751        let (r1h1x, i1h1x) = DistoKernels::branch(h.r1h1, h.i1h1, pp, nn);
752
753        let (temp, itemp) = match ctx.mode {
754            DistoMode::TwoF1 => {
755                let mut temp = d1n2f1(g2, r1h1x, i1h1x);
756                let mut itemp = d1i2f1(g2, r1h1x, i1h1x);
757                if c2 != 0.0 {
758                    temp += -omega * d1i2f1(c2, r1h1x, i1h1x);
759                    itemp += omega * d1n2f1(c2, r1h1x, i1h1x);
760                }
761                (temp, itemp)
762            }
763            DistoMode::ThrF1 => {
764                let (r2h11x, i2h11x) = DistoKernels::branch(h.r2h11, h.i2h11, pp, nn);
765                let mut temp = d1n3f1(g2, g3, r1h1x, i1h1x, r2h11x, i2h11x);
766                let mut itemp = d1i3f1(g2, g3, r1h1x, i1h1x, r2h11x, i2h11x);
767                if c2 != 0.0 {
768                    temp += -omega * d1i3f1(c2, c3, r1h1x, i1h1x, r2h11x, i2h11x);
769                    itemp += omega * d1n3f1(c2, c3, r1h1x, i1h1x, r2h11x, i2h11x);
770                }
771                (temp, itemp)
772            }
773            DistoMode::F1pF2 | DistoMode::F1mF2 => {
774                let (r1h2x, mut i1h2x) = DistoKernels::branch(h.r1h2, h.i1h2, pp, nn);
775                if ctx.mode == DistoMode::F1mF2 {
776                    // H1(−f2) = conj(H1(f2)).
777                    i1h2x = -i1h2x;
778                }
779                let mut temp = d1nf12(g2, r1h1x, i1h1x, r1h2x, i1h2x);
780                let mut itemp = d1if12(g2, r1h1x, i1h1x, r1h2x, i1h2x);
781                if c2 != 0.0 {
782                    temp += -omega * d1if12(c2, r1h1x, i1h1x, r1h2x, i1h2x);
783                    itemp += omega * d1nf12(c2, r1h1x, i1h1x, r1h2x, i1h2x);
784                }
785                (temp, itemp)
786            }
787            DistoMode::TwoF1mF2 => {
788                let (r1h2x, i1h2x) = DistoKernels::branch(h.r1h2, h.i1h2, pp, nn);
789                let i1hm2x = -i1h2x;
790                let (r2h11x, i2h11x) = DistoKernels::branch(h.r2h11, h.i2h11, pp, nn);
791                let (r2h1m2x, i2h1m2x) = DistoKernels::branch(h.r2h1m2, h.i2h1m2, pp, nn);
792                let mut temp = d1n2f12(
793                    g2, g3, r1h1x, i1h1x, r1h2x, i1hm2x, r2h11x, i2h11x, r2h1m2x, i2h1m2x,
794                );
795                let mut itemp = d1i2f12(
796                    g2, g3, r1h1x, i1h1x, r1h2x, i1hm2x, r2h11x, i2h11x, r2h1m2x, i2h1m2x,
797                );
798                if c2 != 0.0 {
799                    temp += -omega
800                        * d1i2f12(
801                            c2, c3, r1h1x, i1h1x, r1h2x, i1hm2x, r2h11x, i2h11x, r2h1m2x, i2h1m2x,
802                        );
803                    itemp += omega
804                        * d1n2f12(
805                            c2, c3, r1h1x, i1h1x, r1h2x, i1hm2x, r2h11x, i2h11x, r2h1m2x, i2h1m2x,
806                        );
807                }
808                (temp, itemp)
809            }
810        };
811
812        ctx.rhs_re[pp] -= temp;
813        ctx.rhs_im[pp] -= itemp;
814        ctx.rhs_re[nn] += temp;
815        ctx.rhs_im[nn] += itemp;
816    }
817
818    /// `DIOpzLoad`: the AC stamps at a general complex frequency.
819    fn load_pz(&mut self, ctx: &mut crate::matrix::PzCtx) -> Result<(), &'static str> {
820        let gspr = self.t.gspr;
821        let geq = ctx.states.get(0, self.st + CONDUCT);
822        let capd = ctx.states.get(0, self.st + CAP_CHARGE + 1);
823        ctx.add_g(self.slots[0], gspr);
824        ctx.add_g(self.slots[1], geq);
825        ctx.add_cap(self.slots[1], capd);
826        ctx.add_g(self.slots[2], geq + gspr);
827        ctx.add_cap(self.slots[2], capd);
828        ctx.add_g(self.slots[3], -gspr);
829        ctx.add_g(self.slots[4], -geq);
830        ctx.add_cap(self.slots[4], -capd);
831        ctx.add_g(self.slots[5], -gspr);
832        ctx.add_g(self.slots[6], -geq);
833        ctx.add_cap(self.slots[6], -capd);
834        Ok(())
835    }
836}
837
838/// `DEVpnjlim` (`dev/devsup.c`), in its ngspice form: the 3f5 only
839/// limits forward steps above `vcrit`; ngspice also clamps negative
840/// iterates at `−vold − 1` (coming from forward bias) or `2·vold − 1`
841/// (already reversed), setting `check` likewise. Measured against
842/// ngspice-47: it is what keeps the ltra_1/ltra_3 BJT driver and
843/// diode load converging at TMAX steps, and it tightens mosmem,
844/// rca3040 and bjtnoise.
845pub(crate) fn pnjlim(vnew: f64, vold: f64, vt: f64, vcrit: f64, check: &mut bool) -> f64 {
846    if vnew > vcrit && (vnew - vold).abs() > vt + vt {
847        *check = true;
848        if vold > 0.0 {
849            let arg = 1.0 + (vnew - vold) / vt;
850            if arg > 0.0 {
851                vold + vt * f64::ln(arg)
852            } else {
853                vcrit
854            }
855        } else {
856            vt * f64::ln(vnew / vt)
857        }
858    } else if vnew < 0.0 {
859        // ngspice's `DEVpnjlim` (not the 3f5's): reverse swings are
860        // limited too — to `−vold − 1` from forward bias, to
861        // `2·vold − 1` from reverse (corpus/KNOWN_DIFFS.md).
862        let arg = if vold > 0.0 {
863            -vold - 1.0
864        } else {
865            2.0 * vold - 1.0
866        };
867        if vnew < arg {
868            *check = true;
869            arg
870        } else {
871            *check = false;
872            vnew
873        }
874    } else {
875        *check = false;
876        vnew
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    #[test]
885    fn pnjlim_matches_the_c() {
886        let vt = 0.025852;
887        let vcrit = 0.6145;
888        let mut check = false;
889        let v = pnjlim(0.5, 0.49, vt, vcrit, &mut check);
890        assert_eq!(v, 0.5);
891        assert!(!check);
892        let v = pnjlim(5.0, 0.6, vt, vcrit, &mut check);
893        assert!(check);
894        assert!((v - (0.6 + vt * (1.0f64 + (5.0 - 0.6) / vt).ln())).abs() < 1e-15);
895        let v = pnjlim(5.0, 0.0, vt, vcrit, &mut check);
896        assert!(check);
897        assert!((v - vt * (5.0f64 / vt).ln()).abs() < 1e-15);
898        // ngspice's reverse limits.
899        let v = pnjlim(-3.0, 0.5, vt, vcrit, &mut check);
900        assert!(check);
901        assert_eq!(v, -1.5);
902        let v = pnjlim(-3.0, -0.5, vt, vcrit, &mut check);
903        assert!(check);
904        assert_eq!(v, -2.0);
905        let v = pnjlim(-1.0, -0.5, vt, vcrit, &mut check);
906        assert!(!check);
907        assert_eq!(v, -1.0);
908    }
909
910    #[test]
911    fn temp_adjust_is_identity_at_reftemp() {
912        let m = DiodeModel::default();
913        let t = DiodeTemp::new("d1", &m, 1.0, 1.0, 300.15, 300.15, 1e-3);
914        assert_eq!(t.csat, m.is);
915        let vte = m.n * KOVERQ * 300.15;
916        assert!((t.vcrit - vte * (vte / (ROOT2 * m.is)).ln()).abs() < 1e-15);
917        // At T == Tnom == REFTEMP the junction-potential adjustment
918        // collapses to the model values.
919        assert!((t.t_jct_cap - m.cjo).abs() < 1e-12);
920        assert!((t.t_dep_cap - m.fc * m.vj).abs() < 1e-12);
921        assert!(t.notes.is_empty());
922    }
923
924    /// The breakdown match (`diotemp.c`) with ngspice-47's two
925    /// corrections: the knee uses `N·vt` like the load, and the
926    /// absolute IBV is matched against `IS·AREA`. The load's
927    /// breakdown branch then carries IBV at `vd = −BV`; the 3f5 forms
928    /// put the knee 0.397 V (N = 1.47) and `vt·ln 10` (AREA = 10) off
929    /// (`regress/diobv_dc`: FAIL 495 / 197 with them, PASS 0.476).
930    #[test]
931    fn breakdown_carries_ibv_at_bv() {
932        let vt = KOVERQ * 300.15;
933        let cases = [
934            // (is, n, ibv, area, xbv of the 3f5 form)
935            (6.79e-18, 1.47, 1e-3, 1.0, 49.156_224_229_991_27),
936            (1e-14, 1.0, 1e-4, 10.0, 50.0 - vt * f64::ln(1e10)),
937        ];
938        for (is, n, ibv, area, xbv_3f5) in cases {
939            let m = DiodeModel {
940                is,
941                n,
942                bv: Some(50.0),
943                ibv,
944                ..DiodeModel::default()
945            };
946            let t = DiodeTemp::new("dz", &m, area, 1.0, 300.15, 300.15, 1e-3);
947            let xbv = t.brkdwn.expect("bv given");
948            let cd = t.csat * f64::exp(-(xbv - 50.0) / t.vte);
949            assert!((cd - ibv).abs() <= 1e-3 * ibv, "cd(-BV) = {cd:e}");
950            assert!(t.notes.is_empty());
951            let shift = xbv_3f5 - xbv;
952            let want = (n - 1.0) * vt * f64::ln(ibv / is) - vt * f64::ln(area);
953            assert!((shift - want).abs() < 1e-3, "shift {shift} vs {want}");
954        }
955        // The symptom deck (`i 1.6 A`, is=6.79e-18 n=1.47 bv=50):
956        // 50.2805 V against the 3f5's 50.6771 V.
957        let m = DiodeModel {
958            is: 6.79e-18,
959            n: 1.47,
960            bv: Some(50.0),
961            ..DiodeModel::default()
962        };
963        let t = DiodeTemp::new("dz", &m, 1.0, 1.0, 300.15, 300.15, 1e-3);
964        let v = t.brkdwn.unwrap() + t.vte * f64::ln(1.6 / t.csat);
965        assert!((v - 50.2805).abs() < 1e-4, "{v}");
966    }
967
968    #[test]
969    fn setup_clamps_warn() {
970        let m = DiodeModel {
971            m: 0.95,
972            eg: 0.05,
973            fc: 0.99,
974            ..DiodeModel::default()
975        };
976        let t = DiodeTemp::new("dx", &m, 1.0, 1.0, 300.15, 300.15, 1e-3);
977        assert_eq!(t.notes.len(), 3);
978        assert_eq!(t.m, 0.9);
979        assert!((t.f3 - (1.0 - 0.95 * 1.9)).abs() < 1e-12);
980    }
981}