Skip to main content

dune_core/
integ.rs

1//! Numerical integration support: corrector coefficients
2//! (`ni/nicomcof.c`), the companion-model evaluation
3//! (`ni/niinteg.c::NIintegrate`) and the local truncation error
4//! timestep estimate (`ckt/cktterr.c::CKTterr`), plus the opt-in
5//! TR-BDF2 composite (not in any SPICE; issue #6).
6
7use crate::state::States;
8
9/// The `.options method=` integration method.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Method {
12    /// `trap` (default): the 3f5's trapezoidal rule, order 1 after
13    /// breakpoints, 2 otherwise.
14    Trapezoidal,
15    /// `gear`: variable-order BDF up to `MAXORD`.
16    Gear,
17    /// `trbdf2`: Dune's opt-in TR-BDF2 composite (Bank et al., IEEE
18    /// Trans. ED 32(10), 1985; Hosea & Shampine, Appl. Numer. Math.
19    /// 20, 1996). Each step `h` is a trapezoidal stage to
20    /// `t + γh`, `γ = 2 − √2`, then a BDF2 stage on
21    /// `{t, t + γh, t + h}`: one-step, second order and L-stable, so
22    /// stiff modes that the trapezoidal rule rings (`−1` amplification
23    /// as `h/τ → ∞`) are damped. Not a SPICE method: the 3f5 contract
24    /// (D-0002) holds only for `trap`/`gear`.
25    TrBdf2,
26}
27
28/// `γ = 2 − √2` of TR-BDF2: the trapezoidal stage covers `γ·h`. With
29/// this value both stages share the Jacobian coefficient
30/// `2/(γh) = (2 − γ)/((1 − γ)h)`.
31pub(crate) const TRBDF2_GAMMA: f64 = 2.0 - std::f64::consts::SQRT_2;
32
33/// The TR-BDF2 local-error constant `k = (−3γ² + 4γ − 2)/(12(2 − γ))`
34/// (`≈ −0.0404`): the step's charge error is `|k|·h³·|q'''|`, against
35/// the trapezoidal rule's `h³·|q'''|/12` (Bank et al. 1985).
36pub(crate) const TRBDF2_LTE_K: f64 = {
37    let g = TRBDF2_GAMMA;
38    (-3.0 * g * g + 4.0 * g - 2.0) / (12.0 * (2.0 - g))
39};
40
41/// `NIcomCof`: fill `ag[0..=order]` for the current step sizes.
42/// `delta_old[0]` is the step being attempted.
43// The literal `0.5 / (1.0 - 0.5)` and the index-style loops keep the
44// C's shape ("should have 'xmu' instead of .5 eventually").
45#[allow(clippy::eq_op, clippy::needless_range_loop)]
46pub(crate) fn com_cof(
47    method: Method,
48    order: usize,
49    delta: f64,
50    delta_old: &[f64; 8],
51) -> Result<[f64; 7], crate::error::Error> {
52    let mut ag = [0.0f64; 7];
53    match method {
54        Method::Trapezoidal => match order {
55            1 => {
56                ag[0] = 1.0 / delta;
57                ag[1] = -1.0 / delta;
58            }
59            2 => {
60                // xmu = 0.5 (pure trapezoidal).
61                ag[0] = 1.0 / delta / (1.0 - 0.5);
62                ag[1] = 0.5 / (1.0 - 0.5);
63            }
64            _ => {
65                return Err(crate::error::Error::Build(
66                    "illegal integration order".into(),
67                ));
68            }
69        },
70        // The engine runs TR-BDF2 as a trapezoidal stage and a gear
71        // stage and asks for those coefficients; the composite has
72        // none of its own.
73        Method::TrBdf2 => {
74            return Err(crate::error::Error::Build(
75                "TR-BDF2 has no single-step coefficients".into(),
76            ));
77        }
78        Method::Gear => {
79            if order == 0 || order > 6 {
80                return Err(crate::error::Error::Build(
81                    "illegal integration order".into(),
82                ));
83            }
84            // Solve for the BDF coefficients on the (possibly
85            // non-uniform) grid, exactly like the C's little LU.
86            let mut mat = [[0.0f64; 8]; 8];
87            ag[1] = -1.0 / delta;
88            for e in mat[0].iter_mut().take(order + 1) {
89                *e = 1.0;
90            }
91            for row in mat.iter_mut().take(order + 1).skip(1) {
92                row[0] = 0.0;
93            }
94            let mut arg = 0.0;
95            for i in 1..=order {
96                arg += delta_old[i - 1];
97                let mut arg1 = 1.0;
98                for j in 1..=order {
99                    arg1 *= arg / delta;
100                    mat[j][i] = arg1;
101                }
102            }
103            // LU decompose.
104            for i in 1..=order {
105                for j in i + 1..=order {
106                    mat[j][i] /= mat[i][i];
107                    for k in i + 1..=order {
108                        mat[j][k] -= mat[j][i] * mat[i][k];
109                    }
110                }
111            }
112            // Forward substitution.
113            for i in 1..=order {
114                for j in i + 1..=order {
115                    ag[j] -= mat[j][i] * ag[i];
116                }
117            }
118            // Backward substitution.
119            ag[order] /= mat[order][order];
120            for i in (0..order).rev() {
121                for j in i + 1..=order {
122                    ag[i] -= mat[i][j] * ag[j];
123                }
124                ag[i] /= mat[i][i];
125            }
126        }
127    }
128    Ok(ag)
129}
130
131/// `NIintegrate`: given the charge state at `q` (its companion
132/// current lives at `q + 1`), update `state0[q+1]` and return
133/// `(geq, ceq)`.
134pub(crate) fn integrate(
135    st: &mut crate::state::StateView,
136    ag: &[f64; 7],
137    order: usize,
138    method: Method,
139    cap: f64,
140    q: usize,
141) -> (f64, f64) {
142    let c = q + 1;
143    match method {
144        Method::Trapezoidal => match order {
145            1 => {
146                let v = ag[0] * st.get(0, q) + ag[1] * st.get(1, q);
147                st.set(0, c, v);
148            }
149            _ => {
150                let v = -st.get(1, c) * ag[1] + ag[0] * (st.get(0, q) - st.get(1, q));
151                st.set(0, c, v);
152            }
153        },
154        // Devices see the stage method (the engine never hands them
155        // `TrBdf2`); the gear sum is the stage that closes the step.
156        Method::Gear | Method::TrBdf2 => {
157            let mut v = 0.0;
158            for (bank, a) in ag.iter().enumerate().take(order + 1).skip(1) {
159                v += a * st.get(bank, q);
160            }
161            v += ag[0] * st.get(0, q);
162            st.set(0, c, v);
163        }
164    }
165    let ceq = st.get(0, c) - ag[0] * st.get(0, q);
166    let geq = ag[0] * cap;
167    (geq, ceq)
168}
169
170/// Inputs `CKTterr` needs from the circuit.
171pub(crate) struct TerrCtx<'a> {
172    pub states: &'a States,
173    /// The just-converged solution (`CKTrhsOld`) and `CKTtime`, for
174    /// devices that estimate from their own history (`LTRAtrunc`).
175    pub sol: &'a [f64],
176    pub time: f64,
177    /// Step history aligned with the state banks: `[0]` spans bank 1
178    /// → bank 0. Under TR-BDF2 the banks hold the stages, so `[0]` is
179    /// the BDF2 stage and `[1]` the trapezoidal stage of the step
180    /// being checked.
181    pub delta_old: &'a [f64; 8],
182    /// Accepted-step history (`[0]` is the step being checked), for
183    /// devices that keep their own per-accepted-point history
184    /// (`TRAtrunc`). Equal to `delta_old` except under TR-BDF2.
185    pub step_old: &'a [f64; 8],
186    pub order: usize,
187    pub method: Method,
188    /// `CKTdelta`; under TR-BDF2 the whole step `h`.
189    pub delta: f64,
190    pub reltol: f64,
191    pub abstol: f64,
192    pub chgtol: f64,
193    pub trtol: f64,
194}
195
196/// `CKTterr`: shrink `time_step` to what the divided-difference LTE
197/// estimate of the charge at `q` allows.
198pub(crate) fn terr(q: usize, ctx: &TerrCtx, time_step: &mut f64) {
199    if ctx.method == Method::TrBdf2 {
200        terr_trbdf2(q, ctx, time_step);
201        return;
202    }
203    const GEAR_COEFF: [f64; 6] = [
204        0.5,
205        0.2222222222,
206        0.1363636364,
207        0.096,
208        0.07299270073,
209        0.05830903790,
210    ];
211    const TRAP_COEFF: [f64; 2] = [0.5, 0.08333333333];
212
213    let c = q + 1;
214    let st = ctx.states;
215    let volttol = ctx.abstol + ctx.reltol * f64::max(st.get(0, c).abs(), st.get(1, c).abs());
216    let chargetol = f64::max(st.get(0, q).abs(), st.get(1, q).abs());
217    let chargetol = ctx.reltol * f64::max(chargetol, ctx.chgtol) / ctx.delta;
218    let tol = f64::max(volttol, chargetol);
219
220    // Divided differences of the charge history.
221    let mut diff = [0.0f64; 8];
222    let mut deltmp = [0.0f64; 8];
223    for i in (0..=ctx.order + 1).rev() {
224        diff[i] = st.get(i, q);
225    }
226    deltmp[..=ctx.order].copy_from_slice(&ctx.delta_old[..=ctx.order]);
227    let mut j = ctx.order;
228    loop {
229        for i in 0..=j {
230            diff[i] = (diff[i] - diff[i + 1]) / deltmp[i];
231        }
232        if j == 0 {
233            break;
234        }
235        j -= 1;
236        for i in 0..=j {
237            deltmp[i] = deltmp[i + 1] + ctx.delta_old[i];
238        }
239    }
240
241    let factor = match ctx.method {
242        Method::Gear => GEAR_COEFF[ctx.order - 1],
243        // `TrBdf2` returned above.
244        Method::Trapezoidal | Method::TrBdf2 => TRAP_COEFF[ctx.order - 1],
245    };
246    let mut del = ctx.trtol * tol / f64::max(ctx.abstol, factor * diff[0].abs());
247    if ctx.order == 2 {
248        del = del.sqrt();
249    } else if ctx.order > 2 {
250        del = (del.ln() / ctx.order as f64).exp();
251    }
252    *time_step = time_step.min(del);
253}
254
255/// The TR-BDF2 counterpart of [`terr`] for a composite step, in
256/// `CKTterr`'s own form: same tolerances, same `trtol`, the `√` of a
257/// second-order method, and `CKTterr`'s scale — at order 2 it compares
258/// one sixth of the true local error (`TRAP_COEFF[1]·q[·,·,·,·]` is
259/// `(h³q'''/12)/6` over `h³`, likewise gear-2's `2/9`), and so does
260/// this: at equal derivatives and tolerance the composite is held to
261/// the trapezoidal rule's true error.
262///
263/// The estimate is Bank et al.'s (1985): from the companion currents
264/// `q'` at the three stage points (banks 2, 1, 0 = `t, t+γh, t+h`) the
265/// step's charge error is `2|k|·h³·|q'[·,·,·]|`, with `q'[·,·,·] ≈
266/// q'''/2` their second divided difference, so the measure is
267/// `|k|·|q'[·,·,·]|/3`. It reads no history before the step. (The
268/// order-1 restart steps the engine takes after breakpoints are plain
269/// backward Euler and go through `CKTterr` itself.)
270fn terr_trbdf2(q: usize, ctx: &TerrCtx, time_step: &mut f64) {
271    let c = q + 1;
272    let st = ctx.states;
273    let (h2, h1) = (ctx.delta_old[0], ctx.delta_old[1]);
274    let h = h1 + h2;
275    let volttol = ctx.abstol + ctx.reltol * f64::max(st.get(0, c).abs(), st.get(2, c).abs());
276    let chargetol = f64::max(st.get(0, q).abs(), st.get(2, q).abs());
277    let chargetol = ctx.reltol * f64::max(chargetol, ctx.chgtol) / h;
278    let tol = f64::max(volttol, chargetol);
279
280    let (g0, gg, g1) = (st.get(2, c), st.get(1, c), st.get(0, c));
281    let dd2 = ((g1 - gg) / h2 - (gg - g0) / h1) / h;
282    let measure = TRBDF2_LTE_K.abs() * dd2.abs() / 3.0;
283    let del = (ctx.trtol * tol / f64::max(ctx.abstol, measure)).sqrt();
284    *time_step = time_step.min(del);
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn trap_coefficients() {
293        let d = [1e-3; 8];
294        let ag = com_cof(Method::Trapezoidal, 1, 1e-3, &d).unwrap();
295        assert_eq!(ag[0], 1e3);
296        assert_eq!(ag[1], -1e3);
297        let ag = com_cof(Method::Trapezoidal, 2, 1e-3, &d).unwrap();
298        assert_eq!(ag[0], 2e3);
299        assert_eq!(ag[1], 1.0);
300    }
301
302    #[test]
303    fn gear_order_1_and_2_match_known_bdf() {
304        let d = [1e-3; 8];
305        // BDF1 on a uniform grid: ag = [1/h, -1/h].
306        let ag = com_cof(Method::Gear, 1, 1e-3, &d).unwrap();
307        assert!((ag[0] - 1e3).abs() < 1e-6);
308        assert!((ag[1] + 1e3).abs() < 1e-6);
309        // BDF2 uniform: [3/(2h), -2/h, 1/(2h)].
310        let ag = com_cof(Method::Gear, 2, 1e-3, &d).unwrap();
311        assert!((ag[0] - 1.5e3).abs() < 1e-6, "{}", ag[0]);
312        assert!((ag[1] + 2e3).abs() < 1e-6);
313        assert!((ag[2] - 0.5e3).abs() < 1e-6);
314    }
315
316    #[test]
317    fn trap_integrate_rc_step() {
318        // One backward-Euler step of a 1 F capacitor from 0 V to 1 V
319        // in 1 s: i = C dv/dt = 1 A; geq = 1, ceq = i - ag0*q = -0.
320        let mut st = States::new(2);
321        let d = [1.0; 8];
322        let ag = com_cof(Method::Trapezoidal, 1, 1.0, &d).unwrap();
323        st.set(1, 0, 0.0); // old charge
324        st.set(0, 0, 1.0); // new charge = C*v
325        let (geq, ceq) = integrate(&mut st.view(), &ag, 1, Method::Trapezoidal, 1.0, 0);
326        assert_eq!(geq, 1.0);
327        assert_eq!(st.get(0, 1), 1.0); // current
328        assert_eq!(ceq, 0.0);
329    }
330
331    /// One TR-BDF2 step of `q' = −λq` from `q = 1` through the engine's
332    /// own pieces (`com_cof` per stage, `integrate`, bank rotation):
333    /// each stage solves `ag0·q + ceq = −λq` for the new charge.
334    fn trbdf2_decay(lambda: f64, h: f64) -> f64 {
335        let mut st = States::new(2);
336        let mut d = [h; 8];
337        // Accepted point: q = 1, q' = −λ (consistent derivative).
338        st.set(0, 0, 1.0);
339        st.set(0, 1, -lambda);
340        let solve = |st: &mut States, ag: &[f64; 7], order, method| {
341            st.set(0, 0, 0.0);
342            let (_, ceq) = integrate(&mut st.view(), ag, order, method, 1.0, 0);
343            let q = -ceq / (ag[0] + lambda);
344            st.set(0, 0, q);
345            integrate(&mut st.view(), ag, order, method, 1.0, 0);
346        };
347        let d1 = TRBDF2_GAMMA * h;
348        st.rotate(2);
349        d[0] = d1;
350        let ag = com_cof(Method::Trapezoidal, 2, d1, &d).unwrap();
351        solve(&mut st, &ag, 2, Method::Trapezoidal);
352        st.rotate(2);
353        d.copy_within(0..7, 1);
354        d[0] = h - d1;
355        let ag = com_cof(Method::Gear, 2, h - d1, &d).unwrap();
356        solve(&mut st, &ag, 2, Method::Gear);
357        st.get(0, 0)
358    }
359
360    #[test]
361    fn trbdf2_stages_share_the_jacobian_coefficient() {
362        // γ = 2 − √2: 2/(γh) == (2 − γ)/((1 − γ)h), and com_cof's
363        // non-uniform BDF2 is the TR-BDF2 second stage.
364        let h = 1e-9;
365        let d1 = TRBDF2_GAMMA * h;
366        let mut d = [h; 8];
367        d[0] = d1;
368        let tr = com_cof(Method::Trapezoidal, 2, d1, &d).unwrap();
369        d.copy_within(0..7, 1);
370        d[0] = h - d1;
371        let bdf = com_cof(Method::Gear, 2, h - d1, &d).unwrap();
372        let rel = (tr[0] - bdf[0]).abs() / tr[0];
373        assert!(rel < 1e-12, "{} vs {}", tr[0], bdf[0]);
374        // Second stage: (1 − γ)/(2 − γ)·h·q'₁ = q₁ − q_γ/(γ(2−γ)) +
375        // (1−γ)²/(γ(2−γ))·q₀.
376        let g = TRBDF2_GAMMA;
377        let w = (1.0 - g) / (2.0 - g) * h;
378        assert!((bdf[0] * w - 1.0).abs() < 1e-12);
379        assert!((bdf[1] * w + 1.0 / (g * (2.0 - g))).abs() < 1e-12);
380        assert!((bdf[2] * w - (1.0 - g).powi(2) / (g * (2.0 - g))).abs() < 1e-12);
381        assert!(com_cof(Method::TrBdf2, 2, h, &d).is_err());
382    }
383
384    #[test]
385    fn trbdf2_is_l_stable_where_trap_rings() {
386        // Stiff mode, h/τ = 1e6: trap's amplification → −1 (the
387        // ringing of issue #6), TR-BDF2's → 0.
388        let (lambda, h) = (1e15, 1e-9);
389        let trap = (1.0 - lambda * h / 2.0) / (1.0 + lambda * h / 2.0);
390        assert!(trap < -0.99999);
391        let r = trbdf2_decay(lambda, h);
392        assert!(r.abs() < 1e-5, "{r}");
393        // Non-stiff: second order, local error |k|·(λh)³.
394        let (lambda, h) = (1.0, 1e-2);
395        let err = (trbdf2_decay(lambda, h) - (-lambda * h).exp()).abs();
396        let pred = TRBDF2_LTE_K.abs() * (lambda * h).powi(3);
397        assert!((err / pred - 1.0).abs() < 0.05, "{err} vs {pred}");
398    }
399
400    #[test]
401    fn trbdf2_lte_estimate_is_exact_on_a_cubic() {
402        // q = t³ on {t0, t0+γh, t0+h}: q'[·,·,·] = q'''/2 = 3 exactly;
403        // the composite's measure is |k|·3/3 and del = √(trtol·tol/m).
404        let (t0, h) = (0.3, 1e-2);
405        let d1 = TRBDF2_GAMMA * h;
406        let mut st = States::new(2);
407        for (bank, t) in [(2, t0), (1, t0 + d1), (0, t0 + h)] {
408            st.set(bank, 0, t * t * t);
409            st.set(bank, 1, 3.0 * t * t);
410        }
411        let mut d = [h; 8];
412        d[0] = h - d1;
413        d[1] = d1;
414        let ctx = TerrCtx {
415            states: &st,
416            sol: &[],
417            time: t0 + h,
418            delta_old: &d,
419            step_old: &[h; 8],
420            order: 2,
421            method: Method::TrBdf2,
422            delta: h,
423            reltol: 1e-3,
424            abstol: 1e-12,
425            chgtol: 1e-14,
426            trtol: 7.0,
427        };
428        let mut del = f64::MAX;
429        terr(0, &ctx, &mut del);
430        let tq = f64::max((t0 + h).powi(3), t0.powi(3));
431        let ti = 3.0 * f64::max((t0 + h).powi(2), t0.powi(2));
432        let tol = f64::max(1e-12 + 1e-3 * ti, 1e-3 * tq / h);
433        let want = (7.0 * tol / (TRBDF2_LTE_K.abs() * 3.0 / 3.0)).sqrt();
434        assert!((del / want - 1.0).abs() < 1e-6, "{del} vs {want}");
435    }
436}