Skip to main content

dune_core/
options.rs

1//! Simulator options (`lib/ckt/cktinit.c`, `lib/ckt/cktntask.c`;
2//! names from `spice.txt` §SIMULATOR VARIABLES). Temperatures arrive
3//! in °C on the card and live in kelvin here, like the C.
4//!
5//! [`Options::default`] is Dune's profile; [`Options::spice3`]
6//! (`.options compat=spice3`) restores every default where Dune
7//! departs from the 3f5 — see [`Compat`].
8
9use dune_netlist::Diagnostic;
10use dune_netlist::ir::{Param, ParamValue};
11
12use crate::consts::{CTOK, REFTEMP};
13use crate::integ::Method;
14
15/// The defaults profile (`.options compat=`, D-0046).
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum Compat {
18    /// `dune` (default): a `.tran` without `method=` integrates with
19    /// [`Method::TrBdf2`]. Measured on the corpus against refined
20    /// references (`dune-oracle --accuracy`, docs/dune-oracle.md), it
21    /// is closer to the converged answer than the trapezoidal rule at
22    /// the same step on 16 of 29 decks and farther on none, and it
23    /// does not ring.
24    #[default]
25    Dune,
26    /// `spice3`: the SPICE3f5 defaults — a `.tran` without `method=`
27    /// uses the trapezoidal rule. What the ngspice oracle runs under,
28    /// and what reproduces a SPICE3 run.
29    Spice3,
30}
31
32#[derive(Clone, Debug)]
33pub struct Options {
34    /// The defaults profile: what an analysis does where the fields
35    /// below leave it the choice (today: [`Options::method`]).
36    pub compat: Compat,
37    /// `RELTOL`, default 1e-3.
38    pub reltol: f64,
39    /// `ABSTOL`, default 1e-12 (amperes).
40    pub abstol: f64,
41    /// `VNTOL`, default 1e-6 (volts).
42    pub vntol: f64,
43    /// `GMIN`, default 1e-12.
44    pub gmin: f64,
45    /// `ITL1`: DC iteration limit, default 100.
46    pub itl1: usize,
47    /// `ITL2`: DC transfer-curve iteration limit, default 50.
48    pub itl2: usize,
49    /// `ITL4`: transient timepoint iteration limit, default 10
50    /// (`CKTtranMaxIter`).
51    pub itl4: usize,
52    /// Gmin steps in `CKTop`. 1 (the ngspice-47 default) selects the
53    /// dynamic Gillespie stepping of the diagonal gmin followed, if it
54    /// fails, by the "true gmin" stepping of the devices' `gmin`;
55    /// n > 1 runs the 3f5 fixed ladder with n steps; 0 skips gmin
56    /// stepping (`corpus/KNOWN_DIFFS.md`).
57    pub gminsteps: usize,
58    /// Source steps in `CKTop`. 1 (the ngspice-47 default,
59    /// `cktntask.c`) selects Gillespie's adaptive source ramp; n > 1
60    /// runs the 3f5 fixed ladder of n steps (the 3f5 default is 10);
61    /// 0 skips source stepping (`corpus/KNOWN_DIFFS.md`).
62    pub srcsteps: usize,
63    /// Operating temperature in kelvin (`TEMP`, default 27 °C).
64    pub temp: f64,
65    /// Nominal parameter-measurement temperature (`TNOM`, 27 °C).
66    pub tnom: f64,
67    /// `PIVTOL`, default 1e-13.
68    pub pivtol: f64,
69    /// `PIVREL`, default 1e-3.
70    pub pivrel: f64,
71    /// `NOOPITER`: go directly to gmin stepping.
72    pub noopiter: bool,
73    /// `TRTOL`: truncation-error overestimation factor, default 7.
74    pub trtol: f64,
75    /// `CHGTOL`: charge tolerance, default 1e-14.
76    pub chgtol: f64,
77    /// `METHOD`: `trap`, `gear` or Dune's `trbdf2`
78    /// ([`Method::TrBdf2`]; not a SPICE value). `None` (default)
79    /// leaves it to the analysis: see [`Options::tran_method`] and
80    /// [`Options::pss_method`].
81    pub method: Option<Method>,
82    /// `MAXORD` for Gear, default 2.
83    pub maxord: usize,
84    /// `NOPAGE`: suppress page breaks in printed output.
85    pub nopage: bool,
86    /// `DEFL`/`DEFW`: default MOS channel length/width, meters
87    /// (`CKTdefaultMosL/W`, 1e-4).
88    pub defl: f64,
89    pub defw: f64,
90    /// `DEFAD`/`DEFAS`: default MOS drain/source diffusion area, m²
91    /// (`CKTdefaultMosAD/AS`, 0).
92    pub defad: f64,
93    pub defas: f64,
94    /// Dune's transient step budget (not a SPICE option; no `.options`
95    /// card sets it): the most internal timepoints — accepted or
96    /// rejected — one `.tran` may attempt before it stops with
97    /// [`Error::StepLimit`](crate::Error::StepLimit). The 3f5 and
98    /// ngspice-47 both parse `ITL5` ("total transient iteration
99    /// limit") and ignore it (`cktsopt.c`: `case OPT_ITL5: break;`),
100    /// so neither bounds a run whose breakpoints cascade (D-0022).
101    ///
102    /// `None` (default): automatic, scaled to the deck —
103    /// `max(TRAN_STEP_FLOOR, TRAN_STEPS_PER_TMAX · ⌈TSTOP/TMAX⌉)`
104    /// with the effective TMAX, i.e. 10 000 steps or 50 per maximum
105    /// step span, whichever is more (the corpus peaks at 13 per span,
106    /// ltra_2; a fixed budget cannot work because a cascading LTRA
107    /// deck costs O(n²) in its history length — D-0022). `Some(n)`:
108    /// at most `n` (`Some(usize::MAX)` is unlimited).
109    pub max_tran_steps: Option<usize>,
110    /// Dune's opt-in LTRA breakpoint hygiene (`.options ltrabkrate=N`;
111    /// not a SPICE option, issue #6). The slope rule of `LTRAaccept`
112    /// plants a breakpoint one delay after every accepted point where
113    /// an incident wave's slope changes; `CKTsetBreak` merges only
114    /// within `5e-5·TMAX`, so a ringing load can feed it an echo
115    /// cascade (D-0022). `Some(n)`, `n > 0`: a slope breakpoint that
116    /// falls within `TMAX/n` of one already scheduled is dropped,
117    /// which caps them at about `n` per TMAX span. `None` or
118    /// `Some(0)` (default): faithful, every request scheduled.
119    pub ltra_break_rate: Option<usize>,
120    /// Dune's opt-in recursive convolution for LTRA lines
121    /// (`.options ltrafit`; not a SPICE option, issue #8). The 3f5
122    /// recomputes every line's convolution over the whole accepted
123    /// history at each timepoint — O(n) per point, quadratic per run.
124    /// `true`: the RLC and RC impulse responses become exponential
125    /// sums (an exact branch-cut quadrature, error budget 1e-6 per
126    /// kernel, reported in the run's notes) advanced once per accepted
127    /// point, with the newest segment kept exact — O(1) per point.
128    /// `false` (default): faithful.
129    pub ltra_fit: bool,
130    /// Dune's transient refinement knob (an API argument, not a SPICE
131    /// option: no `.options` card sets it, issue #16). `Some(d)`,
132    /// `d ≥ 1` and finite: every `.tran` runs with its effective
133    /// maximum step divided by `d` — the 4th argument of the card
134    /// when given, else the default `min(TSTEP, (TSTOP−TSTART)/50)`.
135    /// Everything derived from TMAX (`delmin`, the breakpoint merge
136    /// radius, the step budget) follows, so the run is the one the
137    /// deck would give with that card's TMAX edited to `TMAX/d`.
138    /// It is not a `.options` key because TMAX is a parameter of the
139    /// analysis card, and the divisor applies to every `.tran` card of
140    /// the deck whatever TMAX each one carries. Any other value makes
141    /// `.tran` fail with [`Error::Build`](crate::Error::Build).
142    /// `None` (default): the card's TMAX as is.
143    pub tmax_divisor: Option<f64>,
144    /// `SEED`: the seed of the transient-noise sources
145    /// (`TRNOISE`/`TRRANDOM`, ngspice-47 `.options seed=N`, which sets
146    /// its `rndseed`). Default 1 — ngspice's start-up `rndseed` too
147    /// (`main.c`) —, so a deck without the option is reproducible.
148    /// The same seed gives the same noise bits on every platform (see
149    /// `docs/dune-core.md` §Ruído transiente); ngspice's
150    /// `seed=random` (a clock seed) is refused with a warning.
151    pub seed: u64,
152    /// `NOTRNOISE` (ngspice-47's `notrnoise` variable, read by
153    /// `trnoise_state_gen`): every `TRNOISE` source runs with all its
154    /// noise parameters zeroed — just its `DC` value, no grid, no
155    /// breakpoints —, so a deck compares with and without noise
156    /// without editing it. As in ngspice, `TRRANDOM` is untouched
157    /// (its state generator never reads the variable). Default false.
158    pub notrnoise: bool,
159    /// Dune's transient device noise (`.options devnoise`; not a
160    /// SPICE option — neither the 3f5 nor ngspice has it, issue #20):
161    /// every generator `.noise` knows (thermal `4kT·g`, shot `2q|I|`,
162    /// flicker `KF·…/f^EF`) becomes a stochastic current source in
163    /// `.tran`, drawn from the counted streams of `seed`. `false`
164    /// (default): devices are noiseless in time, as in SPICE.
165    pub dev_noise: bool,
166    /// The grid step of the device noise (`.options devnt=NT`,
167    /// seconds): white up to `1/(2·NT)`, a breakpoint every `NT`.
168    /// `None` (default): the `.tran` card's TSTEP.
169    pub dev_noise_step: Option<f64>,
170    /// Worker threads for the per-iteration device load (issue #9,
171    /// lever 1; an API argument, not a SPICE option). `1` (default):
172    /// the sequential load, exactly the 3f5's `CKTload`. `0`: one
173    /// thread per core. `n > 1`: the devices are split into
174    /// contiguous runs evaluated on `n` threads, and their stamps are
175    /// replayed into the matrix in device order, so every result is
176    /// bit-identical to `1` whatever the count. It only pays on
177    /// circuits with many expensive devices (MOS/BSIM); see
178    /// `docs/perf.md` for the measured break-even. Needs the
179    /// `parallel` feature (native builds); without it (and on
180    /// `wasm32`) the value is ignored and the load is sequential.
181    pub load_threads: usize,
182    /// `KEEPOPINFO` (`cktsopt.c`): `.ac`, `.pz` and `.disto` (and,
183    /// as in ngspice-47, `.noise` and `.sp`) first emit their
184    /// operating point as a plot of its own ("AC Operating Point",
185    /// "Distortion Operating Point", "NOISE Operating Point").
186    /// Default false.
187    pub keepopinfo: bool,
188    /// `TRYTOCOMPACT` (`cktsopt.c`, IF_FLAG; `CKTtryToCompact`): LTRA
189    /// lines without an explicit interpolation flag interpolate
190    /// linearly, and after every accepted timepoint where the last
191    /// three points of every LTRA port voltage and current lie on a
192    /// straight line within `COMPACTREL`/`COMPACTABS`, the middle
193    /// point is dropped from the shared history (`ltraacct.c`). Default
194    /// false. Ignored under [`Options::ltra_fit`] and in `.pss`.
195    pub trytocompact: bool,
196}
197
198impl Default for Options {
199    fn default() -> Self {
200        Options {
201            compat: Compat::Dune,
202            reltol: 1e-3,
203            abstol: 1e-12,
204            vntol: 1e-6,
205            gmin: 1e-12,
206            itl1: 100,
207            itl2: 50,
208            itl4: 10,
209            gminsteps: 1,
210            srcsteps: 1,
211            temp: REFTEMP,
212            tnom: REFTEMP,
213            pivtol: 1e-13,
214            pivrel: 1e-3,
215            noopiter: false,
216            trtol: 7.0,
217            chgtol: 1e-14,
218            method: None,
219            maxord: 2,
220            nopage: false,
221            defl: 1e-4,
222            defw: 1e-4,
223            defad: 0.0,
224            defas: 0.0,
225            max_tran_steps: None,
226            ltra_break_rate: None,
227            ltra_fit: false,
228            load_threads: 1,
229            tmax_divisor: None,
230            seed: 1,
231            notrnoise: false,
232            dev_noise: false,
233            dev_noise_step: None,
234            keepopinfo: false,
235            trytocompact: false,
236        }
237    }
238}
239
240/// Floor of the automatic transient step budget
241/// ([`Options::max_tran_steps`] `= None`).
242pub const TRAN_STEP_FLOOR: usize = 10_000;
243
244/// Internal timepoints allowed per TMAX span of `[0, TSTOP]` by the
245/// automatic transient step budget (3.8× the corpus maximum of
246/// 13.0, `ltra_2`).
247pub const TRAN_STEPS_PER_TMAX: usize = 50;
248
249impl Options {
250    /// The SPICE3f5 defaults ([`Compat::Spice3`]).
251    pub fn spice3() -> Self {
252        Options {
253            compat: Compat::Spice3,
254            ..Options::default()
255        }
256    }
257
258    /// The integration method of a `.tran`: [`Options::method`] when
259    /// given, else the profile's — TR-BDF2 under [`Compat::Dune`],
260    /// the trapezoidal rule under [`Compat::Spice3`].
261    pub fn tran_method(&self) -> Method {
262        self.method.unwrap_or(match self.compat {
263            Compat::Dune => Method::TrBdf2,
264            Compat::Spice3 => Method::Trapezoidal,
265        })
266    }
267
268    /// The integration method of the `.pss` shooting:
269    /// [`Options::method`] when given, else the trapezoidal rule
270    /// under either profile. The shooting of autonomous oscillators
271    /// was built and measured on it (D-0035); under TR-BDF2 some of
272    /// them do not converge yet (`osc_colpitts`, `osc_tline`).
273    pub fn pss_method(&self) -> Method {
274        self.method.unwrap_or(Method::Trapezoidal)
275    }
276
277    /// The step budget of a `.tran` running to `tstop` with effective
278    /// maximum step `max_step` (see [`Options::max_tran_steps`]).
279    pub fn tran_step_budget(&self, tstop: f64, max_step: f64) -> usize {
280        match self.max_tran_steps {
281            Some(n) => n,
282            None => {
283                let spans = (tstop / max_step).ceil();
284                let auto = if spans.is_finite() && spans >= 0.0 {
285                    (spans as usize).saturating_mul(TRAN_STEPS_PER_TMAX)
286                } else {
287                    usize::MAX
288                };
289                auto.max(TRAN_STEP_FLOOR)
290            }
291        }
292    }
293
294    /// Apply `.options` parameters from the IR. Unknown or
295    /// not-yet-implemented options produce a warning, never an error
296    /// (the 3f5 front end also lets unknown variables through).
297    pub fn apply(&mut self, params: &[Param], diags: &mut Vec<Diagnostic>) {
298        for p in params {
299            let num = match &p.value {
300                ParamValue::Num(v) => Some(*v),
301                _ => None,
302            };
303            macro_rules! num_opt {
304                ($field:ident) => {
305                    match num {
306                        Some(v) => self.$field = v,
307                        None => diags.push(Diagnostic::warning(
308                            format!("option {} needs a numeric value", p.name),
309                            p.span,
310                        )),
311                    }
312                };
313            }
314            match p.name.as_str() {
315                "reltol" => num_opt!(reltol),
316                "abstol" => num_opt!(abstol),
317                "vntol" => num_opt!(vntol),
318                "gmin" => num_opt!(gmin),
319                "pivtol" => num_opt!(pivtol),
320                "pivrel" => num_opt!(pivrel),
321                "itl1" => {
322                    if let Some(v) = count(num, p, diags) {
323                        self.itl1 = v;
324                    }
325                }
326                "itl2" => {
327                    if let Some(v) = count(num, p, diags) {
328                        self.itl2 = v;
329                    }
330                }
331                "itl4" => {
332                    if let Some(v) = count(num, p, diags) {
333                        self.itl4 = v;
334                    }
335                }
336                "maxord" => {
337                    if let Some(v) = count(num, p, diags) {
338                        self.maxord = v;
339                    }
340                }
341                "trtol" => num_opt!(trtol),
342                "chgtol" => num_opt!(chgtol),
343                "method" => match &p.value {
344                    ParamValue::Str(s) if s == "gear" => self.method = Some(Method::Gear),
345                    ParamValue::Str(s) if s == "trap" || s == "trapezoidal" => {
346                        self.method = Some(Method::Trapezoidal);
347                    }
348                    // Dune's L-stable composite (issue #6).
349                    ParamValue::Str(s) if s == "trbdf2" => self.method = Some(Method::TrBdf2),
350                    _ => diags.push(Diagnostic::warning(
351                        "method must be trap, gear or trbdf2".to_string(),
352                        p.span,
353                    )),
354                },
355                // The defaults profile (D-0046).
356                "compat" => match &p.value {
357                    ParamValue::Str(s) if s == "spice3" => self.compat = Compat::Spice3,
358                    ParamValue::Str(s) if s == "dune" => self.compat = Compat::Dune,
359                    _ => diags.push(Diagnostic::warning(
360                        "compat must be spice3 or dune".to_string(),
361                        p.span,
362                    )),
363                },
364                "ltrabkrate" => {
365                    if let Some(v) = count(num, p, diags) {
366                        self.ltra_break_rate = Some(v);
367                    }
368                }
369                // Dune's opt-in LTRA recursive convolution (issue #8):
370                // a bare flag or `ltrafit=0|1`.
371                "ltrafit" => self.ltra_fit = num.is_none_or(|v| v != 0.0),
372                "gminsteps" => {
373                    if let Some(v) = count(num, p, diags) {
374                        self.gminsteps = v;
375                    }
376                }
377                "srcsteps" | "itl6" => {
378                    if let Some(v) = count(num, p, diags) {
379                        self.srcsteps = v;
380                    }
381                }
382                "temp" => match num {
383                    Some(v) => self.temp = v + CTOK,
384                    None => diags.push(Diagnostic::warning(
385                        "option temp needs a value in °C".to_string(),
386                        p.span,
387                    )),
388                },
389                "tnom" => match num {
390                    Some(v) => self.tnom = v + CTOK,
391                    None => diags.push(Diagnostic::warning(
392                        "option tnom needs a value in °C".to_string(),
393                        p.span,
394                    )),
395                },
396                // ngspice-47 `inp.c`: `option seed=val|random`; a
397                // value that `atoi` makes non-positive is skipped with
398                // a warning. Dune never seeds from the clock.
399                "seed" => match num {
400                    Some(v) if v >= 1.0 && v.is_finite() && v < 9.007_199_254_740_992e15 => {
401                        self.seed = v as u64;
402                    }
403                    _ => diags.push(Diagnostic::warning(
404                        format!(
405                            "option seed needs a positive integer (seed=random is not \
406                             supported: runs must be reproducible); keeping seed={}",
407                            self.seed
408                        ),
409                        p.span,
410                    )),
411                },
412                // ngspice-47 `set notrnoise` / `.options notrnoise`
413                // (a bare flag; `notrnoise=0|1` too).
414                "notrnoise" => self.notrnoise = num.is_none_or(|v| v != 0.0),
415                // Dune's transient device noise (issue #20): a bare
416                // flag or `devnoise=0|1`, and its grid step.
417                "devnoise" => self.dev_noise = num.is_none_or(|v| v != 0.0),
418                "devnt" => match num {
419                    Some(v) if v > 0.0 && v.is_finite() => self.dev_noise_step = Some(v),
420                    _ => diags.push(Diagnostic::warning(
421                        "option devnt needs a positive time step".to_string(),
422                        p.span,
423                    )),
424                },
425                "noopiter" => self.noopiter = true,
426                // IF_FLAG: a bare word (or `keepopinfo=0|1`).
427                "keepopinfo" => self.keepopinfo = num.is_none_or(|v| v != 0.0),
428                "trytocompact" => self.trytocompact = num.is_none_or(|v| v != 0.0),
429                "nopage" => self.nopage = true,
430                // `cktsopt.c` has a copy-paste bug that makes `defas`
431                // set the drain area; ngspice fixed it and so do we
432                // (`corpus/KNOWN_DIFFS.md`).
433                "defl" => num_opt!(defl),
434                "defw" => num_opt!(defw),
435                "defad" => num_opt!(defad),
436                "defas" => num_opt!(defas),
437                // Recognized 3f5 options that only matter to later
438                // phases (output formatting, statistics, devices).
439                "itl3" | "itl5" | "acct" | "list" | "nomod" | "node" | "opts" | "bypass"
440                | "badmos3" => {}
441                // `spiceif.c`'s `obsolete[]`: SPICE2 artifacts (lvlcod
442                // generated machine code for the solver) that must
443                // never do anything — the 3f5 words it "is obsolete".
444                "limpts" | "limtim" | "lvlcod" => diags.push(Diagnostic::warning(
445                    format!("option {} is an obsolete SPICE2 option; ignored", p.name),
446                    p.span,
447                )),
448                other => diags.push(Diagnostic::warning(
449                    format!("option {other} is not implemented yet; ignored"),
450                    p.span,
451                )),
452            }
453        }
454    }
455}
456
457fn count(num: Option<f64>, p: &Param, diags: &mut Vec<Diagnostic>) -> Option<usize> {
458    match num {
459        Some(v) if v >= 0.0 && v.is_finite() && v < 1e9 => Some(v as usize),
460        _ => {
461            diags.push(Diagnostic::warning(
462                format!("option {} needs a non-negative count", p.name),
463                p.span,
464            ));
465            None
466        }
467    }
468}