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/// `g_now`: on for `REALLY_ON`/`HYST_ON` in the loads.
197fn is_on(state: f64) -> bool {
198    state == REALLY_ON || state == HYST_ON
199}
200
201impl SwitchInst {
202    pub fn new(p: SwitchParams, pos: Var, neg: Var, ctrl: Control, on: bool, st: usize) -> Self {
203        SwitchInst {
204            p,
205            pos,
206            neg,
207            ctrl,
208            on,
209            st,
210            slots: [TRASH; 4],
211        }
212    }
213
214    fn is_current(&self) -> bool {
215        matches!(self.ctrl, Control::Current { .. })
216    }
217
218    fn ctrl_value(&self, sol: &[f64]) -> f64 {
219        match self.ctrl {
220            Control::Voltage { pos, neg } => sol[pos] - sol[neg],
221            Control::Current { branch } => sol[branch],
222        }
223    }
224
225    /// The conductance the small-signal loads use: `(int)` of state0,
226    /// so any nonzero state — `HYST_OFF` included — is on
227    /// (`SWacLoad`, `SWpzLoad`, `SWnoise`).
228    fn g_small_signal(&self, state0: f64) -> f64 {
229        if state0 as i32 != 0 {
230            self.p.gon
231        } else {
232            self.p.goff
233        }
234    }
235
236    fn stamp(&self, g: f64, mut add: impl FnMut(Slot, f64)) {
237        add(self.slots[0], g);
238        add(self.slots[1], -g);
239        add(self.slots[2], -g);
240        add(self.slots[3], g);
241    }
242}
243
244impl crate::devices::Device for SwitchInst {
245    fn par_load(&self) -> bool {
246        true
247    }
248
249    // `DEVdisto` is NULL for both switches (`switf.h`/`cswitf.h`).
250    fn disto_setup(&mut self, _ctx: &crate::disto::DistoSetupCtx) -> Result<(), &'static str> {
251        Ok(())
252    }
253
254    fn alloc(&mut self, mb: &mut MatrixBuilder) {
255        self.slots = [
256            mb.slot(self.pos, self.pos),
257            mb.slot(self.pos, self.neg),
258            mb.slot(self.neg, self.pos),
259            mb.slot(self.neg, self.neg),
260        ];
261    }
262
263    /// `SWload`/`CSWload` (ngspice).
264    fn load(&mut self, ctx: &mut LoadCtx) {
265        let st = self.st + SWITCHSTATE;
266        let old = ctx.states.get(0, st);
267        let prev = ctx.states.get(1, st);
268        let c = self.ctrl_value(ctx.sol);
269        let current = self.is_current();
270
271        let state = match ctx.mode {
272            InitMode::Jct | InitMode::Fix => init_state(&self.p, self.on, c),
273            InitMode::Smsig => prev,
274            InitMode::Float => {
275                let keep = if current { prev } else { old };
276                let s = float_state(&self.p, c, keep, prev);
277                if s != old {
278                    // "ensure one more iteration"
279                    ctx.noncon = true;
280                }
281                s
282            }
283            InitMode::Tran | InitMode::Pred => tran_state(&self.p, c, prev, current),
284        };
285
286        ctx.states.set(0, st, state);
287        if !current {
288            // `CSWload` has this store commented out ("FIXME"), which
289            // leaves `CSWtrunc` without data.
290            ctx.states.set(0, self.st + CTRLVALUE, c);
291        }
292
293        let g = if is_on(state) {
294            self.p.gon
295        } else {
296            self.p.goff
297        };
298        self.stamp(g, |s, v| ctx.add(s, v));
299    }
300
301    /// `SWtrunc` (ngspice; the 3f5 switch has no `DEVtrunc`): when
302    /// the control moves towards the edge of the window that would
303    /// flip the switch, cap the step so it covers at most 3/4 of the
304    /// remaining distance plus 50 mV, extrapolating the last step's
305    /// rate. Note the test is `state0 == 0`: `HYST_OFF` is handled
306    /// as if on. `CSWtrunc` reads a control value `CSWload` never
307    /// stores, so it never limits; it is not ported.
308    fn trunc(&self, t: &TerrCtx, time_step: &mut f64) {
309        if self.is_current() {
310            return;
311        }
312        let ctrl = self.st + CTRLVALUE;
313        let v0 = t.states.get(0, ctrl);
314        let last_change = v0 - t.states.get(1, ctrl);
315        if t.states.get(0, self.st + SWITCHSTATE) == 0.0 {
316            let r = self.p.thresh + self.p.hyst;
317            if v0 < r && last_change > 0.0 {
318                let max_change = (r - v0) * 0.75 + 0.05;
319                let max_step = max_change / last_change * t.delta_old[0];
320                if *time_step > max_step {
321                    *time_step = max_step;
322                }
323            }
324        } else {
325            let r = self.p.thresh - self.p.hyst;
326            if v0 > r && last_change < 0.0 {
327                let max_change = (r - v0) * 0.75 - 0.05;
328                let max_step = max_change / last_change * t.delta_old[0];
329                if *time_step > max_step {
330                    *time_step = max_step;
331                }
332            }
333        }
334    }
335
336    /// `SWacLoad`/`CSWacLoad`: the state left by the `INITSMSIG`
337    /// load (which in ngspice copies state1 — zero after a bare
338    /// operating point, so AC sees the switch open).
339    fn load_ac(&mut self, ctx: &mut AcCtx) {
340        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
341        self.stamp(g, |s, v| ctx.add_g(s, v));
342    }
343
344    /// `SWpzLoad`/`CSWpzLoad`: as the AC load.
345    fn load_pz(&mut self, ctx: &mut PzCtx) -> Result<(), &'static str> {
346        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
347        self.stamp(g, |s, v| ctx.add_g(s, v));
348        Ok(())
349    }
350
351    /// `SWnoise`/`CSWnoise`: one thermal generator of the
352    /// small-signal conductance (`onoise_s1`).
353    fn noise_suffixes(&self) -> Option<&'static [&'static str]> {
354        Some(&[""])
355    }
356
357    fn noise_dens(&self, ctx: &NoiseCtx) -> Vec<f64> {
358        let g = self.g_small_signal(ctx.states.get(0, self.st + SWITCHSTATE));
359        vec![ctx.thermal(self.pos, self.neg, g)]
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn p(hyst: f64) -> SwitchParams {
368        SwitchParams::new(2.5, hyst, Some(10.0), Some(1e6), 1e-12)
369    }
370
371    #[test]
372    fn defaults_follow_swsetup() {
373        let d = SwitchParams::new(0.0, 0.0, None, None, 1e-12);
374        assert_eq!(d.gon, 1.0);
375        assert_eq!(d.goff, 1e-12);
376        let q = p(1.0);
377        assert_eq!(q.gon, 0.1);
378        assert_eq!(q.goff, 1e-6);
379    }
380
381    #[test]
382    fn init_honours_the_hint() {
383        let q = p(1.0);
384        // Hint on: really on only past vt+vh, else on-in-window, and
385        // even below the window the init modes keep it on.
386        assert_eq!(init_state(&q, true, 4.0), REALLY_ON);
387        assert_eq!(init_state(&q, true, 2.5), HYST_ON);
388        assert_eq!(init_state(&q, true, 0.0), HYST_ON);
389        // No hint / off: off whatever the control says.
390        assert_eq!(init_state(&q, false, 0.0), REALLY_OFF);
391        assert_eq!(init_state(&q, false, 2.5), HYST_OFF);
392        assert_eq!(init_state(&q, false, 4.0), HYST_OFF);
393        assert!(is_on(HYST_ON) && is_on(REALLY_ON));
394        assert!(!is_on(HYST_OFF) && !is_on(REALLY_OFF));
395    }
396
397    #[test]
398    fn hysteresis_rising_and_falling() {
399        let q = p(1.0);
400        // Rising from off: holds off through the window, flips past
401        // vt+vh = 3.5.
402        let mut s = REALLY_OFF;
403        let mut trace = Vec::new();
404        for v in [0.0, 1.0, 2.0, 3.0, 3.4, 3.6, 4.5, 5.0] {
405            s = float_state(&q, v, s, s);
406            trace.push(is_on(s));
407        }
408        assert_eq!(trace, [false, false, false, false, false, true, true, true]);
409        // Falling from on: holds on down to vt-vh = 1.5.
410        let mut trace = Vec::new();
411        for v in [5.0, 3.0, 2.0, 1.6, 1.4, 0.0] {
412            s = float_state(&q, v, s, s);
413            trace.push(is_on(s));
414        }
415        assert_eq!(trace, [true, true, true, true, false, false]);
416        // The transient predictor step decides from the last
417        // accepted state the same way.
418        assert_eq!(tran_state(&q, 2.5, REALLY_ON, false), REALLY_ON);
419        assert_eq!(tran_state(&q, 2.5, REALLY_OFF, false), REALLY_OFF);
420        assert_eq!(tran_state(&q, 3.6, REALLY_OFF, true), REALLY_ON);
421        assert_eq!(tran_state(&q, 1.4, HYST_ON, true), REALLY_OFF);
422    }
423
424    #[test]
425    fn in_window_hint_survives_float_for_sw_not_for_csw() {
426        let q = p(1.0);
427        // SWload keeps state0 (the hint the init modes stored);
428        // CSWload keeps state1, which is zero in an operating point.
429        let after_fix = init_state(&q, true, 2.5);
430        assert_eq!(float_state(&q, 2.5, after_fix, 0.0), HYST_ON);
431        assert_eq!(float_state(&q, 2.5, 0.0, 0.0), REALLY_OFF);
432    }
433
434    #[test]
435    fn non_positive_hysteresis_machine() {
436        // vh = -1: on above 3.5, off below 1.5, inside the window the
437        // state is the opposite of where the control came from.
438        let q = p(-1.0);
439        assert_eq!(float_state(&q, 4.0, 0.0, REALLY_OFF), REALLY_ON);
440        assert_eq!(float_state(&q, 1.0, 0.0, REALLY_ON), REALLY_OFF);
441        assert_eq!(float_state(&q, 2.5, 0.0, REALLY_ON), HYST_OFF);
442        assert_eq!(float_state(&q, 2.5, 0.0, REALLY_OFF), HYST_ON);
443        assert_eq!(float_state(&q, 2.5, 0.0, HYST_ON), HYST_ON);
444        assert_eq!(tran_state(&q, 2.5, REALLY_ON, false), REALLY_OFF);
445        assert_eq!(tran_state(&q, 2.5, REALLY_ON, true), HYST_OFF);
446        // vh = 0 takes the same branch: exactly at vt the previous
447        // state decides.
448        let z = p(0.0);
449        assert_eq!(float_state(&z, 2.6, 0.0, REALLY_OFF), REALLY_ON);
450        assert_eq!(float_state(&z, 2.4, 0.0, REALLY_ON), REALLY_OFF);
451        assert_eq!(float_state(&z, 2.5, 0.0, REALLY_OFF), HYST_ON);
452    }
453
454    #[test]
455    fn small_signal_state_is_the_int_cast() {
456        let sw = SwitchInst::new(p(1.0), 1, 2, Control::Voltage { pos: 3, neg: 0 }, false, 0);
457        assert_eq!(sw.g_small_signal(REALLY_OFF), 1e-6);
458        assert_eq!(sw.g_small_signal(REALLY_ON), 0.1);
459        assert_eq!(sw.g_small_signal(HYST_OFF), 0.1);
460        assert_eq!(sw.g_small_signal(HYST_ON), 0.1);
461    }
462}