Skip to main content

dune_core/
switch.rs

1//! Switches — the voltage-controlled `S` (`dev/sw/`) and the
2//! current-controlled `W` (`dev/csw/`) of the 3f5, in the form
3//! ngspice-47 runs them (`swload.c`/`cswload.c` "Modified: 2001 Jon
4//! Engelbert", plus `swtrunc.c`). The switch is a conductance `GON`
5//! or `GOFF` between its two nodes; which one is a *state variable*
6//! (`SWswitchstate`), decided anew in every load from the control
7//! value read off the previous Newton iterate (`CKTrhsOld`) and kept
8//! across the `INITF` ladder and from one timepoint to the next.
9//!
10//! ngspice widens the 3f5's on/off state to four values — off and
11//! on outside the hysteresis window (`REALLY_OFF = 0`,
12//! `REALLY_ON = 1`) and inside it (`HYST_OFF = 2`, `HYST_ON = 3`) —
13//! keeps the sign of `VH`/`IH` (the 3f5 takes `|VH|`; a non-positive
14//! hysteresis runs a separate state machine) and adds a timestep
15//! limit for the voltage switch as the control approaches the
16//! threshold (`SWtrunc`). The two loads are not quite the same code:
17//! inside the window `CSWload`'s `INITFLOAT` keeps the state of the
18//! last *accepted* point (`CKTstate1`) where `SWload` keeps the
19//! current iterate's (`CKTstate0`), and `CSWload` never records its
20//! control value, so `CSWtrunc` is inert. Both are ported as ngspice
21//! has them (corpus/KNOWN_DIFFS.md).
22
23use crate::integ::TerrCtx;
24use crate::matrix::{AcCtx, InitMode, LoadCtx, MatrixBuilder, NoiseCtx, PzCtx, Slot, TRASH, Var};
25
26/// `SW_NUM_STATES`/`CSW_NUM_STATES` (ngspice): switch state, control
27/// value.
28pub(crate) const SW_NUM_STATES: usize = 2;
29const SWITCHSTATE: usize = 0;
30const CTRLVALUE: usize = 1;
31
32/// `REALLY_OFF`, `REALLY_ON`, `HYST_OFF`, `HYST_ON` (ngspice
33/// `swload.c`), stored as doubles in the state vector like the C.
34const REALLY_OFF: f64 = 0.0;
35const REALLY_ON: f64 = 1.0;
36const HYST_OFF: f64 = 2.0;
37const HYST_ON: f64 = 3.0;
38
39/// `.model … sw(…)` parameters, 3f5 names (`sw.c` model table) and
40/// the defaults of `SWsetup`.
41#[derive(Clone, Debug, Default)]
42pub struct SwModel {
43    /// `VT`: threshold voltage, default 0.
44    pub vt: f64,
45    /// `VH`: hysteresis voltage, default 0. Kept signed (ngspice; the
46    /// 3f5 stores `|VH|`).
47    pub vh: f64,
48    /// `RON`: closed resistance, default 1 Ω (`SW_ON_CONDUCTANCE`).
49    pub ron: Option<f64>,
50    /// `ROFF`: open resistance, default `1/GMIN`
51    /// (`SW_OFF_CONDUCTANCE` is `CKTgmin`).
52    pub roff: Option<f64>,
53}
54
55/// `.model … csw(…)` parameters, 3f5 names (`csw.c` model table) and
56/// the defaults of `CSWsetup`.
57#[derive(Clone, Debug, Default)]
58pub struct CswModel {
59    /// `IT`: threshold current, default 0.
60    pub it: f64,
61    /// `IH`: hysteresis current, default 0. Kept signed (ngspice; the
62    /// 3f5 stores `|IH|`).
63    pub ih: f64,
64    /// `RON`: closed resistance, default 1 Ω.
65    pub ron: Option<f64>,
66    /// `ROFF`: open resistance, default `1/GMIN`.
67    pub roff: Option<f64>,
68}
69
70/// What a switch watches.
71#[derive(Clone, Copy, Debug)]
72pub(crate) enum Control {
73    /// `S`: the voltage between two nodes.
74    Voltage { pos: Var, neg: Var },
75    /// `W`: the branch current of a voltage source.
76    Current { branch: Var },
77}
78
79/// The model-level constants a switch instance needs after
80/// `SWsetup`/`CSWsetup` defaulting.
81#[derive(Clone, Copy, Debug)]
82pub(crate) struct SwitchParams {
83    pub thresh: f64,
84    pub hyst: f64,
85    pub gon: f64,
86    pub goff: f64,
87}
88
89impl SwitchParams {
90    /// `SWsetup`/`CSWsetup` defaults: `GON = 1`, `GOFF = gmin`.
91    pub fn new(thresh: f64, hyst: f64, ron: Option<f64>, roff: Option<f64>, gmin: f64) -> Self {
92        SwitchParams {
93            thresh,
94            hyst,
95            gon: ron.map_or(1.0, |r| 1.0 / r),
96            goff: roff.map_or(gmin, |r| 1.0 / r),
97        }
98    }
99}
100
101/// `SWinstance`/`CSWinstance`.
102pub(crate) struct SwitchInst {
103    p: SwitchParams,
104    pos: Var,
105    neg: Var,
106    ctrl: Control,
107    /// `SWzero_stateGiven`: the `ON` hint (`OFF` or nothing is false).
108    on: bool,
109    st: usize,
110    slots: [Slot; 4],
111}
112
113/// Which state an `INITJCT`/`INITFIX` load picks: the hint, refined
114/// to "really" on/off when the control is already past the window
115/// on the hinted side (both `SWload` and `CSWload`; note the `>= 0`
116/// here against the `> 0` of the later modes).
117fn init_state(p: &SwitchParams, on: bool, c: f64) -> f64 {
118    if on {
119        if (p.hyst >= 0.0 && c > p.thresh + p.hyst) || (p.hyst < 0.0 && c > p.thresh - p.hyst) {
120            REALLY_ON
121        } else {
122            HYST_ON
123        }
124    } else if (p.hyst >= 0.0 && c < p.thresh - p.hyst) || (p.hyst < 0.0 && c < p.thresh + p.hyst) {
125        REALLY_OFF
126    } else {
127        HYST_OFF
128    }
129}
130
131/// The `INITFLOAT` decision. `keep` is what a positive-hysteresis
132/// switch holds inside the window (`SWload`: this iterate's state0;
133/// `CSWload`: the last accepted point's state1); `prev` is state1.
134fn float_state(p: &SwitchParams, c: f64, keep: f64, prev: f64) -> f64 {
135    if p.hyst > 0.0 {
136        if c > p.thresh + p.hyst {
137            REALLY_ON
138        } else if c < p.thresh - p.hyst {
139            REALLY_OFF
140        } else {
141            keep
142        }
143    } else if c > p.thresh - p.hyst {
144        REALLY_ON
145    } else if c < p.thresh + p.hyst {
146        REALLY_OFF
147    } else if prev == HYST_OFF || prev == HYST_ON {
148        prev
149    } else if prev == REALLY_ON {
150        HYST_OFF
151    } else if prev == REALLY_OFF {
152        HYST_ON
153    } else {
154        // `internalerror("bad value for previous state")` only
155        // prints; the C's `current_state` keeps its -1 initializer.
156        -1.0
157    }
158}
159
160/// The `INITTRAN`/`INITPRED` decision from the last accepted state
161/// `prev`. Inside a non-positive window the two loads differ:
162/// `SWload` flips to the opposite *really* state (and falls back to
163/// 0), `CSWload` repeats its `INITFLOAT` table.
164fn tran_state(p: &SwitchParams, c: f64, prev: f64, current_ctrl: bool) -> f64 {
165    if p.hyst > 0.0 {
166        if c > p.thresh + p.hyst {
167            REALLY_ON
168        } else if c < p.thresh - p.hyst {
169            REALLY_OFF
170        } else {
171            prev
172        }
173    } else if c > p.thresh - p.hyst {
174        REALLY_ON
175    } else if c < p.thresh + p.hyst {
176        REALLY_OFF
177    } else if prev == HYST_ON || prev == HYST_OFF {
178        prev
179    } else if current_ctrl {
180        if prev == REALLY_ON {
181            HYST_OFF
182        } else if prev == REALLY_OFF {
183            HYST_ON
184        } else {
185            -1.0
186        }
187    } else if prev == REALLY_ON {
188        REALLY_OFF
189    } else if prev == REALLY_OFF {
190        REALLY_ON
191    } else {
192        0.0
193    }
194}
195
196/// Where on the way from control value `c1` to `c0` (as a fraction
197/// of the way) the switch that flipped in between met its flipping
198/// level; `None` when neither edge of the window lies in between.
199fn crossing(p: &SwitchParams, c1: f64, c0: f64) -> Option<f64> {
200    let (lo, hi) = (p.thresh - p.hyst.abs(), p.thresh + p.hyst.abs());
201    let between = |l: f64| (c1 <= l && l <= c0) || (c0 <= l && l <= c1);
202    let rising = c0 > c1;
203    // Far edge for a positive window, near edge otherwise.
204    let order = if (p.hyst > 0.0) == rising {
205        [hi, lo]
206    } else {
207        [lo, hi]
208    };
209    let level = order.into_iter().find(|&l| between(l))?;
210    let theta = (level - c1) / (c0 - c1);
211    (theta.is_finite() && (0.0..=1.0).contains(&theta)).then_some(theta)
212}
213
214/// The control level whose crossing turned a switch at `c1` on
215/// (`on`) or off: the far edge of a positive window, the first edge
216/// ahead of a non-positive one.
217fn flip_level(p: &SwitchParams, c1: f64, on: bool) -> f64 {
218    let (lo, hi) = (p.thresh - p.hyst.abs(), p.thresh + p.hyst.abs());
219    if p.hyst > 0.0 {
220        if on { hi } else { lo }
221    } else if on {
222        if c1 < lo { lo } else { hi }
223    } else if c1 > hi {
224        hi
225    } else {
226        lo
227    }
228}
229
230/// `g_now`: on for `REALLY_ON`/`HYST_ON` in the loads.
231fn is_on(state: f64) -> bool {
232    state == REALLY_ON || state == HYST_ON
233}
234
235impl SwitchInst {
236    pub fn new(p: SwitchParams, pos: Var, neg: Var, ctrl: Control, on: bool, st: usize) -> Self {
237        SwitchInst {
238            p,
239            pos,
240            neg,
241            ctrl,
242            on,
243            st,
244            slots: [TRASH; 4],
245        }
246    }
247
248    fn is_current(&self) -> bool {
249        matches!(self.ctrl, Control::Current { .. })
250    }
251
252    fn ctrl_value(&self, sol: &[f64]) -> f64 {
253        match self.ctrl {
254            Control::Voltage { pos, neg } => sol[pos] - sol[neg],
255            Control::Current { branch } => sol[branch],
256        }
257    }
258
259    /// The conductance the small-signal loads use: `(int)` of state0,
260    /// so any nonzero state — `HYST_OFF` included — is on
261    /// (`SWacLoad`, `SWpzLoad`, `SWnoise`).
262    fn g_small_signal(&self, state0: f64) -> f64 {
263        if state0 as i32 != 0 {
264            self.p.gon
265        } else {
266            self.p.goff
267        }
268    }
269
270    fn stamp(&self, g: f64, mut add: impl FnMut(Slot, f64)) {
271        add(self.slots[0], g);
272        add(self.slots[1], -g);
273        add(self.slots[2], -g);
274        add(self.slots[3], g);
275    }
276}
277
278impl crate::devices::Device for SwitchInst {
279    fn par_load(&self) -> bool {
280        true
281    }
282
283    // `DEVdisto` is NULL for both switches (`switf.h`/`cswitf.h`).
284    fn disto_setup(&mut self, _ctx: &crate::disto::DistoSetupCtx) -> Result<(), &'static str> {
285        Ok(())
286    }
287
288    fn alloc(&mut self, mb: &mut MatrixBuilder) {
289        self.slots = [
290            mb.slot(self.pos, self.pos),
291            mb.slot(self.pos, self.neg),
292            mb.slot(self.neg, self.pos),
293            mb.slot(self.neg, self.neg),
294        ];
295    }
296
297    /// `SWload`/`CSWload` (ngspice).
298    fn load(&mut self, ctx: &mut LoadCtx) {
299        let st = self.st + SWITCHSTATE;
300        let old = ctx.states.get(0, st);
301        let prev = ctx.states.get(1, st);
302        let c = self.ctrl_value(ctx.sol);
303        let current = self.is_current();
304
305        let state = match ctx.mode {
306            InitMode::Jct | InitMode::Fix => init_state(&self.p, self.on, c),
307            InitMode::Smsig => prev,
308            InitMode::Float => {
309                let keep = if current { prev } else { old };
310                let s = float_state(&self.p, c, keep, prev);
311                if s != old {
312                    // "ensure one more iteration"
313                    ctx.noncon = true;
314                }
315                s
316            }
317            InitMode::Tran | InitMode::Pred => tran_state(&self.p, c, prev, current),
318        };
319
320        ctx.states.set(0, st, state);
321        // `CSWload` has this store commented out ("FIXME"), which
322        // leaves `CSWtrunc` without data; Dune stores it for the
323        // event location ([`Device::event`]) and keeps the current
324        // switch out of `trunc`.
325        ctx.states.set(0, self.st + CTRLVALUE, c);
326
327        let g = if is_on(state) {
328            self.p.gon
329        } else {
330            self.p.goff
331        };
332        self.stamp(g, |s, v| ctx.add(s, v));
333    }
334
335    /// `SWtrunc` (ngspice; the 3f5 switch has no `DEVtrunc`): when
336    /// the control moves towards the edge of the window that would
337    /// flip the switch, cap the step so it covers at most 3/4 of the
338    /// remaining distance plus 50 mV, extrapolating the last step's
339    /// rate. Note the test is `state0 == 0`: `HYST_OFF` is handled
340    /// as if on. `CSWtrunc` reads a control value `CSWload` never
341    /// stores, so it never limits; it is not ported.
342    fn trunc(&self, t: &TerrCtx, time_step: &mut f64) {
343        if self.is_current() {
344            return;
345        }
346        let ctrl = self.st + CTRLVALUE;
347        let v0 = t.states.get(0, ctrl);
348        let last_change = v0 - t.states.get(1, ctrl);
349        if t.states.get(0, self.st + SWITCHSTATE) == 0.0 {
350            let r = self.p.thresh + self.p.hyst;
351            if v0 < r && last_change > 0.0 {
352                let max_change = (r - v0) * 0.75 + 0.05;
353                let max_step = max_change / last_change * t.delta_old[0];
354                if *time_step > max_step {
355                    *time_step = max_step;
356                }
357            }
358        } else {
359            let r = self.p.thresh - self.p.hyst;
360            if v0 > r && last_change < 0.0 {
361                let max_change = (r - v0) * 0.75 - 0.05;
362                let max_step = max_change / last_change * t.delta_old[0];
363                if *time_step > max_step {
364                    *time_step = max_step;
365                }
366            }
367        }
368    }
369
370    /// Dune's event location (D-0047; not in the 3f5 or ngspice,
371    /// where a switch flips at whatever timepoint first sees the
372    /// control past the threshold): when the conductance at bank
373    /// `to` differs from bank `from`, the fraction of that interval
374    /// at which the control, taken as linear in time, crossed the
375    /// level that flipped it. With a positive window the flip is at
376    /// the far edge in the direction of travel (`VT+VH` rising,
377    /// `VT-VH` falling); with a non-positive one the state machine
378    /// flips at the first edge it meets.
379    ///
380    /// A switch that controls itself (a relaxation oscillator: the
381    /// flip discharges the node it watches) ends the step with its
382    /// control back on the near side of the level. The crossing is
383    /// then extrapolated from the control's rate over the interval
384    /// before `from`, and reported no later than mid-interval, so
385    /// that retries at least halve the step.
386    fn event(
387        &self,
388        states: &crate::state::States,
389        from: usize,
390        to: usize,
391        back: f64,
392    ) -> Option<f64> {
393        let st = self.st + SWITCHSTATE;
394        let on = is_on(states.get(to, st));
395        if on == is_on(states.get(from, st)) {
396            return None;
397        }
398        let ctrl = self.st + CTRLVALUE;
399        let c1 = states.get(from, ctrl);
400        if let Some(theta) = crossing(&self.p, c1, states.get(to, ctrl)) {
401            return Some(theta);
402        }
403        let rate = (c1 - states.get(from + 1, ctrl)) / back;
404        let theta = (flip_level(&self.p, c1, on) - c1) / rate;
405        Some(if theta.is_finite() && theta >= 0.0 {
406            theta.min(0.5)
407        } else {
408            0.5
409        })
410    }
411
412    /// `SWacLoad`/`CSWacLoad`: the state left by the `INITSMSIG`
413    /// load (which in ngspice copies state1 — zero after a bare
414    /// operating point, so AC sees the switch open).
415    fn load_ac(&mut self, ctx: &mut AcCtx) {
416        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
417        self.stamp(g, |s, v| ctx.add_g(s, v));
418    }
419
420    /// `SWpzLoad`/`CSWpzLoad`: as the AC load.
421    fn load_pz(&mut self, ctx: &mut PzCtx) -> Result<(), &'static str> {
422        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
423        self.stamp(g, |s, v| ctx.add_g(s, v));
424        Ok(())
425    }
426
427    /// `SWnoise`/`CSWnoise`: one thermal generator of the
428    /// small-signal conductance (`onoise_s1`).
429    fn noise_suffixes(&self) -> Option<&'static [&'static str]> {
430        Some(&[""])
431    }
432
433    fn noise_dens(&self, ctx: &NoiseCtx) -> Vec<f64> {
434        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
435        vec![ctx.thermal(self.pos, self.neg, g)]
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn p(hyst: f64) -> SwitchParams {
444        SwitchParams::new(2.5, hyst, Some(10.0), Some(1e6), 1e-12)
445    }
446
447    #[test]
448    fn defaults_follow_swsetup() {
449        let d = SwitchParams::new(0.0, 0.0, None, None, 1e-12);
450        assert_eq!(d.gon, 1.0);
451        assert_eq!(d.goff, 1e-12);
452        let q = p(1.0);
453        assert_eq!(q.gon, 0.1);
454        assert_eq!(q.goff, 1e-6);
455    }
456
457    #[test]
458    fn init_honours_the_hint() {
459        let q = p(1.0);
460        // Hint on: really on only past vt+vh, else on-in-window, and
461        // even below the window the init modes keep it on.
462        assert_eq!(init_state(&q, true, 4.0), REALLY_ON);
463        assert_eq!(init_state(&q, true, 2.5), HYST_ON);
464        assert_eq!(init_state(&q, true, 0.0), HYST_ON);
465        // No hint / off: off whatever the control says.
466        assert_eq!(init_state(&q, false, 0.0), REALLY_OFF);
467        assert_eq!(init_state(&q, false, 2.5), HYST_OFF);
468        assert_eq!(init_state(&q, false, 4.0), HYST_OFF);
469        assert!(is_on(HYST_ON) && is_on(REALLY_ON));
470        assert!(!is_on(HYST_OFF) && !is_on(REALLY_OFF));
471    }
472
473    #[test]
474    fn hysteresis_rising_and_falling() {
475        let q = p(1.0);
476        // Rising from off: holds off through the window, flips past
477        // vt+vh = 3.5.
478        let mut s = REALLY_OFF;
479        let mut trace = Vec::new();
480        for v in [0.0, 1.0, 2.0, 3.0, 3.4, 3.6, 4.5, 5.0] {
481            s = float_state(&q, v, s, s);
482            trace.push(is_on(s));
483        }
484        assert_eq!(trace, [false, false, false, false, false, true, true, true]);
485        // Falling from on: holds on down to vt-vh = 1.5.
486        let mut trace = Vec::new();
487        for v in [5.0, 3.0, 2.0, 1.6, 1.4, 0.0] {
488            s = float_state(&q, v, s, s);
489            trace.push(is_on(s));
490        }
491        assert_eq!(trace, [true, true, true, true, false, false]);
492        // The transient predictor step decides from the last
493        // accepted state the same way.
494        assert_eq!(tran_state(&q, 2.5, REALLY_ON, false), REALLY_ON);
495        assert_eq!(tran_state(&q, 2.5, REALLY_OFF, false), REALLY_OFF);
496        assert_eq!(tran_state(&q, 3.6, REALLY_OFF, true), REALLY_ON);
497        assert_eq!(tran_state(&q, 1.4, HYST_ON, true), REALLY_OFF);
498    }
499
500    #[test]
501    fn in_window_hint_survives_float_for_sw_not_for_csw() {
502        let q = p(1.0);
503        // SWload keeps state0 (the hint the init modes stored);
504        // CSWload keeps state1, which is zero in an operating point.
505        let after_fix = init_state(&q, true, 2.5);
506        assert_eq!(float_state(&q, 2.5, after_fix, 0.0), HYST_ON);
507        assert_eq!(float_state(&q, 2.5, 0.0, 0.0), REALLY_OFF);
508    }
509
510    #[test]
511    fn non_positive_hysteresis_machine() {
512        // vh = -1: on above 3.5, off below 1.5, inside the window the
513        // state is the opposite of where the control came from.
514        let q = p(-1.0);
515        assert_eq!(float_state(&q, 4.0, 0.0, REALLY_OFF), REALLY_ON);
516        assert_eq!(float_state(&q, 1.0, 0.0, REALLY_ON), REALLY_OFF);
517        assert_eq!(float_state(&q, 2.5, 0.0, REALLY_ON), HYST_OFF);
518        assert_eq!(float_state(&q, 2.5, 0.0, REALLY_OFF), HYST_ON);
519        assert_eq!(float_state(&q, 2.5, 0.0, HYST_ON), HYST_ON);
520        assert_eq!(tran_state(&q, 2.5, REALLY_ON, false), REALLY_OFF);
521        assert_eq!(tran_state(&q, 2.5, REALLY_ON, true), HYST_OFF);
522        // vh = 0 takes the same branch: exactly at vt the previous
523        // state decides.
524        let z = p(0.0);
525        assert_eq!(float_state(&z, 2.6, 0.0, REALLY_OFF), REALLY_ON);
526        assert_eq!(float_state(&z, 2.4, 0.0, REALLY_ON), REALLY_OFF);
527        assert_eq!(float_state(&z, 2.5, 0.0, REALLY_OFF), HYST_ON);
528    }
529
530    #[test]
531    fn crossing_picks_the_flipping_edge() {
532        // Positive window 1.5..3.5: rising flips at 3.5, falling at
533        // 1.5, even when the step spans both edges.
534        let q = p(1.0);
535        assert_eq!(crossing(&q, 3.0, 4.0), Some(0.5));
536        assert_eq!(crossing(&q, 1.0, 5.0), Some(0.625));
537        assert_eq!(crossing(&q, 5.0, 1.0), Some(0.875));
538        assert_eq!(crossing(&q, 2.0, 3.0), None);
539        // Negative window: the first edge met flips.
540        let n = p(-1.0);
541        assert_eq!(crossing(&n, 1.0, 5.0), Some(0.125));
542        assert_eq!(crossing(&n, 5.0, 1.0), Some(0.375));
543        assert_eq!(crossing(&n, 2.0, 4.0), Some(0.75));
544        // No motion, no crossing.
545        assert_eq!(crossing(&q, 3.5, 3.5), None);
546    }
547
548    #[test]
549    fn flip_level_follows_the_state_machine() {
550        let q = p(1.0);
551        assert_eq!(flip_level(&q, 3.0, true), 3.5);
552        assert_eq!(flip_level(&q, 3.0, false), 1.5);
553        let n = p(-1.0);
554        assert_eq!(flip_level(&n, 1.0, true), 1.5);
555        assert_eq!(flip_level(&n, 2.0, true), 3.5);
556        assert_eq!(flip_level(&n, 4.0, false), 3.5);
557        assert_eq!(flip_level(&n, 2.0, false), 1.5);
558    }
559
560    #[test]
561    fn small_signal_state_is_the_int_cast() {
562        let sw = SwitchInst::new(p(1.0), 1, 2, Control::Voltage { pos: 3, neg: 0 }, false, 0);
563        assert_eq!(sw.g_small_signal(REALLY_OFF), 1e-6);
564        assert_eq!(sw.g_small_signal(REALLY_ON), 0.1);
565        assert_eq!(sw.g_small_signal(HYST_OFF), 0.1);
566        assert_eq!(sw.g_small_signal(HYST_ON), 0.1);
567    }
568}