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}